From 1f8525abca2e2646b90eaf45aa3eeabd7e44e06f Mon Sep 17 00:00:00 2001 From: "tfe-app[bot]" <200245884+tfe-app[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:51:28 +0000 Subject: [PATCH 001/196] Initial commit --- README.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f6973b --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# mobile-bench-rs +Benchmarking tool for Rust functions on mobile devices using BrowserStack. From b4a55efb91c5b174fe8ec072fbd44b2e71dca528 Mon Sep 17 00:00:00 2001 From: wld-terraform Date: Thu, 4 Dec 2025 12:51:39 +0100 Subject: [PATCH 002/196] Add .github/workflows/relyance-sci.yml --- .github/workflows/relyance-sci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/relyance-sci.yml diff --git a/.github/workflows/relyance-sci.yml b/.github/workflows/relyance-sci.yml new file mode 100644 index 0000000..fb33cf3 --- /dev/null +++ b/.github/workflows/relyance-sci.yml @@ -0,0 +1,23 @@ +name: Relyance SCI Scan + +on: + schedule: + - cron: "43 0 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + execute-relyance-sci: + name: Relyance SCI Job + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Run Relyance SCI + uses: worldcoin/gh-actions-public/relyance@main + # More information: https://github.com/worldcoin/gh-actions-public/tree/main/relyance + with: + secrets-dpp-sci-key: ${{ secrets.DPP_SCI_KEY }} From 0c4c6e4052b245fcedec05fe6493a7b9a1e0238f Mon Sep 17 00:00:00 2001 From: wld-terraform Date: Tue, 16 Dec 2025 13:35:57 +0100 Subject: [PATCH 003/196] Update .github/workflows/relyance-sci.yml --- .github/workflows/relyance-sci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/relyance-sci.yml b/.github/workflows/relyance-sci.yml index fb33cf3..570cf90 100644 --- a/.github/workflows/relyance-sci.yml +++ b/.github/workflows/relyance-sci.yml @@ -11,7 +11,7 @@ permissions: jobs: execute-relyance-sci: name: Relyance SCI Job - runs-on: ubuntu-latest + runs-on: arc-public-large-amd64-runner permissions: contents: read From 29669ce75a3a8bb052489c91aa8df8fa1d3daee1 Mon Sep 17 00:00:00 2001 From: wld-terraform Date: Tue, 16 Dec 2025 17:20:45 +0100 Subject: [PATCH 004/196] Update .github/workflows/relyance-sci.yml --- .github/workflows/relyance-sci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/relyance-sci.yml b/.github/workflows/relyance-sci.yml index 570cf90..95feb3b 100644 --- a/.github/workflows/relyance-sci.yml +++ b/.github/workflows/relyance-sci.yml @@ -11,7 +11,8 @@ permissions: jobs: execute-relyance-sci: name: Relyance SCI Job - runs-on: arc-public-large-amd64-runner + runs-on: + group: arc-public-large-amd64-runner permissions: contents: read From ce6f464d12577db49a973bfcbacef5a536ea9b3d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 12 Jan 2026 07:38:29 -0300 Subject: [PATCH 005/196] Bootstrap mobile-bench-rs with UniFFI integration, BrowserStack runs/fetch, Android logging, iOS UI tests, and docs (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial commit * Fix Android UniFFI integration and benchmark display This commit resolves multiple issues preventing the Android app from loading the Rust native library and correctly displaying benchmark results. Changes implemented: 1. Added JNA dependency (android/app/build.gradle) - Added net.java.dev.jna:jna:5.14.0@aar dependency - Required for UniFFI's Kotlin bindings to interface with native code - Also added NDK version specification and benchmark spec asset directories 2. Fixed library naming mismatch (crates/sample-fns/Cargo.toml) - Set explicit lib name = "sample_fns" (not uniffi_sample_fns) - Changed crate-type to ["lib", "cdylib", "staticlib"] for UniFFI - Added UniFFI dependencies and build configuration - Migrated from JNI to UniFFI for cleaner FFI bindings 3. Updated sync script for library renaming (scripts/sync-android-libs.sh) - Added TARGET_LIB_NAME variable to rename .so during copy - Rust builds libsample_fns.so but JNA expects libuniffi_sample_fns.so - Script now copies and renames: libsample_fns.so -> libuniffi_sample_fns.so - Ensures FFI symbol prefixes match UniFFI expectations 4. Updated Android MainActivity (android/app/src/main/java/dev/world/bench/MainActivity.kt) - Changed System.loadLibrary("sample_fns") to System.loadLibrary("uniffi_sample_fns") - Migrated from JNI to UniFFI-generated Kotlin bindings - Updated benchmark display to show microseconds (μs) instead of milliseconds - Added raw nanosecond display alongside formatted values - Improved error handling with UniFFI's BenchException variants - Added support for loading benchmark specs from assets (bench_spec.json) Root cause analysis: - UniFFI generates Kotlin bindings that expect library name "uniffi_" - The namespace from sample_fns.udl is "sample_fns" - JNA looks for libuniffi_sample_fns.so based on this naming convention - Rust builds libsample_fns.so by default (from crate name) - Solution: Keep Rust lib name simple, rename during Android integration Benchmark display fix: - Functions execute in 0-42 nanoseconds (extremely fast) - Previous millisecond display showed "0.000 ms" for all samples - Now displays in microseconds with raw nanosecond values for clarity - Example: "0.042 μs (42 ns)" instead of "0.000 ms" Testing: - App builds successfully with ./gradlew :app:assembleDebug - Native library loads without UnsatisfiedLinkError - Benchmarks execute and display timing results correctly - Fibonacci(24) averages ~17 nanoseconds per iteration on ARM64 * Android: log benchmark JSON and fix test matcher * iOS: add UI test target and accessibility id * bench-cli: add BrowserStack fetch and artifact download * Commit remaining workspace changes * Ignore generated build artifacts * gitignore: exclude CLI-generated artifacts - Add run-summary.json (benchmark run results) - Add bench-config.toml (user configuration template) - Add device-matrix.yaml (device matrix template) These files are generated by bench-cli and should not be tracked in git. Co-Authored-By: Claude Sonnet 4.5 * iOS: standardize time units to microseconds Change benchmark result display from milliseconds (ms) to microseconds (μs) to match Android's output format. This provides better granularity for typical function benchmarks and makes cross-platform comparison easier. Before: 0.045 ms After: 45.320 μs (45320 ns) Also add raw nanosecond values in parentheses to match Android formatting. Co-Authored-By: Claude Sonnet 4.5 * bench-cli: add comprehensive BrowserStack client tests Add 11 new tests covering: - Client initialization and configuration - URL construction and path handling - Input validation for schedule/upload methods - Error cases for missing artifacts - Both Espresso (Android) and XCUITest (iOS) code paths Also suppress dead_code warning for test-only with_base_url helper. Test coverage increased from 1 to 12 tests for browserstack.rs module. Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: dcbuilder.eth Co-authored-by: Claude Sonnet 4.5 --- .github/workflows/mobile-bench.yml | 133 ++ .gitignore | 21 + BUILD.md | 415 ++++ CLAUDE.md | 371 +++ Cargo.lock | 2095 +++++++++++++++++ Cargo.toml | 22 + PROJECT_PLAN.md | 48 + README.md | 198 ++ TESTING.md | 564 +++++ android/.gitignore | 5 + android/README.md | 22 + android/app/build.gradle | 69 + android/app/proguard-rules.pro | 4 + .../java/dev/world/bench/MainActivityTest.kt | 25 + android/app/src/main/AndroidManifest.xml | 21 + .../main/java/dev/world/bench/MainActivity.kt | 167 ++ .../main/java/uniffi/sample_fns/sample_fns.kt | 1206 ++++++++++ .../app/src/main/jniLibs/arm64-v8a/.gitkeep | 1 + .../app/src/main/jniLibs/armeabi-v7a/.gitkeep | 1 + .../app/src/main/res/layout/activity_main.xml | 16 + android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 5 + android/build.gradle | 21 + android/gradle.properties | 3 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43739 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 251 ++ android/gradlew.bat | 94 + android/settings.gradle | 2 + crates/bench-cli/Cargo.toml | 20 + crates/bench-cli/src/browserstack.rs | 514 ++++ crates/bench-cli/src/main.rs | 1052 +++++++++ crates/bench-runner/Cargo.toml | 9 + crates/bench-runner/src/lib.rs | 92 + crates/sample-fns/Cargo.toml | 29 + crates/sample-fns/build.rs | 3 + .../sample-fns/src/bin/generate-bindings.rs | 77 + crates/sample-fns/src/lib.rs | 221 ++ crates/sample-fns/src/sample_fns.udl | 26 + .../BenchRunner/BenchRunner-Bridging-Header.h | 8 + .../BenchRunner/BenchRunnerApp.swift | 10 + .../BenchRunner/BenchRunnerFFI.swift | 118 + ios/BenchRunner/BenchRunner/ContentView.swift | 25 + .../BenchRunner/Generated/sample_fns.swift | 806 +++++++ .../BenchRunner/Generated/sample_fnsFFI.h | 551 +++++ .../Generated/sample_fnsFFI.modulemap | 4 + ios/BenchRunner/BenchRunner/Info.plist | 22 + .../BenchRunnerUITests.swift | 12 + ios/BenchRunner/project.yml | 37 + scripts/bindgen.rs | 56 + scripts/build-android-app.sh | 34 + scripts/build-android.sh | 35 + scripts/build-ios.sh | 183 ++ scripts/generate-bindings.sh | 42 + scripts/sync-android-libs.sh | 39 + 55 files changed, 9816 insertions(+) create mode 100644 .github/workflows/mobile-bench.yml create mode 100644 .gitignore create mode 100644 BUILD.md create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 PROJECT_PLAN.md create mode 100644 TESTING.md create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/androidTest/java/dev/world/bench/MainActivityTest.kt create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/dev/world/bench/MainActivity.kt create mode 100644 android/app/src/main/java/uniffi/sample_fns/sample_fns.kt create mode 100644 android/app/src/main/jniLibs/arm64-v8a/.gitkeep create mode 100644 android/app/src/main/jniLibs/armeabi-v7a/.gitkeep create mode 100644 android/app/src/main/res/layout/activity_main.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle create mode 100644 crates/bench-cli/Cargo.toml create mode 100644 crates/bench-cli/src/browserstack.rs create mode 100644 crates/bench-cli/src/main.rs create mode 100644 crates/bench-runner/Cargo.toml create mode 100644 crates/bench-runner/src/lib.rs create mode 100644 crates/sample-fns/Cargo.toml create mode 100644 crates/sample-fns/build.rs create mode 100644 crates/sample-fns/src/bin/generate-bindings.rs create mode 100644 crates/sample-fns/src/lib.rs create mode 100644 crates/sample-fns/src/sample_fns.udl create mode 100644 ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h create mode 100644 ios/BenchRunner/BenchRunner/BenchRunnerApp.swift create mode 100644 ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift create mode 100644 ios/BenchRunner/BenchRunner/ContentView.swift create mode 100644 ios/BenchRunner/BenchRunner/Generated/sample_fns.swift create mode 100644 ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h create mode 100644 ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.modulemap create mode 100644 ios/BenchRunner/BenchRunner/Info.plist create mode 100644 ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift create mode 100644 ios/BenchRunner/project.yml create mode 100644 scripts/bindgen.rs create mode 100755 scripts/build-android-app.sh create mode 100755 scripts/build-android.sh create mode 100755 scripts/build-ios.sh create mode 100755 scripts/generate-bindings.sh create mode 100755 scripts/sync-android-libs.sh diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml new file mode 100644 index 0000000..ecdbb7a --- /dev/null +++ b/.github/workflows/mobile-bench.yml @@ -0,0 +1,133 @@ +name: Mobile Bench (manual) + +on: + workflow_dispatch: + inputs: + platform: + description: "android | ios | both" + required: false + default: "android" + +jobs: + tests: + name: Host tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cargo test + run: cargo test --all + + android: + if: ${{ github.event.inputs.platform == 'android' || github.event.inputs.platform == 'both' || github.event.inputs.platform == '' }} + name: Android build (APK) + needs: tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-ndk + run: cargo install cargo-ndk + + - name: Setup Android SDK/NDK + uses: android-actions/setup-android@v3 + with: + packages: | + platform-tools + platforms;android-34 + build-tools;34.0.0 + ndk;26.1.10909125 + + - name: Build Rust shared libs for Android + run: scripts/build-android.sh + + - name: Sync .so into Android app + run: scripts/sync-android-libs.sh + + - name: Setup Gradle + uses: gradle/gradle-build-action@v3 + with: + gradle-version: 8.7 + + - name: Assemble APK + working-directory: android + run: gradle :app:assembleDebug + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: mobile-bench-android-apk + path: android/app/build/outputs/apk/debug/*.apk + + ios: + if: ${{ github.event.inputs.platform == 'ios' || github.event.inputs.platform == 'both' || github.event.inputs.platform == '' }} + name: iOS xcframework + needs: tests + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim + + - name: Install cargo-apple and cbindgen + run: | + cargo install cargo-apple + cargo install cbindgen + + - name: Build iOS xcframework + header + run: scripts/build-ios.sh + + - name: Upload iOS artifacts + uses: actions/upload-artifact@v4 + with: + name: mobile-bench-ios + path: | + target/ios/sample_fns.xcframework + target/ios/include/sample_fns.h + + browserstack-stub: + name: BrowserStack stub run + needs: android + if: ${{ secrets.BROWSERSTACK_USERNAME != '' && secrets.BROWSERSTACK_ACCESS_KEY != '' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download APK from build + uses: actions/download-artifact@v4 + with: + name: mobile-bench-android-apk + path: artifacts + + - name: Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Local bench summary (stub) + run: | + cargo run -p bench-cli -- run \ + --target android \ + --function sample_fns::fibonacci \ + --iterations 5 \ + --warmup 1 \ + --devices "browserstack-stub" \ + --local-only \ + --output run-summary.json + + - name: Upload run summary + uses: actions/upload-artifact@v4 + with: + name: browserstack-run-summary + path: run-summary.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3715aa4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +target/ +**/*.DS_Store +.vscode/ +android/app/src/main/jniLibs/*/libsample_fns.so +android/app/src/main/jniLibs/*/libuniffi_sample_fns.so +android/build/ +android/.gradle/ +android/app/build/ +**/.gradle/ +**/build/ +ios/BenchRunner/BenchRunner.xcodeproj/ +**/xcuserdata/ +**/DerivedData/ +ios/build/ +target/ios/ +.env.local + +# CLI-generated artifacts +run-summary.json +bench-config.toml +device-matrix.yaml diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..c7bd1f6 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,415 @@ +# Build Reference Guide + +Complete build instructions for Android and iOS targets. + +## Table of Contents +- [Prerequisites](#prerequisites) +- [Android Build](#android-build) +- [iOS Build](#ios-build) +- [Common Issues](#common-issues) + +## Prerequisites + +### All Platforms +```bash +# Install Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Verify installation +rustc --version +cargo --version +``` + +### Android +```bash +# Install Android NDK via Android Studio or sdkmanager +# Set environment variable (add to ~/.zshrc or ~/.bashrc) +export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/29.0.14206865 + +# Install required Rust targets +rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android + +# Install cargo-ndk +cargo install cargo-ndk + +# Verify NDK installation +ls $ANDROID_NDK_HOME +``` + +### iOS (macOS only) +```bash +# Install Xcode from App Store + +# Install command-line tools +xcode-select --install + +# Install xcodegen +brew install xcodegen + +# Install required Rust targets +rustup target add aarch64-apple-ios aarch64-apple-ios-sim + +# Verify installation +xcodegen --version +xcodebuild -version +``` + +## Android Build + +### Quick Start (Recommended) +```bash +# Build everything and create APK in one command +./scripts/build-android-app.sh + +# Install on connected device or emulator +adb install -r android/app/build/outputs/apk/debug/app-debug.apk + +# Launch the app +adb shell am start -n dev.world.bench/.MainActivity +``` + +### Step-by-Step Build + +#### Step 1: Build Rust Libraries +```bash +./scripts/build-android.sh +``` + +This compiles Rust code for three Android ABIs: +- `aarch64-linux-android` → `arm64-v8a` (64-bit ARM devices) +- `armv7-linux-androideabi` → `armeabi-v7a` (32-bit ARM devices) +- `x86_64-linux-android` → `x86_64` (x86 emulators) + +Output: `target/android/{abi}/release/libsample_fns.so` + +#### Step 2: Sync Libraries to Android Project +```bash +./scripts/sync-android-libs.sh +``` + +This copies `.so` files to `android/app/src/main/jniLibs/{abi}/libsample_fns.so` where Android's build system expects them. + +#### Step 3: Build APK with Gradle +```bash +cd android +./gradlew :app:assembleDebug +cd .. +``` + +Output: `android/app/build/outputs/apk/debug/app-debug.apk` + +#### Step 4: Install and Run +```bash +# Install +adb install -r android/app/build/outputs/apk/debug/app-debug.apk + +# Launch with default parameters +adb shell am start -n dev.world.bench/.MainActivity + +# Or launch with custom benchmark parameters +adb shell am start -n dev.world.bench/.MainActivity \ + --es bench_function sample_fns::checksum \ + --ei bench_iterations 50 \ + --ei bench_warmup 10 +``` + +### Using Android Studio +1. Build Rust libraries first: + ```bash + ./scripts/build-android.sh + ./scripts/sync-android-libs.sh + ``` + +2. Open the `android/` directory in Android Studio + +3. Wait for Gradle sync to complete + +4. Click Run (green play button) or Run → Run 'app' + +5. Select target device/emulator + +### Rebuild After Code Changes +```bash +# If Rust code changed +./scripts/build-android.sh +./scripts/sync-android-libs.sh + +# If only Kotlin/Java changed +cd android && ./gradlew :app:assembleDebug + +# Full clean rebuild +cargo clean +./scripts/build-android-app.sh +``` + +## iOS Build + +### Quick Start (Recommended) +```bash +# Build Rust xcframework (includes automatic code signing) +./scripts/build-ios.sh + +# Generate Xcode project +cd ios/BenchRunner +xcodegen generate + +# Open in Xcode +open BenchRunner.xcodeproj +``` + +Then in Xcode: +- Select a simulator (e.g., iPhone 15) from the device menu +- Click Run (⌘+R) + +### Step-by-Step Build + +#### Step 1: Build Rust XCFramework +```bash +./scripts/build-ios.sh +``` + +This script: +1. Compiles Rust for iOS targets: + - `aarch64-apple-ios` (physical devices) + - `aarch64-apple-ios-sim` (M1+ Mac simulators) + +2. Creates xcframework with structure: + ``` + target/ios/sample_fns.xcframework/ + ├── Info.plist + ├── ios-arm64/ + │ └── sample_fns.framework/ + │ ├── sample_fns (static library) + │ ├── Headers/ + │ │ ├── sample_fnsFFI.h + │ │ └── module.modulemap + │ └── Info.plist + └── ios-simulator-arm64/ + └── sample_fns.framework/ + ├── sample_fns (static library) + ├── Headers/ + │ ├── sample_fnsFFI.h + │ └── module.modulemap + └── Info.plist + ``` + +3. Copies UniFFI-generated C headers into each framework slice + +4. Creates module maps for Swift interoperability + +5. **Automatically code-signs the xcframework** (required for Xcode) + +Output: `target/ios/sample_fns.xcframework` (signed) + +**Note**: The build script now includes automatic code signing. If signing fails for any reason, you can sign manually: +```bash +codesign --force --deep --sign - target/ios/sample_fns.xcframework +``` + +Code signing is **required** for Xcode to accept and link the framework. Without signing, you'll see "The Framework 'sample_fns.xcframework' is unsigned" errors. + +#### Step 2: Generate Xcode Project +```bash +cd ios/BenchRunner +xcodegen generate +``` + +This generates `BenchRunner.xcodeproj` from `project.yml` specification. The generated project includes: +- Source files from `BenchRunner/` directory +- Generated Swift bindings (`BenchRunner/Generated/sample_fns.swift`) +- Bridging header (`BenchRunner/BenchRunner-Bridging-Header.h`) +- Framework dependency on `../../target/ios/sample_fns.xcframework` + +#### Step 3: Build and Run in Xcode +```bash +open BenchRunner.xcodeproj +``` + +In Xcode: +1. Select scheme: **BenchRunner** +2. Select destination: **iPhone 15** (or any simulator, or physical device) +3. Click Run (⌘+R) or Product → Run + +The app will launch and display benchmark results. + +### Custom Benchmark Parameters + +#### Method 1: Environment Variables in Xcode +1. Product → Scheme → Edit Scheme... +2. Run → Arguments → Environment Variables +3. Add variables: + - `BENCH_FUNCTION` = `sample_fns::checksum` + - `BENCH_ITERATIONS` = `50` + - `BENCH_WARMUP` = `10` +4. Close and run + +#### Method 2: Command Line (Simulator) +```bash +# Build for simulator +xcodebuild -project ios/BenchRunner/BenchRunner.xcodeproj \ + -scheme BenchRunner \ + -destination 'platform=iOS Simulator,name=iPhone 15' \ + -derivedDataPath ios/build + +# Launch with arguments +xcrun simctl launch booted dev.world.bench \ + --bench-function=sample_fns::checksum \ + --bench-iterations=50 \ + --bench-warmup=10 +``` + +### Rebuild After Code Changes +```bash +# If Rust code changed (includes automatic signing) +./scripts/build-ios.sh + +# If Swift code changed, just rebuild in Xcode (⌘+B) + +# If project.yml changed +cd ios/BenchRunner +xcodegen generate +open BenchRunner.xcodeproj + +# Full clean rebuild +cargo clean +./scripts/build-ios.sh +cd ios/BenchRunner +xcodegen generate +# Clean in Xcode (⌘+Shift+K) then build (⌘+B) +``` + +### Important iOS Notes + +**Static Frameworks**: The xcframework contains static libraries (`.a` files), not dynamic frameworks. This means: +- The framework is linked at compile time +- No module import is needed in Swift (`import sample_fns` is NOT used) +- A bridging header exposes C FFI types to Swift +- The UniFFI-generated Swift bindings are compiled directly into the app + +**Bridging Header**: The project uses `BenchRunner-Bridging-Header.h` to import the C FFI: +```objc +#import "sample_fnsFFI.h" +``` + +This makes C types (`RustBuffer`, `RustCallStatus`, etc.) available to Swift without explicit imports. + +**Code Signing**: The build script (`build-ios.sh`) automatically signs the xcframework. If you build manually or signing fails, sign with: +```bash +codesign --force --deep --sign - target/ios/sample_fns.xcframework +``` + +## Common Issues + +### Android + +**Issue**: `ANDROID_NDK_HOME is not set` +```bash +# Find your NDK installation +find ~/Library/Android/sdk/ndk -name "ndk-build" 2>/dev/null + +# Export the path (add to ~/.zshrc or ~/.bashrc) +export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/29.0.14206865 +``` + +**Issue**: `cargo-ndk: command not found` +```bash +cargo install cargo-ndk +``` + +**Issue**: App crashes with `UnsatisfiedLinkError` +```bash +# Ensure .so files are in the APK +./scripts/sync-android-libs.sh +cd android && ./gradlew clean assembleDebug + +# Verify .so files are in APK +unzip -l android/app/build/outputs/apk/debug/app-debug.apk | grep libsample_fns.so +``` + +**Issue**: `Error: UnknownFunction` +- Check function name is correct: `fibonacci`, `checksum`, `sample_fns::fibonacci`, `sample_fns::checksum` +- Function names are case-sensitive + +### iOS + +**Issue**: `xcodegen: command not found` +```bash +brew install xcodegen +``` + +**Issue**: "The Framework 'sample_fns.xcframework' is unsigned" +```bash +codesign --force --deep --sign - target/ios/sample_fns.xcframework +``` + +**Issue**: "While building for iOS Simulator, no library for this platform was found" +```bash +# Rebuild with correct structure +rm -rf target/ios/sample_fns.xcframework +./scripts/build-ios.sh +codesign --force --deep --sign - target/ios/sample_fns.xcframework + +# Clean Xcode build +cd ios/BenchRunner +xcodebuild clean -project BenchRunner.xcodeproj -scheme BenchRunner +``` + +**Issue**: "Unable to find module dependency: 'sample_fns'" +- Remove any `import sample_fns` statements from Swift code +- The types are available globally via the bridging header + +**Issue**: "Cannot find type 'RustBuffer' in scope" +```bash +# Ensure bridging header exists +cat ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h + +# Should contain: +# #import "sample_fnsFFI.h" + +# Regenerate project +cd ios/BenchRunner +xcodegen generate +``` + +**Issue**: "framework 'ios-simulator-arm64' not found" +- The framework binary or directory structure is incorrect +- Rebuild: `./scripts/build-ios.sh` +- Verify structure: Each framework should be named `sample_fns.framework`, not the platform identifier + +**Issue**: "Framework had an invalid CFBundleIdentifier" +- Framework bundle ID conflicts with app bundle ID +- Check `scripts/build-ios.sh` uses `dev.world.sample-fns` for framework +- App uses `dev.world.bench` + +## UniFFI Bindings + +If you modify the Rust API (`crates/sample-fns/src/sample_fns.udl`): + +```bash +# Regenerate bindings +cargo run --bin generate-bindings --features bindgen + +# This updates: +# - android/app/src/main/java/uniffi/sample_fns/sample_fns.kt (Kotlin) +# - ios/BenchRunner/BenchRunner/Generated/sample_fns.swift (Swift) +# - ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h (C header) + +# Then rebuild mobile apps +./scripts/build-android-app.sh # Android +./scripts/build-ios.sh # iOS +codesign --force --deep --sign - target/ios/sample_fns.xcframework +``` + +## Performance Testing + +Run benchmarks locally without mobile builds: +```bash +cargo run -p bench-cli -- demo --iterations 100 --warmup 10 +``` + +## Additional Documentation + +- **`TESTING.md`**: Comprehensive testing guide with troubleshooting +- **`README.md`**: Project overview and quick start +- **`CLAUDE.md`**: Developer guide for working with this codebase +- **`PROJECT_PLAN.md`**: Architecture and roadmap diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f8f1456 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,371 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +mobile-bench-rs is a benchmarking tool for Rust functions on mobile devices (Android/iOS) using BrowserStack AppAutomate. It packages Rust functions into mobile binaries, runs them on real devices, and collects timing metrics. + +## Core Architecture + +### Workspace Structure + +The repository is organized as a Cargo workspace with three main crates: + +- **`bench-cli`**: CLI orchestrator that drives the entire workflow - building artifacts, uploading to BrowserStack, executing runs, and collecting results. Entry point for all operations. +- **`bench-runner`**: Lightweight harness library that gets embedded in mobile binaries. Provides timing infrastructure for benchmarks. +- **`sample-fns`**: Example benchmark functions with UniFFI bindings for mobile platforms. Compiled as `cdylib`, `staticlib`, and `rlib` for different mobile targets. + +### Mobile Integration Flow + +1. **Build Phase**: Rust functions are compiled to native libraries (`.so` for Android, `.a` for iOS) +2. **Bindings Generation**: UniFFI generates type-safe Kotlin/Swift bindings from `sample_fns.udl` +3. **Packaging**: Libraries and generated bindings are embedded into mobile apps (Android APK, iOS xcframework) +4. **Execution**: Apps read benchmark specs from: + - Android: Intent extras or `bench_spec.json` asset + - iOS: Environment variables, launch args, or `bench_spec.json` bundle resource +5. **FFI Boundary**: Mobile apps call `runBenchmark(spec)` via UniFFI-generated bindings which provide type-safe access to Rust code + +### BrowserStack Integration + +The CLI supports both Espresso (Android) and XCUITest (iOS) test automation frameworks: + +- **Android**: Uploads app APK + test-suite APK (androidTest), schedules Espresso runs +- **iOS**: Uploads app IPA/bundle + XCUITest runner package, schedules XCUITest runs +- Credentials: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY` (from env or config) + +## Build and Testing Documentation + +**Primary Documentation:** +- **`BUILD.md`**: Complete build reference with prerequisites, step-by-step instructions, and troubleshooting for both Android and iOS +- **`TESTING.md`**: Comprehensive testing guide with advanced scenarios and detailed troubleshooting + +For comprehensive testing instructions, see **`TESTING.md`** which includes: +- Prerequisites and setup +- Host testing (cargo test, CLI demo) +- Android testing (emulator, device, Android Studio) +- iOS testing (simulator, device, Xcode) +- Troubleshooting common issues +- Advanced testing scenarios + +Quick test commands: +```bash +# Run all Rust tests +cargo test --all + +# Test host harness +cargo run -p bench-cli -- demo --iterations 10 --warmup 2 + +# Android e2e (requires Android NDK) +scripts/build-android-app.sh +adb install -r android/app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n dev.world.bench/.MainActivity + +# iOS e2e (requires Xcode) +scripts/build-ios.sh +cd ios/BenchRunner && xcodegen generate && open BenchRunner.xcodeproj +``` + +## Common Commands + +### Building + +#### Android +```bash +# Build Rust shared libraries for Android (requires Android NDK) +scripts/build-android.sh + +# Sync .so files into Android project structure +scripts/sync-android-libs.sh + +# Build complete APK with Gradle +cd android && gradle :app:assembleDebug + +# Or use the full build script +scripts/build-android-app.sh +``` + +Requirements: +- `ANDROID_NDK_HOME` environment variable set +- `cargo-ndk` installed: `cargo install cargo-ndk` +- Android SDK/NDK available (API level 24+) + +#### iOS +```bash +# Build Rust xcframework for iOS (includes UniFFI headers and automatic signing) +scripts/build-ios.sh + +# Generate Xcode project from project.yml +cd ios/BenchRunner && xcodegen generate + +# Open in Xcode +open BenchRunner.xcodeproj +``` + +Requirements: +- Xcode command-line tools +- Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim` +- `xcodegen` installed: `brew install xcodegen` + +**Important iOS Build Details:** + +The `build-ios.sh` script creates an xcframework with the following structure: +``` +target/ios/sample_fns.xcframework/ +├── Info.plist # XCFramework manifest +├── ios-arm64/ # Device slice +│ └── sample_fns.framework/ +│ ├── sample_fns # Static library (libsample_fns.a) +│ ├── Headers/ +│ │ ├── sample_fnsFFI.h # UniFFI-generated C header +│ │ └── module.modulemap # Module map for Swift import +│ └── Info.plist +└── ios-simulator-arm64/ # Simulator slice (M1+ Macs) + └── sample_fns.framework/ + ├── sample_fns # Static library (libsample_fns.a) + ├── Headers/ + │ ├── sample_fnsFFI.h + │ └── module.modulemap + └── Info.plist +``` + +**Key Configuration Details:** +- Framework binary must be named `sample_fns` (the module name), not the platform identifier +- Each framework slice must be in `{LibraryIdentifier}/sample_fns.framework/` directory structure +- Module map defines the C module as `sample_fnsFFI` (matches what UniFFI-generated Swift code imports) +- Info.plist uses `iPhoneOS`/`iPhoneSimulator` platform identifiers with `SupportedPlatformVariant` +- Framework bundle ID is `dev.world.sample-fns` (must not conflict with app bundle ID `dev.world.bench`) +- The Xcode project uses a bridging header (`BenchRunner-Bridging-Header.h`) to expose C FFI types to Swift +- UniFFI-generated Swift bindings are compiled directly into the app (no `import sample_fns` needed) + +**Automatic Code Signing**: The build script automatically signs the xcframework with: +```bash +codesign --force --deep --sign - target/ios/sample_fns.xcframework +``` + +If automatic signing fails, the script will display a warning with instructions for manual signing. + +Note: UniFFI C headers are generated automatically during the build process and copied into each framework slice. + +### Benchmarking + +#### Local Smoke Test +```bash +cargo run -p bench-cli -- run \ + --target android \ + --function sample_fns::fibonacci \ + --iterations 100 \ + --warmup 10 \ + --local-only \ + --output run-summary.json +``` + +#### BrowserStack Run (Android) +```bash +cargo run -p bench-cli -- run \ + --target android \ + --function sample_fns::checksum \ + --iterations 30 \ + --warmup 5 \ + --devices "Pixel 7-13" \ + --output run-summary.json +``` + +#### BrowserStack Run (iOS) +```bash +cargo run -p bench-cli -- run \ + --target ios \ + --function sample_fns::fibonacci \ + --iterations 20 \ + --warmup 3 \ + --devices "iPhone 14-16" \ + --ios-app target/ios/BenchRunner.ipa \ + --ios-test-suite target/ios/BenchRunnerUITests.zip +``` + +#### Using Config Files +```bash +# Generate starter config +cargo run -p bench-cli -- init --output bench-config.toml --target android + +# Generate device matrix +cargo run -p bench-cli -- plan --output device-matrix.yaml + +# Run with config +cargo run -p bench-cli -- run --config bench-config.toml +``` + +## Key Implementation Details + +### FFI Boundary (`sample-fns`) + +This crate uses **UniFFI** to generate type-safe bindings for Kotlin and Swift. The API is defined in `crates/sample-fns/src/sample_fns.udl`: + +- **`runBenchmark(spec: BenchSpec) -> BenchReport`**: Main benchmark entrypoint with structured input/output +- **`BenchSpec`**: Struct containing `name` (function path), `iterations`, and `warmup` parameters +- **`BenchReport`**: Struct containing the original spec and a list of `BenchSample` timing results +- **`BenchError`**: Error enum with variants: `InvalidIterations`, `UnknownFunction`, `ExecutionFailed` + +Regenerate bindings after modifying the UDL: +```bash +cargo run --bin generate-bindings --features bindgen +``` + +Generated files (committed to git): +- Kotlin: `android/app/src/main/java/uniffi/sample_fns/sample_fns.kt` +- Swift: `ios/BenchRunner/BenchRunner/Generated/sample_fns.swift` +- C header: `ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h` + +### Mobile Spec Injection + +The CLI writes benchmark parameters to `target/mobile-spec/{android,ios}/bench_spec.json` during build. Mobile apps read this at runtime to know which function to benchmark. + +### BrowserStack Credentials + +Credentials are resolved in this order: +1. Config file (supports `${ENV_VAR}` expansion) +2. Environment variables: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `BROWSERSTACK_PROJECT` +3. `.env.local` file (loaded automatically via `dotenvy`) + +### CI/CD (`.github/workflows/mobile-bench.yml`) + +The workflow supports manual dispatch with platform selection: +- Runs host tests first +- Builds Android APK and/or iOS xcframework +- Uploads artifacts +- Optionally triggers BrowserStack runs (requires secrets) + +## Development Notes + +### Adding New Benchmark Functions + +1. Add function to `crates/sample-fns/src/lib.rs` +2. Add function dispatch to `run_benchmark()` match statement (e.g., `"my_func" => run_closure(spec, || my_func())`) +3. If the API surface changes (new public types or functions), update `sample_fns.udl` +4. Regenerate bindings: `cargo run --bin generate-bindings --features bindgen` +5. Rebuild native libraries: `scripts/build-android.sh` and/or `scripts/build-ios.sh` +6. Mobile apps will automatically use the updated bindings + +### Target Architectures + +- **Android**: `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` (emulator) +- **iOS**: `aarch64-apple-ios` (device), `aarch64-apple-ios-sim` (simulator on M1+ Macs) + +### XCFramework Structure + +`scripts/build-ios.sh` manually constructs an xcframework (not using `xcodebuild -create-xcframework`) by creating framework slices for each target with proper Info.plist and module.modulemap files. + +**Critical Implementation Details:** +1. **Directory Structure**: Each framework must be in `{LibraryIdentifier}/{FrameworkName}.framework/`, not directly at the root. For example: `ios-simulator-arm64/sample_fns.framework/`, not `ios-simulator-arm64.framework/`. + +2. **Framework Binary Naming**: The binary inside each framework slice must be named after the module (`sample_fns`), not the platform identifier (`ios-simulator-arm64`). This is what Xcode's linker expects. + +3. **Module Map**: The C module in `module.modulemap` must be named `sample_fnsFFI` to match what UniFFI-generated Swift code tries to import via `#if canImport(sample_fnsFFI)`. + +4. **Platform Identifiers**: The framework Info.plist uses Apple's official platform names: + - Device: `CFBundleSupportedPlatforms = ["iPhoneOS"]` + - Simulator: `CFBundleSupportedPlatforms = ["iPhoneSimulator"]` + + The xcframework Info.plist uses `SupportedPlatform = "ios"` with `SupportedPlatformVariant = "simulator"` for simulator slices. + +5. **Bundle Identifier**: The framework bundle ID must not conflict with the app's bundle ID. Use `dev.world.sample-fns` for the framework, while the app uses `dev.world.bench`. + +6. **Static vs Dynamic**: The xcframework contains static libraries (`.a` archives built with `staticlib` crate-type), not dynamic frameworks. This requires a bridging header in the Xcode project to expose C types to Swift. + +7. **Code Signing**: After building, the xcframework must be code-signed for Xcode to accept it: `codesign --force --deep --sign - target/ios/sample_fns.xcframework` + +### Gradle Integration (Android) + +The Android app expects `.so` files under `android/app/src/main/jniLibs/{abi}/libsample_fns.so`. The `sync-android-libs.sh` script copies them from `target/android/{abi}/release/` to the correct locations. + +## Configuration Files + +### `bench-config.toml` (generated by `init` command) +```toml +target = "android" +function = "sample_fns::fibonacci" +iterations = 100 +warmup = 10 +device_matrix = "device-matrix.yaml" + +[browserstack] +app_automate_username = "${BROWSERSTACK_USERNAME}" +app_automate_access_key = "${BROWSERSTACK_ACCESS_KEY}" +project = "mobile-bench-rs" + +# iOS only: +[ios_xcuitest] +app = "target/ios/BenchRunner.ipa" +test_suite = "target/ios/BenchRunnerUITests.zip" +``` + +### `device-matrix.yaml` (generated by `plan` command) +```yaml +devices: + - name: Pixel 7 + os: android + os_version: "13.0" + tags: [default, pixel] + - name: iPhone 14 + os: ios + os_version: "16" + tags: [default, iphone] +``` + +## Common iOS Build Issues and Solutions + +### Issue: "The Framework 'sample_fns.xcframework' is unsigned" +**Solution**: Code-sign the xcframework after building: +```bash +codesign --force --deep --sign - target/ios/sample_fns.xcframework +``` + +### Issue: "While building for iOS Simulator, no library for this platform was found" +**Root Cause**: Incorrect xcframework structure (frameworks at wrong path or incorrectly named). + +**Solution**: Ensure `build-ios.sh` creates the correct structure with frameworks in subdirectories: +``` +ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) +``` + +### Issue: "framework 'ios-simulator-arm64' not found" (linker error) +**Root Cause**: Framework LibraryPath in xcframework Info.plist points to wrong name. + +**Solution**: Verify xcframework Info.plist has: +```xml +LibraryPath +sample_fns.framework +``` + +### Issue: "Unable to find module dependency: 'sample_fns'" in Swift +**Root Cause**: Trying to import the module when it should be compiled directly into the app. + +**Solution**: Remove `import sample_fns` from Swift files. The UniFFI-generated Swift bindings are compiled into the app target, and C types are exposed via the bridging header. + +### Issue: "Cannot find type 'RustBuffer' in scope" +**Root Cause**: Bridging header missing or not configured. + +**Solution**: +1. Ensure `BenchRunner-Bridging-Header.h` exists with `#import "sample_fnsFFI.h"` +2. Verify `project.yml` has `SWIFT_OBJC_BRIDGING_HEADER` set +3. Regenerate Xcode project: `xcodegen generate` + +### Issue: "Framework had an invalid CFBundleIdentifier" +**Root Cause**: Framework bundle ID conflicts with app bundle ID. + +**Solution**: Use different bundle IDs: +- Framework: `dev.world.sample-fns` +- App: `dev.world.bench` + +## Important Files + +- **`PROJECT_PLAN.md`**: Goals, architecture, task backlog +- **`TESTING.md`**: Comprehensive testing guide with detailed troubleshooting +- **`scripts/build-android.sh`**: Builds Rust libs with cargo-ndk for Android targets +- **`scripts/build-ios.sh`**: Builds iOS xcframework with correct structure and code signing +- **`scripts/sync-android-libs.sh`**: Copies .so files into Android jniLibs structure +- **`android/app/src/main/java/dev/world/bench/MainActivity.kt`**: Android app entry point +- **`ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift`**: iOS FFI wrapper +- **`ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h`**: Objective-C bridging header for C FFI types +- **`ios/BenchRunner/project.yml`**: XcodeGen project specification +- **`crates/bench-cli/src/browserstack.rs`**: BrowserStack REST API client diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..30f40e2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2095 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bench-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "bench-runner", + "clap", + "dotenvy", + "reqwest", + "sample-fns", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "bench-runner" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.2.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reqwest" +version = "0.12.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "sample-fns" +version = "0.1.0" +dependencies = [ + "bench-runner", + "camino", + "serde", + "serde_json", + "thiserror 1.0.69", + "uniffi", + "uniffi_bindgen", +] + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "uniffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" +dependencies = [ + "anyhow", + "cargo_metadata", + "uniffi_bindgen", + "uniffi_build", + "uniffi_core", + "uniffi_macros", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cade167af943e189a55020eda2c314681e223f1e42aca7c4e52614c2b627698f" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "once_cell", + "paste", + "serde", + "textwrap", + "toml 0.5.11", + "uniffi_meta", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7cf32576e08104b7dc2a6a5d815f37616e66c6866c2a639fe16e6d2286b75b" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_checksum_derive" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802d2051a700e3ec894c79f80d2705b69d85844dafbbe5d1a92776f8f48b563a" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "uniffi_core" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" +dependencies = [ + "anyhow", + "bytes", + "log", + "once_cell", + "paste", + "static_assertions", +] + +[[package]] +name = "uniffi_macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c65a5b12ec544ef136693af8759fb9d11aefce740fb76916721e876639033b" +dependencies = [ + "bincode", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml 0.5.11", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a74ed96c26882dac1ca9b93ca23c827e284bacbd7ec23c6f0b0372f747d59e4" +dependencies = [ + "anyhow", + "bytes", + "siphasher", + "uniffi_checksum_derive", +] + +[[package]] +name = "uniffi_testing" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6f984f0781f892cc864a62c3a5c60361b1ccbd68e538e6c9fbced5d82268ac" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "fs-err", + "once_cell", +] + +[[package]] +name = "uniffi_udl" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037820a4cfc4422db1eaa82f291a3863c92c7d1789dc513489c36223f9b4cdfc" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "uniffi_testing", + "weedle2", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..50b805a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[workspace] +members = [ + "crates/bench-cli", + "crates/bench-runner", + "crates/sample-fns", +] +resolver = "2" + +[workspace.package] +edition = "2024" +license = "MIT OR Apache-2.0" +version = "0.1.0" + +[workspace.dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +serde_yaml = "0.9" +toml = "0.8" +uniffi = "0.28" diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 0000000..2092598 --- /dev/null +++ b/PROJECT_PLAN.md @@ -0,0 +1,48 @@ +# Mobile Bench RS – Plan + +## Goals + +- Package arbitrary Rust functions into Android (Kotlin) and iOS (Swift) binaries. +- Drive builds and benchmark runs via a Rust CLI that works locally and in GitHub Actions. +- Execute binaries on real devices through BrowserStack AppAutomate, collecting timing/telemetry and artifacts. +- Produce repeatable, configurable runs (device matrix, iterations, warmups) with exportable reports. + +## Non-Goals (for now) + +- Desktop or web benchmarks. +- Perf profiling beyond timing/throughput (e.g., flamegraphs, memory sampling). +- Real-time dashboards; focus on generated reports and CI annotations first. + +## Architecture Outline + +- `bench-cli`: Orchestrates builds, packaging, upload, AppAutomate sessions, and result collation. +- `bench-runner`: Minimal Rust harness compiled into mobile libs; exposes FFI entrypoints for target functions and collects timings. +- Mobile bindings: + - Android: Kotlin wrapper + APK test harness embedding Rust lib (cargo-ndk); uses Espresso/Appium-style entrypoints for AppAutomate. + - iOS: Swift wrapper + test host app/xcframework; invokes Rust via C-ABI bindings. +- CI: GitHub Actions workflows for build (per target), upload to BrowserStack, run matrix, fetch reports, and publish summary. + +## MVP Scope + +- Benchmark a single exported Rust function with configurable iterations. +- Build Android APK + iOS app/xcframework locally and in CI. +- Trigger one Android device run on BrowserStack and capture timing JSON. +- CLI command: `bench-cli run --target android --function path::to::fn --devices "pixel_7"` producing a report. + +## Task Backlog (initial) + +- [ ] Repo bootstrap: Cargo workspace, `bench-cli` binary crate, `bench-runner` library crate, example `sample-fns` crate. +- [ ] Define FFI boundary: macro/attribute to mark benchmarkable Rust functions; export through C ABI; basic timing harness. +- [ ] Android packaging: cargo-ndk config, Kotlin wrapper module, minimal test/activity to trigger Rust bench entrypoint. +- [ ] iOS packaging: xcframework build script (cargo lipo or cargo-apple), C header generation (cbindgen), Swift wrapper, test host. +- [ ] CLI scaffolding: parse config (function path, iterations, warmups, device matrix), invoke build scripts, prepare artifacts. +- [ ] BrowserStack integration: AppAutomate REST client (upload builds, start sessions, poll status, download logs/artifacts). +- [ ] Result handling: normalize timing output to JSON, aggregate across iterations/devices, emit markdown/CSV summary. +- [ ] CI: GitHub Actions workflow covering build, artifact upload, BrowserStack-triggered run (behind secrets), and report upload. +- [ ] Developer UX: local smoke test runners, sample bench functions, docs with step-by-step usage. +- [ ] Stretch: parallel device runs, retries, percentile stats, optional energy/thermal readings where available. + +## In-Repo Placeholders (current) +- Scripts: `scripts/build-android.sh`, `scripts/build-ios.sh` for manual/CI builds (require Android NDK / cargo-apple). +- Android demo app: `android/` Gradle project that loads the Rust demo cdylib (`sample-fns`) and displays results. +- Workflow: `.github/workflows/mobile-bench.yml` manual build for Android; extend with BrowserStack upload/run and iOS job. diff --git a/README.md b/README.md index 5f6973b..a795b06 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,200 @@ # mobile-bench-rs Benchmarking tool for Rust functions on mobile devices using BrowserStack. + +## Layout +- `crates/bench-cli`: CLI orchestrator for building/packaging benchmarks and driving BrowserStack runs (stubbed). +- `crates/bench-runner`: Shared harness that will be embedded in Android/iOS binaries; currently host-side only. +- `crates/sample-fns`: Small Rust functions used as demo benchmarks with UniFFI bindings for mobile platforms. +- `PROJECT_PLAN.md`: Goals, architecture outline, and initial task backlog. +- `android/`: Minimal Android app that loads the Rust demo library; Gradle project for BrowserStack/AppAutomate runs. + +## Quick Start + +### Host Demo (No Mobile Build Required) +Test the benchmarking harness locally: +```bash +cargo run -p bench-cli -- demo --iterations 10 --warmup 2 +``` + +### Mobile Testing +For complete end-to-end testing on Android/iOS, see the **[End-to-End Testing](#end-to-end-testing)** section below. + +**Quick commands:** +- **Android**: `scripts/build-android-app.sh` then install APK +- **iOS**: `scripts/build-ios.sh` then open in Xcode + +### Generate Config Files +```bash +cargo run -p bench-cli -- init --output bench-config.toml +cargo run -p bench-cli -- plan --output device-matrix.yaml +``` + +## UniFFI Bindings + +This project uses [UniFFI](https://mozilla.github.io/uniffi-rs/) to generate type-safe Kotlin and Swift bindings from Rust. + +To regenerate bindings after modifying the API (`crates/sample-fns/src/sample_fns.udl`): +```bash +cargo run --bin generate-bindings --features bindgen +``` + +Generated files (committed to git for reproducibility): +- **Kotlin**: `android/app/src/main/java/uniffi/sample_fns/sample_fns.kt` +- **Swift**: `ios/BenchRunner/BenchRunner/Generated/sample_fns.swift` +- **C header**: `ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h` + +The UniFFI API exposes: +- `runBenchmark(spec: BenchSpec) -> BenchReport`: Run a benchmark by name +- `BenchSpec(name, iterations, warmup)`: Benchmark configuration +- `BenchReport`: Contains timing samples and statistics +- `BenchError`: Type-safe error handling (InvalidIterations, UnknownFunction, ExecutionFailed) + +## End-to-End Testing + +### Android Testing + +#### Quick Start (All-in-One) +```bash +# Build everything and create APK +scripts/build-android-app.sh + +# Install and launch on emulator/device +adb install -r android/app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n dev.world.bench/.MainActivity +``` + +#### Step-by-Step +```bash +# 1. Build Rust libraries for all Android ABIs (arm64-v8a, armeabi-v7a, x86_64) +scripts/build-android.sh + +# 2. Sync .so files into Android project structure (jniLibs) +scripts/sync-android-libs.sh + +# 3. Build the APK with Gradle +cd android && ./gradlew :app:assembleDebug + +# 4. Install and launch +adb install -r app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n dev.world.bench/.MainActivity +``` + +#### Testing with Custom Parameters +```bash +# Launch with custom benchmark function and parameters +adb shell am start -n dev.world.bench/.MainActivity \ + --es bench_function sample_fns::checksum \ + --ei bench_iterations 30 \ + --ei bench_warmup 5 +``` + +#### Using Android Studio +1. Open the `android/` directory in Android Studio +2. Ensure Rust libraries are built: `scripts/build-android.sh` +3. Sync libs: `scripts/sync-android-libs.sh` +4. Click Run (the app module should auto-sync) +5. Select emulator/device and run + +**Expected Output**: The app displays formatted benchmark results with individual sample timings and statistics (min/max/avg). + +### iOS Testing + +#### Prerequisites +```bash +# Install xcodegen if not already installed +brew install xcodegen + +# Install Rust iOS targets +rustup target add aarch64-apple-ios aarch64-apple-ios-sim +``` + +#### Step-by-Step +```bash +# 1. Build Rust xcframework for iOS (includes UniFFI headers and automatic code signing) +scripts/build-ios.sh + +# The script creates a properly structured xcframework with: +# - Static libraries for device (aarch64-apple-ios) and simulator (aarch64-apple-ios-sim) +# - UniFFI-generated C headers in each framework slice +# - Module maps for Swift interop +# - Correct bundle identifiers and platform identifiers +# - Automatic code signing for Xcode compatibility + +# 2. Generate Xcode project from project.yml +cd ios/BenchRunner +xcodegen generate + +# 3. Open in Xcode +open BenchRunner.xcodeproj +``` + +Then in Xcode: +1. Select a simulator (e.g., iPhone 15) or connected device +2. Click Run (⌘R) or Product → Run +3. The app will display benchmark results + +**Note**: The project uses a bridging header to expose C FFI types from Rust to Swift. The UniFFI-generated Swift bindings are compiled directly into the app (no module import needed). + +#### Testing with Custom Parameters + +**Method 1: Edit Scheme (Xcode)** +1. Product → Scheme → Edit Scheme... +2. Run → Arguments → Environment Variables +3. Add variables: + - `BENCH_FUNCTION` = `sample_fns::checksum` + - `BENCH_ITERATIONS` = `30` + - `BENCH_WARMUP` = `5` +4. Run the app + +**Method 2: Command Line (simulator only)** +```bash +# Build and run with xcrun +xcrun simctl launch booted dev.world.bench.BenchRunner \ + --bench-function=sample_fns::checksum \ + --bench-iterations=30 \ + --bench-warmup=5 +``` + +**Expected Output**: The app displays formatted benchmark results with individual sample timings and statistics (min/max/avg). + +### Key Differences from Pre-UniFFI + +The build process is **simpler** now: +- ✅ No need to run `cbindgen` manually +- ✅ UniFFI headers (`sample_fnsFFI.h`) are automatically generated during `build-ios.sh` +- ✅ Kotlin/Swift bindings are already committed to git +- ✅ Only regenerate bindings if you change `sample_fns.udl` (via `cargo run --bin generate-bindings --features bindgen`) +- ✅ Apps show formatted output with statistics instead of raw JSON +- ✅ Type-safe error handling (no more string parsing) + +### Requirements + +**Android:** +- Android SDK/NDK (API level 24+) +- `ANDROID_NDK_HOME` environment variable set +- `cargo-ndk` installed: `cargo install cargo-ndk` +- Android emulator or physical device + +**iOS:** +- macOS with Xcode command-line tools +- Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim` +- `xcodegen` installed: `brew install xcodegen` +- iOS Simulator or physical device (requires code signing) + +## Additional Documentation + +- **`BUILD.md`**: Complete build reference guide for Android and iOS (prerequisites, step-by-step instructions, troubleshooting) +- **`TESTING.md`**: Comprehensive testing guide with troubleshooting and advanced scenarios +- **`PROJECT_PLAN.md`**: Project goals, architecture, and task backlog +- **`CLAUDE.md`**: Developer guide for working with this codebase (for Claude Code and developers) + +## BrowserStack XCUITest (iOS) +- Provide signed artifacts for BrowserStack real devices: the app bundle (`.ipa` or zipped `.app`) and the XCUITest runner package (`.zip` or `.ipa` containing the test bundle). +- CLI flags (when not using a config file): `--ios-app` and `--ios-test-suite` must both be set whenever `--target ios` is paired with `--devices`. +- Config block example: + ```toml + [ios_xcuitest] + app = "target/ios/BenchRunner.ipa" + test_suite = "target/ios/BenchRunnerUITests.zip" + ``` +- Example run (requires BrowserStack credentials in env vars): `cargo run -p bench-cli -- run --target ios --function sample_fns::checksum --iterations 10 --warmup 2 --devices "iPhone 14-16" --ios-app target/ios/BenchRunner.ipa --ios-test-suite target/ios/BenchRunnerUITests.zip` diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..d93629f --- /dev/null +++ b/TESTING.md @@ -0,0 +1,564 @@ +# Testing Guide + +This document provides comprehensive testing instructions for mobile-bench-rs. + +> **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see **[BUILD.md](BUILD.md)**. This document focuses on testing scenarios and troubleshooting. + +## Table of Contents +- [Prerequisites](#prerequisites) +- [Host Testing](#host-testing) +- [Android Testing](#android-testing) +- [iOS Testing](#ios-testing) +- [Troubleshooting](#troubleshooting) + +## Prerequisites + +### Rust +```bash +# Install Rust if not already installed +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Install required targets +rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android +rustup target add aarch64-apple-ios aarch64-apple-ios-sim + +# Install cargo-ndk for Android builds +cargo install cargo-ndk +``` + +### Android +```bash +# Install Android SDK and NDK (via Android Studio or command line) +# Set environment variable (add to ~/.zshrc or ~/.bashrc) +export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/29.0.14206865 + +# Verify NDK is available +ls $ANDROID_NDK_HOME +``` + +### iOS (macOS only) +```bash +# Install Xcode from App Store +# Install command-line tools +xcode-select --install + +# Install xcodegen +brew install xcodegen +``` + +## Host Testing + +### Unit Tests +Run all Rust tests: +```bash +cargo test --all +``` + +Expected output: All tests pass (11 tests total as of UniFFI migration). + +### CLI Demo +Test the benchmarking harness without mobile builds: +```bash +cargo run -p bench-cli -- demo --iterations 10 --warmup 2 +``` + +Expected output: JSON report with timing samples for `fibonacci` function. + +### Testing Different Functions +```bash +# Test fibonacci (default) +cargo run -p bench-cli -- demo --iterations 5 --warmup 1 + +# Currently supports: +# - fibonacci / fib / sample_fns::fibonacci +# - checksum / checksum_1k / sample_fns::checksum +``` + +## Android Testing + +### Method 1: Quick All-in-One Build + +```bash +# Build everything and create APK +scripts/build-android-app.sh + +# Install on connected device/emulator +adb install -r android/app/build/outputs/apk/debug/app-debug.apk + +# Launch app +adb shell am start -n dev.world.bench/.MainActivity +``` + +### Method 2: Step-by-Step Build + +```bash +# Step 1: Build Rust libraries for Android +scripts/build-android.sh + +# This compiles for three ABIs: +# - aarch64-linux-android (arm64-v8a) +# - armv7-linux-androideabi (armeabi-v7a) +# - x86_64-linux-android (x86_64, for emulator) + +# Step 2: Copy .so files to Android project +scripts/sync-android-libs.sh + +# This copies from target/android/{abi}/release/ to android/app/src/main/jniLibs/{abi}/ + +# Step 3: Build APK +cd android +./gradlew :app:assembleDebug +cd .. + +# Step 4: Install and launch +adb install -r android/app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n dev.world.bench/.MainActivity +``` + +### Method 3: Using Android Studio + +1. Build Rust libraries first: + ```bash + scripts/build-android.sh + scripts/sync-android-libs.sh + ``` + +2. Open `android/` directory in Android Studio + +3. Let Gradle sync complete + +4. Click Run (green triangle) or Run → Run 'app' + +5. Select target device/emulator + +### Testing with Custom Parameters + +Launch with different benchmark configurations: + +```bash +# Test checksum function with 30 iterations +adb shell am start -n dev.world.bench/.MainActivity \ + --es bench_function sample_fns::checksum \ + --ei bench_iterations 30 \ + --ei bench_warmup 5 + +# Test fibonacci with minimal runs +adb shell am start -n dev.world.bench/.MainActivity \ + --es bench_function fibonacci \ + --ei bench_iterations 5 \ + --ei bench_warmup 1 +``` + +Parameters: +- `--es bench_function `: Function name (fibonacci, checksum, sample_fns::fibonacci, etc.) +- `--ei bench_iterations `: Number of benchmark iterations +- `--ei bench_warmup `: Number of warmup iterations + +### Verifying Output + +Check logcat for detailed output: +```bash +adb logcat | grep -i bench +``` + +The app display should show: +``` +=== Benchmark Results === + +Function: sample_fns::fibonacci +Iterations: 20 +Warmup: 3 + +Samples (20): + 1. 0.001 ms + 2. 0.001 ms + ... + +Statistics: + Min: 0.001 ms + Max: 0.002 ms + Avg: 0.001 ms +``` + +## iOS Testing + +### Build and Run + +```bash +# Step 1: Build Rust xcframework (includes automatic code signing) +scripts/build-ios.sh + +# This script: +# - Compiles Rust for aarch64-apple-ios (device) and aarch64-apple-ios-sim (simulator) +# - Creates xcframework with proper structure: +# target/ios/sample_fns.xcframework/ +# ├── Info.plist +# ├── ios-arm64/ +# │ └── sample_fns.framework/ +# │ ├── sample_fns (binary) +# │ ├── Headers/ +# │ │ ├── sample_fnsFFI.h +# │ │ └── module.modulemap +# │ └── Info.plist +# └── ios-simulator-arm64/ +# └── sample_fns.framework/ +# ├── sample_fns (binary) +# ├── Headers/ +# │ ├── sample_fnsFFI.h +# │ └── module.modulemap +# └── Info.plist +# - Copies UniFFI-generated C headers into framework +# - Creates module map for Swift to import C FFI +# - Automatically code-signs the xcframework + +# Step 2: Generate Xcode project from project.yml +cd ios/BenchRunner +xcodegen generate + +# Step 3: Open in Xcode +open BenchRunner.xcodeproj +``` + +In Xcode: +1. Select a scheme: BenchRunner +2. Select a destination: iPhone 15 (or any simulator) +3. Click Run (⌘R) or Product → Run + +**Important Notes:** +- The xcframework contains static libraries (`.a` archives), not dynamic frameworks +- A bridging header (`BenchRunner-Bridging-Header.h`) is used to expose C FFI types to Swift +- The UniFFI-generated Swift bindings (`sample_fns.swift`) are compiled directly into the app +- No `import sample_fns` is needed - the types are available globally via the bridging header + +### Testing with Custom Parameters + +#### Method 1: Edit Scheme in Xcode + +1. Product → Scheme → Edit Scheme... +2. Click "Run" in left sidebar +3. Go to "Arguments" tab +4. Click "Environment Variables" section +5. Click "+" to add variables: + - Name: `BENCH_FUNCTION`, Value: `sample_fns::checksum` + - Name: `BENCH_ITERATIONS`, Value: `30` + - Name: `BENCH_WARMUP`, Value: `5` +6. Click Close +7. Run the app + +#### Method 2: Command Line (Simulator Only) + +First, build and install to simulator: +```bash +# Build for simulator +xcodebuild -project ios/BenchRunner/BenchRunner.xcodeproj \ + -scheme BenchRunner \ + -destination 'platform=iOS Simulator,name=iPhone 15' \ + -derivedDataPath ios/build + +# Launch with arguments +xcrun simctl launch booted dev.world.bench.BenchRunner \ + --bench-function=sample_fns::checksum \ + --bench-iterations=30 \ + --bench-warmup=5 +``` + +#### Method 3: Edit bench_spec.json Bundle Resource + +Add `bench_spec.json` to the app bundle: +1. Create `ios/BenchRunner/BenchRunner/Resources/bench_spec.json`: + ```json + { + "function": "sample_fns::checksum", + "iterations": 30, + "warmup": 5 + } + ``` +2. Add to Xcode project (File → Add Files to "BenchRunner"...) +3. Ensure it's in "Copy Bundle Resources" build phase +4. Run the app + +### Verifying Output + +The app should display: +``` +=== Benchmark Results === + +Function: sample_fns::fibonacci +Iterations: 20 +Warmup: 3 + +Samples (20): + 1. 0.001 ms + 2. 0.001 ms + ... + +Statistics: + Min: 0.001 ms + Max: 0.002 ms + Avg: 0.001 ms +``` + +## Troubleshooting + +### Android + +**Problem**: `ANDROID_NDK_HOME is not set` +```bash +# Solution: Export the NDK path +export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/29.0.14206865 +# Or add to ~/.zshrc / ~/.bashrc +``` + +**Problem**: `cargo-ndk: command not found` +```bash +# Solution: Install cargo-ndk +cargo install cargo-ndk +``` + +**Problem**: `error: failed to run custom build command for 'sample-fns'` +```bash +# Solution: Clean and rebuild +cargo clean +scripts/build-android.sh +``` + +**Problem**: App crashes on launch with "UnsatisfiedLinkError" +```bash +# Solution: Ensure .so files are in the APK +scripts/sync-android-libs.sh +cd android && ./gradlew clean assembleDebug +``` + +**Problem**: App shows "Error: UnknownFunction" +- Check function name matches one of: `fibonacci`, `fib`, `sample_fns::fibonacci`, `checksum`, `checksum_1k`, `sample_fns::checksum` +- Function names are case-sensitive + +### iOS + +**Problem**: `xcodegen: command not found` +```bash +# Solution: Install xcodegen +brew install xcodegen +``` + +**Problem**: "The Framework 'sample_fns.xcframework' is unsigned" +```bash +# Solution: Code-sign the xcframework +codesign --force --deep --sign - target/ios/sample_fns.xcframework + +# The build script now includes signing, but if you built manually: +scripts/build-ios.sh +cd ios/BenchRunner +xcodegen generate +# Clean build in Xcode (⌘+Shift+K) then build (⌘+B) +``` + +**Problem**: "No such module 'sample_fns'" or "Unable to find module dependency: 'sample_fns'" in Swift +```bash +# Solution: The Swift bindings are compiled directly into the app. +# Remove any `import sample_fns` statements from your Swift code. +# The types (BenchSpec, BenchReport, etc.) are available without import. +``` + +**Problem**: "Cannot find type 'RustBuffer' in scope" or FFI type errors +```bash +# Solution: Ensure the bridging header is configured +# Check that BenchRunner-Bridging-Header.h exists at: +# ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h + +# If missing, create it with: +cat > ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h << 'EOF' +// +// BenchRunner-Bridging-Header.h +// BenchRunner +// +// Bridge to import C FFI from Rust (UniFFI-generated) +// + +#import "sample_fnsFFI.h" +EOF + +# Then regenerate the Xcode project: +cd ios/BenchRunner +xcodegen generate +``` + +**Problem**: Build fails with "library not found for -lsample_fns" or "framework 'ios-simulator-arm64' not found" +```bash +# Solution: Ensure xcframework was built correctly with proper structure +rm -rf target/ios/sample_fns.xcframework +scripts/build-ios.sh +codesign --force --deep --sign - target/ios/sample_fns.xcframework + +# Verify structure: +ls -la target/ios/sample_fns.xcframework/ +# Should show: +# ios-arm64/sample_fns.framework/ +# ios-simulator-arm64/sample_fns.framework/ +# Info.plist +``` + +**Problem**: "While building for iOS Simulator, no library for this platform was found" +```bash +# Solution: Rebuild the xcframework - the structure may be incorrect +rm -rf target/ios/sample_fns.xcframework +scripts/build-ios.sh +codesign --force --deep --sign - target/ios/sample_fns.xcframework + +# Clean Xcode build folder +cd ios/BenchRunner +xcodebuild clean -project BenchRunner.xcodeproj -scheme BenchRunner +# Then build in Xcode +``` + +**Problem**: "Framework had an invalid CFBundleIdentifier in its Info.plist" +```bash +# Solution: The framework bundle ID should not conflict with the app +# Check scripts/build-ios.sh has correct bundle ID (dev.world.sample-fns) +# Rebuild: +scripts/build-ios.sh +codesign --force --deep --sign - target/ios/sample_fns.xcframework +``` + +**Problem**: Simulator crashes with "Symbol not found" +```bash +# Solution: Clean and rebuild for simulator architecture +cargo clean +scripts/build-ios.sh +codesign --force --deep --sign - target/ios/sample_fns.xcframework + +# In Xcode, clean (⌘+Shift+K) then build (⌘+B) +``` + +**Problem**: "Could not launch" on physical device +- Ensure proper code signing is configured in Xcode +- Select your development team in Xcode → Project Settings → Signing & Capabilities +- Trust developer certificate on device: Settings → General → VPN & Device Management +- The xcframework must be signed: `codesign --force --deep --sign - target/ios/sample_fns.xcframework` + +### UniFFI Bindings + +**Problem**: Changes to `sample_fns.udl` not reflected in mobile apps +```bash +# Solution: Regenerate bindings +cargo run --bin generate-bindings --features bindgen + +# Then rebuild mobile apps +scripts/build-android-app.sh # For Android +scripts/build-ios.sh # For iOS +``` + +**Problem**: "error: cannot find type `BenchSpec` in the crate root" +```bash +# Solution: Ensure build.rs runs and generates scaffolding +cargo clean +cargo build -p sample-fns +``` + +### General + +**Problem**: Tests fail after code changes +```bash +# Solution: Run tests to see specific failures +cargo test --all + +# Common causes: +# - Missing serde dependency (check Cargo.toml) +# - API signature changes (update UDL and regenerate bindings) +# - Test assertions need updating +``` + +**Problem**: "permission denied" when running scripts +```bash +# Solution: Make scripts executable +chmod +x scripts/*.sh +``` + +## Advanced Testing + +### BrowserStack Integration Testing + +See the main [README.md](README.md) for BrowserStack testing instructions. + +### Performance Regression Testing + +Compare benchmark results across builds: +```bash +# Run benchmark and save results +cargo run -p bench-cli -- run \ + --target android \ + --function sample_fns::fibonacci \ + --iterations 100 \ + --local-only \ + --output results-v1.json + +# After changes, run again +cargo run -p bench-cli -- run \ + --target android \ + --function sample_fns::fibonacci \ + --iterations 100 \ + --local-only \ + --output results-v2.json + +# Compare results (requires jq) +jq -s '.[0].local_report.samples, .[1].local_report.samples' results-v1.json results-v2.json +``` + +### Adding New Test Functions + +1. Add function to `crates/sample-fns/src/lib.rs` +2. Add to dispatch in `run_benchmark()` match statement +3. Add test case in `#[cfg(test)]` module +4. Run tests: `cargo test -p sample-fns` +5. Test on mobile platforms + +Example: +```rust +// In lib.rs +pub fn my_new_function(n: u32) -> u64 { + // implementation +} + +// In run_benchmark() +"my_new_function" | "sample_fns::my_new_function" => { + run_closure(runner_spec, || { + let _ = my_new_function(100); + Ok(()) + }) + .map_err(|e: BenchRunnerError| -> BenchError { e.into() })? +} + +// In tests +#[test] +fn test_my_new_function() { + let spec = BenchSpec { + name: "my_new_function".to_string(), + iterations: 3, + warmup: 1, + }; + let report = run_benchmark(spec).unwrap(); + assert_eq!(report.samples.len(), 3); +} +``` + +## Continuous Integration + +The project includes a GitHub Actions workflow (`.github/workflows/mobile-bench.yml`) that: +- Runs host tests on every push +- Builds Android APK (optional) +- Builds iOS xcframework (optional) +- Uploads artifacts + +To trigger manually: +1. Go to GitHub Actions tab +2. Select "mobile-bench-rs CI" +3. Click "Run workflow" +4. Select platform(s) to build + +## Additional Resources + +- [UniFFI Documentation](https://mozilla.github.io/uniffi-rs/) +- [Android NDK Documentation](https://developer.android.com/ndk) +- [Rust Cross-Compilation Guide](https://rust-lang.github.io/rustup/cross-compilation.html) +- [PROJECT_PLAN.md](PROJECT_PLAN.md) - Roadmap and architecture +- [CLAUDE.md](CLAUDE.md) - Developer guide for working with this codebase diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..ebde2ec --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +local.properties +.idea/ +app/build/ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..061db9a --- /dev/null +++ b/android/README.md @@ -0,0 +1,22 @@ +# Android demo app + +Minimal Android app that loads the Rust `sample-fns` cdylib and calls exported functions. This is a thin wrapper meant for BrowserStack AppAutomate and CI smoke tests. + +## Build steps +1. Build Rust libs for Android: + ```bash + scripts/build-android.sh + ``` +2. Copy `.so` outputs into the app: + ```bash + scripts/sync-android-libs.sh + ``` +3. Assemble the APK (requires Java + Gradle + Android SDK/NDK on PATH): + ```bash + cd android + gradle :app:assembleDebug + ``` + +Artifacts will be under `android/app/build/outputs/apk/debug/`. + +> Note: Gradle/AGP versions are pinned in `android/build.gradle`. Update as needed. diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..34b1371 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,69 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" + +android { + namespace = "dev.world.bench" + compileSdk = 34 + ndkVersion "26.1.10909125" + def repoRoot = rootProject.projectDir.parentFile + def benchSpecDir = new File(repoRoot, "target/mobile-spec/android") + if (!benchSpecDir.exists()) { + benchSpecDir.mkdirs() + } + + defaultConfig { + applicationId "dev.world.bench" + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "0.1" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + minifyEnabled false + } + release { + minifyEnabled false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + main { + jniLibs.srcDirs "src/main/jniLibs" + assets.srcDirs += [benchSpecDir.absolutePath] + } + androidTest { + assets.srcDirs += [benchSpecDir.absolutePath] + } + } + + packagingOptions { + jniLibs { + // Keep symbols in Rust shared objects; avoid strip warning. + keepDebugSymbols += ["**/libsample_fns.so"] + } + } +} + +dependencies { + implementation "androidx.core:core-ktx:1.12.0" + implementation "androidx.appcompat:appcompat:1.6.1" + implementation "com.google.android.material:material:1.11.0" + implementation "net.java.dev.jna:jna:5.14.0@aar" + androidTestImplementation "androidx.test.ext:junit:1.1.5" + androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..2fd7008 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# Keep all native method names so JNI bindings stay intact +-keepclasseswithmembernames class * { + native ; +} diff --git a/android/app/src/androidTest/java/dev/world/bench/MainActivityTest.kt b/android/app/src/androidTest/java/dev/world/bench/MainActivityTest.kt new file mode 100644 index 0000000..e98e434 --- /dev/null +++ b/android/app/src/androidTest/java/dev/world/bench/MainActivityTest.kt @@ -0,0 +1,25 @@ +package dev.world.bench + +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import org.hamcrest.Matchers.containsString +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + + @Test + fun showsBenchOutput() { + onView(withId(R.id.result_text)) + .check(matches(withText(containsString("Samples")))) + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ebf60ea --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/java/dev/world/bench/MainActivity.kt b/android/app/src/main/java/dev/world/bench/MainActivity.kt new file mode 100644 index 0000000..fd40708 --- /dev/null +++ b/android/app/src/main/java/dev/world/bench/MainActivity.kt @@ -0,0 +1,167 @@ +package dev.world.bench + +import android.os.Bundle +import android.os.Debug +import android.os.Process +import android.os.SystemClock +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import org.json.JSONArray +import org.json.JSONObject +import uniffi.sample_fns.BenchException +import uniffi.sample_fns.BenchReport +import uniffi.sample_fns.BenchSpec +import uniffi.sample_fns.runBenchmark + +class MainActivity : AppCompatActivity() { + + companion object { + private const val DEFAULT_FUNCTION = "sample_fns::fibonacci" + private const val DEFAULT_ITERATIONS = 20u + private const val DEFAULT_WARMUP = 3u + private const val FUNCTION_EXTRA = "bench_function" + private const val ITERATIONS_EXTRA = "bench_iterations" + private const val WARMUP_EXTRA = "bench_warmup" + private const val SPEC_ASSET = "bench_spec.json" + + init { + System.loadLibrary("uniffi_sample_fns") + } + } + + private data class BenchParams( + val function: String, + val iterations: UInt, + val warmup: UInt, + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + val params = resolveBenchParams() + val display = try { + val spec = BenchSpec( + name = params.function, + iterations = params.iterations, + warmup = params.warmup + ) + val report = runBenchmark(spec) + // Debug: Log first sample's raw nanoseconds + if (report.samples.isNotEmpty()) { + android.util.Log.d("MainActivity", "First sample duration_ns: ${report.samples[0].durationNs}") + } + logBenchReport(report) + formatBenchReport(report) + } catch (e: BenchException.InvalidIterations) { + "Error: ${e.message}" + } catch (e: BenchException.UnknownFunction) { + "Error: ${e.message}" + } catch (e: BenchException.ExecutionFailed) { + "Error: ${e.message}" + } catch (e: Exception) { + "Unexpected error: ${e.message}" + } + + findViewById(R.id.result_text)?.text = display + } + + private fun formatBenchReport(report: BenchReport): String = buildString { + appendLine("=== Benchmark Results ===") + appendLine() + appendLine("Function: ${report.spec.name}") + appendLine("Iterations: ${report.spec.iterations}") + appendLine("Warmup: ${report.spec.warmup}") + appendLine() + appendLine("Samples (${report.samples.size}):") + report.samples.forEachIndexed { index, sample -> + val durationUs = sample.durationNs.toDouble() / 1_000.0 + appendLine(" ${index + 1}. ${String.format("%.3f", durationUs)} μs (${sample.durationNs} ns)") + } + + if (report.samples.isNotEmpty()) { + val durations = report.samples.map { it.durationNs.toDouble() / 1_000.0 } + val min = durations.minOrNull() ?: 0.0 + val max = durations.maxOrNull() ?: 0.0 + val avg = durations.average() + appendLine() + appendLine("Statistics:") + appendLine(" Min: ${String.format("%.3f", min)} μs") + appendLine(" Max: ${String.format("%.3f", max)} μs") + appendLine(" Avg: ${String.format("%.3f", avg)} μs") + } + } + + private fun logBenchReport(report: BenchReport) { + val json = JSONObject() + val spec = JSONObject() + spec.put("name", report.spec.name) + spec.put("iterations", report.spec.iterations.toInt()) + spec.put("warmup", report.spec.warmup.toInt()) + json.put("spec", spec) + + val samples = report.samples.map { it.durationNs.toLong() } + val sampleArray = JSONArray() + samples.forEach { sampleArray.put(it) } + json.put("samples_ns", sampleArray) + + if (samples.isNotEmpty()) { + val min = samples.minOrNull() ?: 0L + val max = samples.maxOrNull() ?: 0L + val avg = samples.sum().toDouble() / samples.size.toDouble() + val stats = JSONObject() + stats.put("min_ns", min) + stats.put("max_ns", max) + stats.put("avg_ns", avg.toDouble()) + json.put("stats", stats) + } + + val memInfo = Debug.MemoryInfo() + Debug.getMemoryInfo(memInfo) + val resources = JSONObject() + resources.put("elapsed_cpu_ms", Process.getElapsedCpuTime()) + resources.put("uptime_ms", SystemClock.elapsedRealtime()) + resources.put("total_pss_kb", memInfo.totalPss) + resources.put("private_dirty_kb", memInfo.totalPrivateDirty) + resources.put("native_heap_kb", Debug.getNativeHeapAllocatedSize() / 1024) + val usedHeap = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() + resources.put("java_heap_kb", usedHeap / 1024) + json.put("resources", resources) + + android.util.Log.i("BenchRunner", "BENCH_JSON ${json}") + } + + private fun resolveBenchParams(): BenchParams { + val defaults = loadBenchParamsFromAssets() ?: BenchParams( + DEFAULT_FUNCTION, + DEFAULT_ITERATIONS, + DEFAULT_WARMUP + ) + val fn = intent?.getStringExtra(FUNCTION_EXTRA) + ?.takeUnless { it.isBlank() } + ?: defaults.function + val iterations = intent?.getIntExtra(ITERATIONS_EXTRA, defaults.iterations.toInt())?.toUInt() + ?: defaults.iterations + val warmup = intent?.getIntExtra(WARMUP_EXTRA, defaults.warmup.toInt())?.toUInt() + ?: defaults.warmup + return BenchParams(fn, iterations, warmup) + } + + private fun loadBenchParamsFromAssets(): BenchParams? { + return try { + val raw = assets.open(SPEC_ASSET).bufferedReader().use { it.readText() } + if (raw.isBlank()) { + null + } else { + val json = JSONObject(raw) + BenchParams( + json.optString("function", DEFAULT_FUNCTION), + json.optInt("iterations", DEFAULT_ITERATIONS.toInt()).toUInt(), + json.optInt("warmup", DEFAULT_WARMUP.toInt()).toUInt(), + ) + } + } catch (_: Exception) { + null + } + } +} diff --git a/android/app/src/main/java/uniffi/sample_fns/sample_fns.kt b/android/app/src/main/java/uniffi/sample_fns/sample_fns.kt new file mode 100644 index 0000000..a9e9050 --- /dev/null +++ b/android/app/src/main/java/uniffi/sample_fns/sample_fns.kt @@ -0,0 +1,1206 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +@file:Suppress("NAME_SHADOWING") + +package uniffi.sample_fns + +// Common helper code. +// +// Ideally this would live in a separate .kt file where it can be unittested etc +// in isolation, and perhaps even published as a re-useable package. +// +// However, it's important that the details of how this helper code works (e.g. the +// way that different builtin types are passed across the FFI) exactly match what's +// expected by the Rust code on the other side of the interface. In practice right +// now that means coming from the exact some version of `uniffi` that was used to +// compile the Rust component. The easiest way to ensure this is to bundle the Kotlin +// helpers directly inline like we're doing here. + +import com.sun.jna.Library +import com.sun.jna.IntegerType +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.Callback +import com.sun.jna.ptr.* +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.CharBuffer +import java.nio.charset.CodingErrorAction +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.ConcurrentHashMap + +// This is a helper for safely working with byte buffers returned from the Rust code. +// A rust-owned buffer is represented by its capacity, its current length, and a +// pointer to the underlying data. + +/** + * @suppress + */ +@Structure.FieldOrder("capacity", "len", "data") +open class RustBuffer : Structure() { + // Note: `capacity` and `len` are actually `ULong` values, but JVM only supports signed values. + // When dealing with these fields, make sure to call `toULong()`. + @JvmField var capacity: Long = 0 + @JvmField var len: Long = 0 + @JvmField var data: Pointer? = null + + class ByValue: RustBuffer(), Structure.ByValue + class ByReference: RustBuffer(), Structure.ByReference + + internal fun setValue(other: RustBuffer) { + capacity = other.capacity + len = other.len + data = other.data + } + + companion object { + internal fun alloc(size: ULong = 0UL) = uniffiRustCall() { status -> + // Note: need to convert the size to a `Long` value to make this work with JVM. + UniffiLib.INSTANCE.ffi_sample_fns_rustbuffer_alloc(size.toLong(), status) + }.also { + if(it.data == null) { + throw RuntimeException("RustBuffer.alloc() returned null data pointer (size=${size})") + } + } + + internal fun create(capacity: ULong, len: ULong, data: Pointer?): RustBuffer.ByValue { + var buf = RustBuffer.ByValue() + buf.capacity = capacity.toLong() + buf.len = len.toLong() + buf.data = data + return buf + } + + internal fun free(buf: RustBuffer.ByValue) = uniffiRustCall() { status -> + UniffiLib.INSTANCE.ffi_sample_fns_rustbuffer_free(buf, status) + } + } + + @Suppress("TooGenericExceptionThrown") + fun asByteBuffer() = + this.data?.getByteBuffer(0, this.len.toLong())?.also { + it.order(ByteOrder.BIG_ENDIAN) + } +} + +/** + * The equivalent of the `*mut RustBuffer` type. + * Required for callbacks taking in an out pointer. + * + * Size is the sum of all values in the struct. + * + * @suppress + */ +class RustBufferByReference : ByReference(16) { + /** + * Set the pointed-to `RustBuffer` to the given value. + */ + fun setValue(value: RustBuffer.ByValue) { + // NOTE: The offsets are as they are in the C-like struct. + val pointer = getPointer() + pointer.setLong(0, value.capacity) + pointer.setLong(8, value.len) + pointer.setPointer(16, value.data) + } + + /** + * Get a `RustBuffer.ByValue` from this reference. + */ + fun getValue(): RustBuffer.ByValue { + val pointer = getPointer() + val value = RustBuffer.ByValue() + value.writeField("capacity", pointer.getLong(0)) + value.writeField("len", pointer.getLong(8)) + value.writeField("data", pointer.getLong(16)) + + return value + } +} + +// This is a helper for safely passing byte references into the rust code. +// It's not actually used at the moment, because there aren't many things that you +// can take a direct pointer to in the JVM, and if we're going to copy something +// then we might as well copy it into a `RustBuffer`. But it's here for API +// completeness. + +@Structure.FieldOrder("len", "data") +internal open class ForeignBytes : Structure() { + @JvmField var len: Int = 0 + @JvmField var data: Pointer? = null + + class ByValue : ForeignBytes(), Structure.ByValue +} +/** + * The FfiConverter interface handles converter types to and from the FFI + * + * All implementing objects should be public to support external types. When a + * type is external we need to import it's FfiConverter. + * + * @suppress + */ +public interface FfiConverter { + // Convert an FFI type to a Kotlin type + fun lift(value: FfiType): KotlinType + + // Convert an Kotlin type to an FFI type + fun lower(value: KotlinType): FfiType + + // Read a Kotlin type from a `ByteBuffer` + fun read(buf: ByteBuffer): KotlinType + + // Calculate bytes to allocate when creating a `RustBuffer` + // + // This must return at least as many bytes as the write() function will + // write. It can return more bytes than needed, for example when writing + // Strings we can't know the exact bytes needed until we the UTF-8 + // encoding, so we pessimistically allocate the largest size possible (3 + // bytes per codepoint). Allocating extra bytes is not really a big deal + // because the `RustBuffer` is short-lived. + fun allocationSize(value: KotlinType): ULong + + // Write a Kotlin type to a `ByteBuffer` + fun write(value: KotlinType, buf: ByteBuffer) + + // Lower a value into a `RustBuffer` + // + // This method lowers a value into a `RustBuffer` rather than the normal + // FfiType. It's used by the callback interface code. Callback interface + // returns are always serialized into a `RustBuffer` regardless of their + // normal FFI type. + fun lowerIntoRustBuffer(value: KotlinType): RustBuffer.ByValue { + val rbuf = RustBuffer.alloc(allocationSize(value)) + try { + val bbuf = rbuf.data!!.getByteBuffer(0, rbuf.capacity).also { + it.order(ByteOrder.BIG_ENDIAN) + } + write(value, bbuf) + rbuf.writeField("len", bbuf.position().toLong()) + return rbuf + } catch (e: Throwable) { + RustBuffer.free(rbuf) + throw e + } + } + + // Lift a value from a `RustBuffer`. + // + // This here mostly because of the symmetry with `lowerIntoRustBuffer()`. + // It's currently only used by the `FfiConverterRustBuffer` class below. + fun liftFromRustBuffer(rbuf: RustBuffer.ByValue): KotlinType { + val byteBuf = rbuf.asByteBuffer()!! + try { + val item = read(byteBuf) + if (byteBuf.hasRemaining()) { + throw RuntimeException("junk remaining in buffer after lifting, something is very wrong!!") + } + return item + } finally { + RustBuffer.free(rbuf) + } + } +} + +/** + * FfiConverter that uses `RustBuffer` as the FfiType + * + * @suppress + */ +public interface FfiConverterRustBuffer: FfiConverter { + override fun lift(value: RustBuffer.ByValue) = liftFromRustBuffer(value) + override fun lower(value: KotlinType) = lowerIntoRustBuffer(value) +} +// A handful of classes and functions to support the generated data structures. +// This would be a good candidate for isolating in its own ffi-support lib. + +internal const val UNIFFI_CALL_SUCCESS = 0.toByte() +internal const val UNIFFI_CALL_ERROR = 1.toByte() +internal const val UNIFFI_CALL_UNEXPECTED_ERROR = 2.toByte() + +@Structure.FieldOrder("code", "error_buf") +internal open class UniffiRustCallStatus : Structure() { + @JvmField var code: Byte = 0 + @JvmField var error_buf: RustBuffer.ByValue = RustBuffer.ByValue() + + class ByValue: UniffiRustCallStatus(), Structure.ByValue + + fun isSuccess(): Boolean { + return code == UNIFFI_CALL_SUCCESS + } + + fun isError(): Boolean { + return code == UNIFFI_CALL_ERROR + } + + fun isPanic(): Boolean { + return code == UNIFFI_CALL_UNEXPECTED_ERROR + } + + companion object { + fun create(code: Byte, errorBuf: RustBuffer.ByValue): UniffiRustCallStatus.ByValue { + val callStatus = UniffiRustCallStatus.ByValue() + callStatus.code = code + callStatus.error_buf = errorBuf + return callStatus + } + } +} + +class InternalException(message: String) : kotlin.Exception(message) + +/** + * Each top-level error class has a companion object that can lift the error from the call status's rust buffer + * + * @suppress + */ +interface UniffiRustCallStatusErrorHandler { + fun lift(error_buf: RustBuffer.ByValue): E; +} + +// Helpers for calling Rust +// In practice we usually need to be synchronized to call this safely, so it doesn't +// synchronize itself + +// Call a rust function that returns a Result<>. Pass in the Error class companion that corresponds to the Err +private inline fun uniffiRustCallWithError(errorHandler: UniffiRustCallStatusErrorHandler, callback: (UniffiRustCallStatus) -> U): U { + var status = UniffiRustCallStatus() + val return_value = callback(status) + uniffiCheckCallStatus(errorHandler, status) + return return_value +} + +// Check UniffiRustCallStatus and throw an error if the call wasn't successful +private fun uniffiCheckCallStatus(errorHandler: UniffiRustCallStatusErrorHandler, status: UniffiRustCallStatus) { + if (status.isSuccess()) { + return + } else if (status.isError()) { + throw errorHandler.lift(status.error_buf) + } else if (status.isPanic()) { + // when the rust code sees a panic, it tries to construct a rustbuffer + // with the message. but if that code panics, then it just sends back + // an empty buffer. + if (status.error_buf.len > 0) { + throw InternalException(FfiConverterString.lift(status.error_buf)) + } else { + throw InternalException("Rust panic") + } + } else { + throw InternalException("Unknown rust call status: $status.code") + } +} + +/** + * UniffiRustCallStatusErrorHandler implementation for times when we don't expect a CALL_ERROR + * + * @suppress + */ +object UniffiNullRustCallStatusErrorHandler: UniffiRustCallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): InternalException { + RustBuffer.free(error_buf) + return InternalException("Unexpected CALL_ERROR") + } +} + +// Call a rust function that returns a plain value +private inline fun uniffiRustCall(callback: (UniffiRustCallStatus) -> U): U { + return uniffiRustCallWithError(UniffiNullRustCallStatusErrorHandler, callback) +} + +internal inline fun uniffiTraitInterfaceCall( + callStatus: UniffiRustCallStatus, + makeCall: () -> T, + writeReturn: (T) -> Unit, +) { + try { + writeReturn(makeCall()) + } catch(e: kotlin.Exception) { + callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR + callStatus.error_buf = FfiConverterString.lower(e.toString()) + } +} + +internal inline fun uniffiTraitInterfaceCallWithError( + callStatus: UniffiRustCallStatus, + makeCall: () -> T, + writeReturn: (T) -> Unit, + lowerError: (E) -> RustBuffer.ByValue +) { + try { + writeReturn(makeCall()) + } catch(e: kotlin.Exception) { + if (e is E) { + callStatus.code = UNIFFI_CALL_ERROR + callStatus.error_buf = lowerError(e) + } else { + callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR + callStatus.error_buf = FfiConverterString.lower(e.toString()) + } + } +} +// Map handles to objects +// +// This is used pass an opaque 64-bit handle representing a foreign object to the Rust code. +internal class UniffiHandleMap { + private val map = ConcurrentHashMap() + private val counter = java.util.concurrent.atomic.AtomicLong(0) + + val size: Int + get() = map.size + + // Insert a new object into the handle map and get a handle for it + fun insert(obj: T): Long { + val handle = counter.getAndAdd(1) + map.put(handle, obj) + return handle + } + + // Get an object from the handle map + fun get(handle: Long): T { + return map.get(handle) ?: throw InternalException("UniffiHandleMap.get: Invalid handle") + } + + // Remove an entry from the handlemap and get the Kotlin object back + fun remove(handle: Long): T { + return map.remove(handle) ?: throw InternalException("UniffiHandleMap: Invalid handle") + } +} + +// Contains loading, initialization code, +// and the FFI Function declarations in a com.sun.jna.Library. +@Synchronized +private fun findLibraryName(componentName: String): String { + val libOverride = System.getProperty("uniffi.component.$componentName.libraryOverride") + if (libOverride != null) { + return libOverride + } + return "uniffi_sample_fns" +} + +private inline fun loadIndirect( + componentName: String +): Lib { + return Native.load(findLibraryName(componentName), Lib::class.java) +} + +// Define FFI callback types +internal interface UniffiRustFutureContinuationCallback : com.sun.jna.Callback { + fun callback(`data`: Long,`pollResult`: Byte,) +} +internal interface UniffiForeignFutureFree : com.sun.jna.Callback { + fun callback(`handle`: Long,) +} +internal interface UniffiCallbackInterfaceFree : com.sun.jna.Callback { + fun callback(`handle`: Long,) +} +@Structure.FieldOrder("handle", "free") +internal open class UniffiForeignFuture( + @JvmField internal var `handle`: Long = 0.toLong(), + @JvmField internal var `free`: UniffiForeignFutureFree? = null, +) : Structure() { + class UniffiByValue( + `handle`: Long = 0.toLong(), + `free`: UniffiForeignFutureFree? = null, + ): UniffiForeignFuture(`handle`,`free`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFuture) { + `handle` = other.`handle` + `free` = other.`free` + } + +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU8( + @JvmField internal var `returnValue`: Byte = 0.toByte(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Byte = 0.toByte(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU8(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU8) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU8 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU8.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI8( + @JvmField internal var `returnValue`: Byte = 0.toByte(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Byte = 0.toByte(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI8(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI8) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI8 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI8.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU16( + @JvmField internal var `returnValue`: Short = 0.toShort(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Short = 0.toShort(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU16(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU16) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU16 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU16.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI16( + @JvmField internal var `returnValue`: Short = 0.toShort(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Short = 0.toShort(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI16(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI16) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI16 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI16.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU32( + @JvmField internal var `returnValue`: Int = 0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Int = 0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI32( + @JvmField internal var `returnValue`: Int = 0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Int = 0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU64( + @JvmField internal var `returnValue`: Long = 0.toLong(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Long = 0.toLong(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI64( + @JvmField internal var `returnValue`: Long = 0.toLong(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Long = 0.toLong(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructF32( + @JvmField internal var `returnValue`: Float = 0.0f, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Float = 0.0f, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructF32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructF32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteF32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructF32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructF64( + @JvmField internal var `returnValue`: Double = 0.0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Double = 0.0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructF64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructF64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteF64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructF64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructPointer( + @JvmField internal var `returnValue`: Pointer = Pointer.NULL, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Pointer = Pointer.NULL, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructPointer(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructPointer) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompletePointer : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructPointer.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructRustBuffer( + @JvmField internal var `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructRustBuffer(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructRustBuffer) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteRustBuffer : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructRustBuffer.UniffiByValue,) +} +@Structure.FieldOrder("callStatus") +internal open class UniffiForeignFutureStructVoid( + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructVoid(`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructVoid) { + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteVoid : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructVoid.UniffiByValue,) +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +// A JNA Library to expose the extern-C FFI definitions. +// This is an implementation detail which will be called internally by the public API. + +internal interface UniffiLib : Library { + companion object { + internal val INSTANCE: UniffiLib by lazy { + loadIndirect(componentName = "sample_fns") + .also { lib: UniffiLib -> + uniffiCheckContractApiVersion(lib) + uniffiCheckApiChecksums(lib) + } + } + + } + + fun uniffi_sample_fns_fn_func_run_benchmark(`spec`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun ffi_sample_fns_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun ffi_sample_fns_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun ffi_sample_fns_rustbuffer_free(`buf`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Unit + fun ffi_sample_fns_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun ffi_sample_fns_rust_future_poll_u8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_u8(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_u8(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_u8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte + fun ffi_sample_fns_rust_future_poll_i8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_i8(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_i8(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_i8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte + fun ffi_sample_fns_rust_future_poll_u16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_u16(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_u16(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_u16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Short + fun ffi_sample_fns_rust_future_poll_i16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_i16(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_i16(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_i16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Short + fun ffi_sample_fns_rust_future_poll_u32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_u32(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_u32(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_u32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Int + fun ffi_sample_fns_rust_future_poll_i32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_i32(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_i32(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_i32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Int + fun ffi_sample_fns_rust_future_poll_u64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_u64(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_u64(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_u64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + fun ffi_sample_fns_rust_future_poll_i64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_i64(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_i64(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_i64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + fun ffi_sample_fns_rust_future_poll_f32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_f32(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_f32(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_f32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Float + fun ffi_sample_fns_rust_future_poll_f64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_f64(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_f64(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_f64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Double + fun ffi_sample_fns_rust_future_poll_pointer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_pointer(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_pointer(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_pointer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Pointer + fun ffi_sample_fns_rust_future_poll_rust_buffer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_rust_buffer(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_rust_buffer(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_rust_buffer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun ffi_sample_fns_rust_future_poll_void(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_sample_fns_rust_future_cancel_void(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_free_void(`handle`: Long, + ): Unit + fun ffi_sample_fns_rust_future_complete_void(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + fun uniffi_sample_fns_checksum_func_run_benchmark( + ): Short + fun ffi_sample_fns_uniffi_contract_version( + ): Int + +} + +private fun uniffiCheckContractApiVersion(lib: UniffiLib) { + // Get the bindings contract version from our ComponentInterface + val bindings_contract_version = 26 + // Get the scaffolding contract version by calling the into the dylib + val scaffolding_contract_version = lib.ffi_sample_fns_uniffi_contract_version() + if (bindings_contract_version != scaffolding_contract_version) { + throw RuntimeException("UniFFI contract version mismatch: try cleaning and rebuilding your project") + } +} + +@Suppress("UNUSED_PARAMETER") +private fun uniffiCheckApiChecksums(lib: UniffiLib) { + if (lib.uniffi_sample_fns_checksum_func_run_benchmark() != 35019.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } +} + +// Async support + +// Public interface members begin here. + + +// Interface implemented by anything that can contain an object reference. +// +// Such types expose a `destroy()` method that must be called to cleanly +// dispose of the contained objects. Failure to call this method may result +// in memory leaks. +// +// The easiest way to ensure this method is called is to use the `.use` +// helper method to execute a block and destroy the object at the end. +interface Disposable { + fun destroy() + companion object { + fun destroy(vararg args: Any?) { + args.filterIsInstance() + .forEach(Disposable::destroy) + } + } +} + +/** + * @suppress + */ +inline fun T.use(block: (T) -> R) = + try { + block(this) + } finally { + try { + // N.B. our implementation is on the nullable type `Disposable?`. + this?.destroy() + } catch (e: Throwable) { + // swallow + } + } + +/** + * Used to instantiate an interface without an actual pointer, for fakes in tests, mostly. + * + * @suppress + * */ +object NoPointer + +/** + * @suppress + */ +public object FfiConverterUInt: FfiConverter { + override fun lift(value: Int): UInt { + return value.toUInt() + } + + override fun read(buf: ByteBuffer): UInt { + return lift(buf.getInt()) + } + + override fun lower(value: UInt): Int { + return value.toInt() + } + + override fun allocationSize(value: UInt) = 4UL + + override fun write(value: UInt, buf: ByteBuffer) { + buf.putInt(value.toInt()) + } +} + +/** + * @suppress + */ +public object FfiConverterULong: FfiConverter { + override fun lift(value: Long): ULong { + return value.toULong() + } + + override fun read(buf: ByteBuffer): ULong { + return lift(buf.getLong()) + } + + override fun lower(value: ULong): Long { + return value.toLong() + } + + override fun allocationSize(value: ULong) = 8UL + + override fun write(value: ULong, buf: ByteBuffer) { + buf.putLong(value.toLong()) + } +} + +/** + * @suppress + */ +public object FfiConverterString: FfiConverter { + // Note: we don't inherit from FfiConverterRustBuffer, because we use a + // special encoding when lowering/lifting. We can use `RustBuffer.len` to + // store our length and avoid writing it out to the buffer. + override fun lift(value: RustBuffer.ByValue): String { + try { + val byteArr = ByteArray(value.len.toInt()) + value.asByteBuffer()!!.get(byteArr) + return byteArr.toString(Charsets.UTF_8) + } finally { + RustBuffer.free(value) + } + } + + override fun read(buf: ByteBuffer): String { + val len = buf.getInt() + val byteArr = ByteArray(len) + buf.get(byteArr) + return byteArr.toString(Charsets.UTF_8) + } + + fun toUtf8(value: String): ByteBuffer { + // Make sure we don't have invalid UTF-16, check for lone surrogates. + return Charsets.UTF_8.newEncoder().run { + onMalformedInput(CodingErrorAction.REPORT) + encode(CharBuffer.wrap(value)) + } + } + + override fun lower(value: String): RustBuffer.ByValue { + val byteBuf = toUtf8(value) + // Ideally we'd pass these bytes to `ffi_bytebuffer_from_bytes`, but doing so would require us + // to copy them into a JNA `Memory`. So we might as well directly copy them into a `RustBuffer`. + val rbuf = RustBuffer.alloc(byteBuf.limit().toULong()) + rbuf.asByteBuffer()!!.put(byteBuf) + return rbuf + } + + // We aren't sure exactly how many bytes our string will be once it's UTF-8 + // encoded. Allocate 3 bytes per UTF-16 code unit which will always be + // enough. + override fun allocationSize(value: String): ULong { + val sizeForLength = 4UL + val sizeForString = value.length.toULong() * 3UL + return sizeForLength + sizeForString + } + + override fun write(value: String, buf: ByteBuffer) { + val byteBuf = toUtf8(value) + buf.putInt(byteBuf.limit()) + buf.put(byteBuf) + } +} + + + +data class BenchReport ( + var `spec`: BenchSpec, + var `samples`: List +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeBenchReport: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): BenchReport { + return BenchReport( + FfiConverterTypeBenchSpec.read(buf), + FfiConverterSequenceTypeBenchSample.read(buf), + ) + } + + override fun allocationSize(value: BenchReport) = ( + FfiConverterTypeBenchSpec.allocationSize(value.`spec`) + + FfiConverterSequenceTypeBenchSample.allocationSize(value.`samples`) + ) + + override fun write(value: BenchReport, buf: ByteBuffer) { + FfiConverterTypeBenchSpec.write(value.`spec`, buf) + FfiConverterSequenceTypeBenchSample.write(value.`samples`, buf) + } +} + + + +data class BenchSample ( + var `durationNs`: kotlin.ULong +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeBenchSample: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): BenchSample { + return BenchSample( + FfiConverterULong.read(buf), + ) + } + + override fun allocationSize(value: BenchSample) = ( + FfiConverterULong.allocationSize(value.`durationNs`) + ) + + override fun write(value: BenchSample, buf: ByteBuffer) { + FfiConverterULong.write(value.`durationNs`, buf) + } +} + + + +data class BenchSpec ( + var `name`: kotlin.String, + var `iterations`: kotlin.UInt, + var `warmup`: kotlin.UInt +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeBenchSpec: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): BenchSpec { + return BenchSpec( + FfiConverterString.read(buf), + FfiConverterUInt.read(buf), + FfiConverterUInt.read(buf), + ) + } + + override fun allocationSize(value: BenchSpec) = ( + FfiConverterString.allocationSize(value.`name`) + + FfiConverterUInt.allocationSize(value.`iterations`) + + FfiConverterUInt.allocationSize(value.`warmup`) + ) + + override fun write(value: BenchSpec, buf: ByteBuffer) { + FfiConverterString.write(value.`name`, buf) + FfiConverterUInt.write(value.`iterations`, buf) + FfiConverterUInt.write(value.`warmup`, buf) + } +} + + + + + +sealed class BenchException(message: String): kotlin.Exception(message) { + + class InvalidIterations(message: String) : BenchException(message) + + class UnknownFunction(message: String) : BenchException(message) + + class ExecutionFailed(message: String) : BenchException(message) + + + companion object ErrorHandler : UniffiRustCallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): BenchException = FfiConverterTypeBenchError.lift(error_buf) + } +} + +/** + * @suppress + */ +public object FfiConverterTypeBenchError : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): BenchException { + + return when(buf.getInt()) { + 1 -> BenchException.InvalidIterations(FfiConverterString.read(buf)) + 2 -> BenchException.UnknownFunction(FfiConverterString.read(buf)) + 3 -> BenchException.ExecutionFailed(FfiConverterString.read(buf)) + else -> throw RuntimeException("invalid error enum value, something is very wrong!!") + } + + } + + override fun allocationSize(value: BenchException): ULong { + return 4UL + } + + override fun write(value: BenchException, buf: ByteBuffer) { + when(value) { + is BenchException.InvalidIterations -> { + buf.putInt(1) + Unit + } + is BenchException.UnknownFunction -> { + buf.putInt(2) + Unit + } + is BenchException.ExecutionFailed -> { + buf.putInt(3) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } + +} + + + + +/** + * @suppress + */ +public object FfiConverterSequenceTypeBenchSample: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeBenchSample.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterTypeBenchSample.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeBenchSample.write(it, buf) + } + } +} + @Throws(BenchException::class) fun `runBenchmark`(`spec`: BenchSpec): BenchReport { + return FfiConverterTypeBenchReport.lift( + uniffiRustCallWithError(BenchException) { _status -> + UniffiLib.INSTANCE.uniffi_sample_fns_fn_func_run_benchmark( + FfiConverterTypeBenchSpec.lower(`spec`),_status) +} + ) + } + + + diff --git a/android/app/src/main/jniLibs/arm64-v8a/.gitkeep b/android/app/src/main/jniLibs/arm64-v8a/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/android/app/src/main/jniLibs/arm64-v8a/.gitkeep @@ -0,0 +1 @@ + diff --git a/android/app/src/main/jniLibs/armeabi-v7a/.gitkeep b/android/app/src/main/jniLibs/armeabi-v7a/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/android/app/src/main/jniLibs/armeabi-v7a/.gitkeep @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..ba5178a --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..0e264a7 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Mobile Bench + Loading Rust benchmarks… + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..fb5b86a --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..c3e6c75 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,21 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean", Delete) { + delete(rootProject.buildDir) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..18825f9 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..980502d167d3610f88fa03b2f717935189d9fbcf GIT binary patch literal 43739 zcma&OV|1kL)-4>{b~@RPlI`agO}&qLNq0LVAdON+ZYxkG9wHh1Y?(XH82k$p_jmVdm zi@S!-+Tr)-L-!jKecV1e)7tD~6YpNnx1fAPz+2-3F=ehLkP4F%`kuCCA0o^<4|SFz z%JRrA@@qUF$g%QiEtXs#W1M0eU#+=3R?kaJ;AL_)O7q-^4h z3ZyV@;D?*d*3SnJd*`nN`@DeoA-DpvZr&qZ8hr8eC5H1ljV+R&6xCkr`ZTK1}y6(I+AOBpmD*v%HQ zMLQOWbyOT0?xxI%l;5C5%^_xv)%Gs7#m!H5{C5s4gdL>77ZF><13R$%08r2RXB!qL zm)oggrdN*5@9e?7t*3R|H_Q%0%L;z;iw##pPW0TP#20wjkX}U%%KP z;F43x7tGyxpG_~UiA{IXO?CKktzX7|WqMkXXbrIV1*&SS;=@4~%-D9YGl7n?BWk*k zCDuU1+GB~4A_)t_7W2$S(_EwTBWIULqrNfS$JcXs;gp%@nDED_bn~;NkT97~!A31N zGNckrHn>{gKYqwP6H7+|D{lQ>l=Zh|w*%(p@c`QtoDt1P^5R3cAnCnk)5A&YK(l~B0ukD#vSwwsE8y`5XddNYd% zL1&tsuVH7Y)*p0v{0!8Ln4KK&YrSgIM`mfnO~F-_OdwF8i1L_gId;JX5O$J(UwN_m zn+iPv-?1(Tk}Ms|JZA7*ZudW3v(^x__YIEVnKI+)FRAsA!}njzBtz|+FRVZQXfZr) zG63J#h;#G_b%CCIb%eF&0h%$eVZe&4!*3y|yC{>3*iTD&a^F zSpohx{U;{Uz;XaSs^f1|(o$IJpA4kCUWQ~}`GvTx9lw-K=JOi{KABDzez`iShbfz-Bch3PgjEET6RvhOQ67Q3hSna$D(^s7!W**H;_JuVqyB zE%eti3ks+y%tYx_^0Y-E-tBk#8mcOUpZUj~NYu07y=pyuNIo-d-{4>SBLUm(ts3P% zOe`gp+MY7QZjnO%L0k@*&;*^oZ-&21;2PE=3&ie1VZ*;|^+)p9X0`_N2bqXkg$#eA zY|tuN9&5DBBj0@?s5u)_Ft6Tc&2iY1j>!K6%Q~+!CYmD zf!zLeEZ!hEr79*73&7|$<4jhTqnkuXl)RH(S&3MA6>>xVr|(`^RZiu_McSpEdAyH2 z=nC%K$(^6%sM zcxDvX?*Qn|EoaQoCs(_}@huU8mXugwzGEV#+ekRmve+qFp^7~dHo~#c3aYmaqwXYe z6SD867qoY*=?XRX_#DLisQ1boo266>s>Zk$XQW0TH4dMc>z^_zGqc7s<*_>|Up*Ygs1SR$xUx-1!(?r!$(A{oY;Z`EQN=j2V2}079TcMe zzw zHEdYcJW(?BDQ$gdiSa8P94^Y>R4ZgnT(6r6lrFNFT}8hsG?PhY4ZqP{Lrj8|U5L}6 zOvwj1*fPGg-@gA(AY`Gzz%t5tUP-}k{uekvtPJljrXUZHO$(%Qx7nM{St7$lrc{Zd9>=q0SWfmTfu7LI$R2W0b?50c}3KRkm1NnGN{NwFhM37xfym+#N|G+l4;{`Crop=P^ZeXt z0xGZ8qgTIN8e5=m?%t}pfW3Y_f7z(cJJ>xs2s?NuL=(D9dn{jr|KV$}W9p+*(PM~6 zh+%zwZTNm|=RCHMY7dLsp$YWvy{s}<3A!=vpw0o0d6mW5xgarh@|#rzvrFhY4T(K7 z?WSRdb6dn?9cXD4xsF@;beW8~^wnD}WAG5O@@Rr)Xp{f&it{HLrth>}? zz>l_oI|J;iQh*`(F;uo2n-w&>CX#?KAJg%C)y(fMDOcV8wF@Jr(U_!M`oULpRPd}5 zb}#AR*yObx9^y^yU|PsGh`@ri>#^saV@^s!j$~*$YZlu-gGX>L9ae zpgPr8cD(Jrp}`pkZr~NW+jOeUaOgz*UqpuC=Up4?hsrSJRDP}bs!kW+|obs&#Ucu zTEMG8-Bn}4iT;xgEq7F4-{2zahKs`4+>HSss`?QvkYSK~_q{mDP7x))L{bq0!jCMP zH>nCcmvM)4YlO|ULAJ=sLfr$LVeho}SZ6ggo+AFtVjy|4pz)+>Ts{^!2|zt$mJ(Jv z@VxHfeP=>~e;ke>!4_lk5ie>ihFd^~_q(|qx1v0)3*zy#TR|EUs;0Ss-~=8BsEZs3 zNa5f5MYR9hFUktaNs5UotI)}c{U6VGD?2_WBTY*;120WWH90<2uf#CVynS#pPCG0) zAv-}WNdpXX8fucdU#Ladg8998zmO^z^E(DwA;z^6IAn{+@rwyr$su~lsaG*jig~eV zF@`232L>sbdEqHeK=keb$k)R`LVdp0?izhLRFkjs?;n=&>tXGk%<0XY3{7lI>5XkH z>4oiWZ4K>AWGwAW1)a=YZB6Z5L_Lg69b7E!?dXhc44s|-&nJJpbqoZWS5+~Cn&7>L1h_Ju;iqzu@*oVRq11&os;L`kquX~dp z$N&lwPOoWA^QIqDsA{IjXZ#@Xv1Qz*Du%-4kT|nQqC-50_*(=uGI6U=D?$;xPX`*A zLEI5l9dVost1%-E{Qgy72jBC#e(E3+vKlcCl1NE|@ZBn<5&JRdIWgZ!?qd?g0Q?U- zVB_f=^P)755_qO#GrfV)sCfgLm{{`k#@-_343|As%Ng|MIF#Gd3$l4^JpS;Q@E8ZG zoRq3*jL#Ezh#2Z~7srY1W0M#2Y|I7Cw30`Biyl4PjA=84+-X7vf89V;1}UXqZczBW z2;T+vDqbO8tNCMb7Vt}bLH~*bNH5qH@mIIN;p_bSNRa()B;@~JU%#o6wmhmL(gy-s zY7@0W9+aMAXJe5mEtDEV7ZN?GYV>!cX!?@&u=9XUmUiuY#vA@S#HTVbQVS!W2lprL z`IV4W6urr;^xKKYiS%^+A6@T23{l@hAHBV&sO=lL*xiDyt)en&i_ls7oHJ$*0e9<( zd+C9@T{Yl{V7zP|3QRb?%g|bKd9-$p+(@F8mMM6fG$Y{!T{Q}9W=GIx>Z{M%v}?rz z)7wQ%t-Xzf)WP(+QTgq?h!Rn|De0~0QX^>Xt82gvp(+#B&!HX^wml37YL>kT1x z%S!qWcwy~kDL>UR6>DKo;QH2l($3i2X?+{JXrmPb`Ga-`!u<^!a6q*H4fjJl7V{z! zF$%5rjd(ku*43G$DZkt_Qf&#qz$7z?8GNvB8KPZ?tPJmv14(JOtXbJjmJN=($#tD**>}mSyjKMN6Fk%wdDylGpO2bAX13-zAcRMtF2I4LoNyUMZq5Z3e z^^jDPB-#C(ItmGqcD^lzMpqP$k)!9NRl&VSuG$pSSP&-rAvseFIb-hVZBBUlN{;YL zb1jj$wyCI>Fi!KjT9GBYs#rhHLLxzm=Y|U;23j3GDke5qF^zHp2zKnUf37OBqmQHD zvdf0rT)5a3I^o_b24Psp(jZqgL1yt@$4!Q#jx|J5DD$IfeS$G5>YohehYqL>VArLW z+M0M%Fa;ZOVUCmo2s=#(Wii?G2@LMI>oOs+wubu6b=HRtK0i`~*G)?02=n`|5R$;# zQn3AuE=DkA(7Jag2gAC%`GIRyz}@4(nM|-(x<3{{>|mlPXy|5Fns> z(7%H+^WQ>Q!O+Rs)QMEk%*E8{pRjiR7g|YCK9@rkMB^0>C|a8hgnFW_`u47c!;lCw z2qq~bfy0oG^<-S!K4)s^-ju$P-#;Am1otrw_I;)w@(K{`zJ`L7!E!<7Y<`&Ie3{Pe z?)Uk84f`7e1}--?R!@x&i`DKN))H4bRFz#S6#WT)X)gg+Vh+(p@KwqqFf1^uork4T z*YG?{mY*f{bRAZ7#Db%E3bz<{sFap&QX4i7s+_9i&1>$~f@J;RkcT$JMTaujsYtjT zQYa)j>VeuB@rbIJ79l!L*Z}UuY+5DNT52{&iU z=y8<88bjAtC-GMz~ z?WYme8OXE&18Iw`zJ-6xYEBKYl|M@{W5FIDfr4=5EVc3QAn{_RpKPgm$078%va;pf zBDUBbL4hX!glnP2_>2__^j(51dJ`)7Fj}|aKJ~7B@&`4(LZ}VgskG0<)1X7+US<&( zblpns&t*D1f!=ib;m$vMsH0c5K!ykMV_B=B}P!02Q6&`eve^ z(3D5l987Q&bE)Fod-EvkHfzlLW$&nj9!ShFXlLQ}$h}T}fp{q`SXYT$#aB`GSDUda ze9~*Ev30643aNVtWed4QeJ`(UHI(m2xn>Sm?XawT;k=b*y@x6@2=0K4nFt}Tv28K0Olb68 z>YQm>noPo?ED7(q21c_qv&jjYJMYeee1z!G_lC3QXRX>=dO*mIPT@tR71HHCj5}^( z$CNJ-AO)~cjivW#3E@gMDib?>1iyAg&!j^b9r_tl8YO+^&>K2rqNz7Q$r0Rl&Jj)mr<>XI9naW; z4mQ`&fOkfFKwRjNQZ`crM(!Kg9>+`50rsI_ucXm{?3`w+IsM9HB>pHbL{r{28U%=$ zW9ap8zuolKbhZ+i!wfK2>%t zv6WkTr_MDcQy}1l?{Rq(?oLd7gx}unZ2w*Uj)oe7b@3(vzgU!1LC84X>y;)+FSx{uwqLOSWw+G2-k$VI-k|HEF zS_$Uuug>gvR3vrWWt6%$v2YJysF>`8p6LEZ=?X4^&`he9TwWi355sW8-0UX zWSHgW0!C!A(i>F3AAPq5Hq^n~XPTo%9iR6hyS?rRr}qfAIdk^NulblIaV2Wz>C@9+ z*S&MM-ZyBsKA#VkfY|mv;pho@+p5nWrt>oR`ekY738WB1hye{L6DOgkr>WQzS~%pb z6Yy1BSqNdOE7VQPK~`h9u9}vD8!22xHo(?04^uerSS1ks_{qyvmLjLUH+ zO*ze(9$VdDv#2nmQY%dJEV)qBJCXq+P8Dg_SJ_hw3YXG!)@@5;=ZHw6Q?*z~dTR_0 zYqn-tp({m{^sO%Cu+w0Qu-F)Bcre?7X_LK!yyo#AN>^$3m~3E;sOd`VRp&}gq)0CC zd4}ig#Bcqu)$?=}R(Fs^koV!}tZrwEIcJIXww94e%c@N*IJ-?5!MPDjJOc_Q>xpjw zlE-Gto0e2Ona)GW#37@lrxcuPI5VtOl)|Z%Xl?XX@%97uI;MMG=Em0WZYbo1iK>)a z>MFyJ@aRyKONq6xOE6^axqXJURvmTgs3MrVaN2ECLE+xo-r0B!`s+o<>s^w7O~mIQhax~z>d z;=zD#FW35k89Nt^Wz2Ij*92#TCKA{S8-}8La;uBHmhI$KJ@9a2lSQr6)wnp#-`BDF zWiN|cluh^aXT;1DVqz67H=Fi}O{jm9uI zGAePT1~BVDfjPnvUHeVU5jTB2w4MXxGB0vuWgW14B`U|cOo}-fw<`zyKu7f<Wru*%T{S?GMci2jVV(i^o6AGM?+jR%fulh>32< zxRE1Jbs*tP^JVQ&G88|eW9O6wrsQl@QLJ@>-s6Es7QLW-T}^k)Ohc;z>oRo%oKy6T zCI?j^rv!$MKkbT`QSfuKUnbrW?)CHvMX#7c1|>^7Y>v_ky&Af63EPUfDP;;?A#=oI z?&!L*PUxz|hdX=^L=;{%s5zNJ)ZCNat_+m}yMMtWt)5r>Ft3lnyqgYO%eZ^ELs z%%-@X5I@FIg8|6K-BO~Jx{A0wANTM%pX>DYvWT{DK7Y$XDdt+%=IH?4V@|_JC;7SK zrKI=P9O+<9xF7%h2bMG`Fk7%PB!Y^p;fW=U{CRrs=oOe+vy6eP35az8s>Tx5&);5# zL_D^?2Ls;~>vF`W-gg`;@Wt<#ZTuj$waI4O;O=+kIXKh%9|9zGTu)irlnNWodzGiJAA_=PNBKZIgni z63TJIBr44)c50k-F}?uwG;iy6XPSk7LJ^P>EXh27^~+CkMqZ^ub{-S_M^Z z@x(h(@xFI2wT5F+lP6}L{u<3GQ{#XpaU|1Rp~)d{ob8(MAy5l0^3v`Ni<3FNHghCO z1)rQ<9CKW}g2avY`MbaFtqom+@v3d2aKtWxbcB}38GJ}q{EAO9kL0}W=|-}D2B~JS zsvd;u?CiOsZF-0wD;<6wVeWn+_+*c8FJ|4_$v0y*)LB9$+IWPT@GVRZf08oyix!K# zSOo>s?tk8?W#&hHlkb0}boxN$)aOqUMWr5#6lM|UR_u-6 zIsXkY{?K^-+mw=bhk&Jb=I9@=#-SmNX#HBSZbjd>Skouau@xAWx`hTo@6PtJ<3-QX z7udgC+ok_S);a_bkP*V)>FIw|@XA^`J6qbB|5H)F+C%?OIaRimpHo2dqXUJ}P6&|e zXKx5}qu*GcZ}p#{nCUkOL=H-@ci+(c)zB=xM$0JX7v9~2m~kxgwvBitjx8^3K20NN zk>q{B>zi|wmE(LdrN8w9sI)vajVuPq+3+slPcZf*5W-NvZ zKcvBiGT2@^s>62&5-sX2=BCoA&myYp!4D>ysPo|7N13MMaY~LnY|k8hCV#s|HLEbEz))ZWGdK71H;wS(fxUKMo?lD)e)?Rdy+^J( z9$n?A8rFgvZFgVZF=AkcBAwh0%k@VH#V~UJ0nt5+LaqNZ%j7OzUo`n=;uba$Kk@cR zq$)R`bdP8TqC85&S$O>6UhxOv4C0WgBet}qPA@w8ks}dR-C+FnekgfZ_q&CPY8)n+ zTvBoE5e#-&OKb|oA-t7X)_eT3MiaJ@*ZPxd8!KFWf_K4DQ}AbEGhP4{t3IH8i~(~3 z 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 '} + 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 + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# 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" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + 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" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@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 + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +: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/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..0fc2137 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "mobile-bench-android" +include(":app") diff --git a/crates/bench-cli/Cargo.toml b/crates/bench-cli/Cargo.toml new file mode 100644 index 0000000..9d50b04 --- /dev/null +++ b/crates/bench-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "bench-cli" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +bench-runner = { path = "../bench-runner" } +sample-fns = { path = "../sample-fns" } +clap.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true +toml.workspace = true +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking", "json", "multipart"] } +dotenvy = "0.15" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/bench-cli/src/browserstack.rs b/crates/bench-cli/src/browserstack.rs new file mode 100644 index 0000000..2463413 --- /dev/null +++ b/crates/bench-cli/src/browserstack.rs @@ -0,0 +1,514 @@ +use anyhow::{Context, Result, anyhow}; +use reqwest::blocking::multipart::Form; +use reqwest::blocking::{Client, Response}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::Value; +use std::path::Path; + +const DEFAULT_BASE_URL: &str = "https://api-cloud.browserstack.com"; +const USER_AGENT: &str = "mobile-bench-rs/0.1"; + +#[derive(Debug, Clone)] +pub struct BrowserStackAuth { + pub username: String, + pub access_key: String, +} + +/// BrowserStack App Automate (Espresso) client. +#[derive(Debug, Clone)] +pub struct BrowserStackClient { + http: Client, + auth: BrowserStackAuth, + base_url: String, + project: Option, +} + +impl BrowserStackClient { + pub fn new(auth: BrowserStackAuth, project: Option) -> Result { + let http = Client::builder() + .user_agent(USER_AGENT) + .build() + .context("building HTTP client")?; + + Ok(Self { + http, + auth, + base_url: DEFAULT_BASE_URL.to_string(), + project, + }) + } + + #[cfg(test)] + #[allow(dead_code)] // Used in tests to verify URL construction + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + /// Upload an Espresso app-under-test APK to BrowserStack. + pub fn upload_espresso_app(&self, artifact: &Path) -> Result { + if !artifact.exists() { + return Err(anyhow!("app artifact not found at {:?}", artifact)); + } + + let form = Form::new().file("file", artifact)?; + let resp = self + .http + .post(self.api("app-automate/espresso/v2/app")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .multipart(form) + .send() + .context("uploading app to BrowserStack")?; + + parse_response(resp, "app upload") + } + + /// Upload an Espresso test-suite APK to BrowserStack. + pub fn upload_espresso_test_suite(&self, artifact: &Path) -> Result { + if !artifact.exists() { + return Err(anyhow!("test suite artifact not found at {:?}", artifact)); + } + + let form = Form::new().file("file", artifact)?; + let resp = self + .http + .post(self.api("app-automate/espresso/v2/test-suite")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .multipart(form) + .send() + .context("uploading test suite to BrowserStack")?; + + parse_response(resp, "test suite upload") + } + + pub fn upload_xcuitest_app(&self, artifact: &Path) -> Result { + if !artifact.exists() { + return Err(anyhow!("iOS app artifact not found at {:?}", artifact)); + } + + let form = Form::new().file("file", artifact)?; + let resp = self + .http + .post(self.api("app-automate/xcuitest/v2/app")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .multipart(form) + .send() + .context("uploading iOS app to BrowserStack")?; + + parse_response(resp, "iOS app upload") + } + + pub fn upload_xcuitest_test_suite(&self, artifact: &Path) -> Result { + if !artifact.exists() { + return Err(anyhow!( + "iOS XCUITest suite artifact not found at {:?}", + artifact + )); + } + + let form = Form::new().file("file", artifact)?; + let resp = self + .http + .post(self.api("app-automate/xcuitest/v2/test-suite")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .multipart(form) + .send() + .context("uploading iOS XCUITest suite to BrowserStack")?; + + parse_response(resp, "iOS XCUITest suite upload") + } + + pub fn schedule_espresso_run( + &self, + devices: &[String], + app_url: &str, + test_suite_url: &str, + ) -> Result { + if devices.is_empty() { + return Err(anyhow!("device list is empty; provide at least one target")); + } + if app_url.is_empty() { + return Err(anyhow!("app_url is empty")); + } + if test_suite_url.is_empty() { + return Err(anyhow!("test_suite_url is empty")); + } + + let body = BuildRequest { + app: app_url.to_string(), + test_suite: test_suite_url.to_string(), + devices: devices.to_vec(), + device_logs: true, + disable_animations: true, + build_name: self.project.clone(), + }; + + let resp = self + .http + .post(self.api("app-automate/espresso/v2/build")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .json(&body) + .send() + .context("scheduling BrowserStack Espresso run")?; + + let build: BuildResponse = parse_response(resp, "schedule run")?; + Ok(ScheduledRun { + build_id: build.build_id, + }) + } + + pub fn schedule_xcuitest_run( + &self, + devices: &[String], + app_url: &str, + test_suite_url: &str, + ) -> Result { + if devices.is_empty() { + return Err(anyhow!("device list is empty; provide at least one target")); + } + if app_url.is_empty() { + return Err(anyhow!("app_url is empty")); + } + if test_suite_url.is_empty() { + return Err(anyhow!("test_suite_url is empty")); + } + + let body = XcuitestBuildRequest { + app: app_url.to_string(), + test_suite: test_suite_url.to_string(), + devices: devices.to_vec(), + device_logs: true, + build_name: self.project.clone(), + }; + + let resp = self + .http + .post(self.api("app-automate/xcuitest/v2/build")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .json(&body) + .send() + .context("scheduling BrowserStack XCUITest run")?; + + let build: BuildResponse = parse_response(resp, "schedule run")?; + Ok(ScheduledRun { + build_id: build.build_id, + }) + } + + fn api(&self, path: &str) -> String { + format!( + "{}/{}", + self.base_url.trim_end_matches('/'), + path.trim_start_matches('/') + ) + } + + pub fn get_json(&self, path: &str) -> Result { + let resp = self + .http + .get(self.api(path)) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .send() + .with_context(|| format!("requesting BrowserStack API {}", path))?; + + parse_response(resp, path) + } + + pub fn download_url(&self, url: &str, dest: &Path) -> Result<()> { + let resp = self + .http + .get(url) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .send() + .with_context(|| format!("downloading BrowserStack asset {}", url))?; + let status = resp.status(); + let bytes = resp + .bytes() + .with_context(|| format!("reading BrowserStack asset body {}", url))?; + if !status.is_success() { + return Err(anyhow!( + "BrowserStack asset download failed (status {}): {}", + status, + String::from_utf8_lossy(&bytes) + )); + } + std::fs::write(dest, bytes) + .with_context(|| format!("writing BrowserStack asset to {:?}", dest))?; + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppUpload { + #[serde(alias = "appUrl")] + pub app_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TestSuiteUpload { + #[serde(alias = "test_suite_url", alias = "testSuiteUrl")] + pub test_suite_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScheduledRun { + pub build_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct BuildRequest { + app: String, + test_suite: String, + devices: Vec, + device_logs: bool, + disable_animations: bool, + #[serde(skip_serializing_if = "Option::is_none")] + build_name: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct XcuitestBuildRequest { + app: String, + test_suite: String, + devices: Vec, + device_logs: bool, + #[serde(skip_serializing_if = "Option::is_none")] + build_name: Option, +} + +#[derive(Debug, Deserialize)] +struct BuildResponse { + #[serde(alias = "build_id", alias = "buildId")] + build_id: String, +} + +fn parse_response(resp: Response, context: &str) -> Result { + let status = resp.status(); + let text = resp + .text() + .with_context(|| format!("reading BrowserStack API response body for {}", context))?; + + if !status.is_success() { + return Err(anyhow!( + "BrowserStack API {} failed (status {}): {}", + context, + status, + text + )); + } + + serde_json::from_str(&text) + .with_context(|| format!("parsing BrowserStack API response for {}", context)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_missing_artifact() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + let missing = Path::new("/tmp/definitely-missing-file"); + assert!(client.upload_espresso_app(missing).is_err()); + } + + #[test] + fn suppresses_dead_code_warning_for_test_helper() { + // This test uses with_base_url to verify it works and suppress the warning + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap() + .with_base_url("https://test.example.com"); + + assert_eq!(client.base_url, "https://test.example.com"); + } + + #[test] + fn new_client_uses_default_base_url() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "testuser".into(), + access_key: "testkey".into(), + }, + Some("test-project".into()), + ) + .unwrap(); + + assert_eq!(client.base_url, DEFAULT_BASE_URL); + assert_eq!(client.project, Some("test-project".to_string())); + } + + #[test] + fn api_constructs_url_correctly() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let url = client.api("app-automate/espresso/v2/app"); + assert_eq!(url, "https://api-cloud.browserstack.com/app-automate/espresso/v2/app"); + } + + #[test] + fn api_handles_leading_slash() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let url = client.api("/app-automate/builds"); + assert_eq!(url, "https://api-cloud.browserstack.com/app-automate/builds"); + } + + #[test] + fn api_handles_trailing_slash_in_base_url() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap() + .with_base_url("https://test.example.com/"); + + let url = client.api("endpoint"); + assert_eq!(url, "https://test.example.com/endpoint"); + } + + #[test] + fn schedule_espresso_run_rejects_empty_devices() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let result = client.schedule_espresso_run( + &[], + "bs://app123", + "bs://test456", + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("empty")); + } + + #[test] + fn schedule_espresso_run_rejects_empty_app_url() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let result = client.schedule_espresso_run( + &["Pixel 7-13".to_string()], + "", + "bs://test456", + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("app_url")); + } + + #[test] + fn schedule_espresso_run_rejects_empty_test_suite_url() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let result = client.schedule_espresso_run( + &["Pixel 7-13".to_string()], + "bs://app123", + "", + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("test_suite_url")); + } + + #[test] + fn schedule_xcuitest_run_rejects_empty_devices() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let result = client.schedule_xcuitest_run( + &[], + "bs://app123", + "bs://test456", + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("empty")); + } + + #[test] + fn upload_xcuitest_app_rejects_missing_artifact() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let missing = Path::new("/tmp/nonexistent-ios-app.ipa"); + assert!(client.upload_xcuitest_app(missing).is_err()); + } + + #[test] + fn upload_xcuitest_test_suite_rejects_missing_artifact() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let missing = Path::new("/tmp/nonexistent-test-suite.zip"); + assert!(client.upload_xcuitest_test_suite(missing).is_err()); + } +} diff --git a/crates/bench-cli/src/main.rs b/crates/bench-cli/src/main.rs new file mode 100644 index 0000000..43310c9 --- /dev/null +++ b/crates/bench-cli/src/main.rs @@ -0,0 +1,1052 @@ +use anyhow::{Context, Result, anyhow, bail}; +use bench_runner::{BenchSpec, run_closure}; +use clap::{Parser, Subcommand, ValueEnum}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; +use std::time::{Duration, Instant}; + +use browserstack::{BrowserStackAuth, BrowserStackClient}; + +mod browserstack; + +/// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. +#[derive(Parser, Debug)] +#[command(name = "bench-cli", author, version, about = "Mobile Rust benchmarking orchestrator", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Run a benchmark against a target platform (mobile integration stub for now). + Run { + #[arg(long, value_enum)] + target: MobileTarget, + #[arg(long, help = "Fully-qualified Rust function to benchmark")] + function: String, + #[arg(long, default_value_t = 100)] + iterations: u32, + #[arg(long, default_value_t = 10)] + warmup: u32, + #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] + devices: Vec, + #[arg(long, help = "Optional path to config file")] + config: Option, + #[arg(long, help = "Optional output path for JSON report")] + output: Option, + #[arg(long, help = "Skip mobile builds and only run the host harness")] + local_only: bool, + #[arg( + long, + help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" + )] + ios_app: Option, + #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] + ios_test_suite: Option, + #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] + fetch: bool, + #[arg(long, default_value = "target/browserstack")] + fetch_output_dir: PathBuf, + #[arg(long, default_value_t = 10)] + fetch_poll_interval_secs: u64, + #[arg(long, default_value_t = 1800)] + fetch_timeout_secs: u64, + }, + /// Run a local demo against bundled sample functions to validate the harness. + Demo { + #[arg(long, default_value_t = 50)] + iterations: u32, + #[arg(long, default_value_t = 5)] + warmup: u32, + }, + /// Scaffold a base config file for the CLI. + Init { + #[arg(long, default_value = "bench-config.toml")] + output: PathBuf, + #[arg(long, value_enum, default_value_t = MobileTarget::Android)] + target: MobileTarget, + }, + /// Generate a sample device matrix file. + Plan { + #[arg(long, default_value = "device-matrix.yaml")] + output: PathBuf, + }, + /// Fetch BrowserStack build artifacts (logs, session JSON) for CI. + Fetch { + #[arg(long, value_enum)] + target: MobileTarget, + #[arg(long)] + build_id: String, + #[arg(long, default_value = "target/browserstack")] + output_dir: PathBuf, + #[arg(long, default_value_t = true)] + wait: bool, + #[arg(long, default_value_t = 10)] + poll_interval_secs: u64, + #[arg(long, default_value_t = 1800)] + timeout_secs: u64, + }, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum MobileTarget { + Android, + Ios, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct BrowserStackConfig { + app_automate_username: String, + app_automate_access_key: String, + project: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct IosXcuitestArtifacts { + app: PathBuf, + test_suite: PathBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +struct BenchConfig { + target: MobileTarget, + function: String, + iterations: u32, + warmup: u32, + device_matrix: PathBuf, + browserstack: BrowserStackConfig, + #[serde(skip_serializing_if = "Option::is_none", default)] + ios_xcuitest: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct DeviceEntry { + name: String, + os: String, + os_version: String, + tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DeviceMatrix { + devices: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RunSpec { + target: MobileTarget, + function: String, + iterations: u32, + warmup: u32, + devices: Vec, + #[serde(skip_serializing, skip_deserializing, default)] + browserstack: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + ios_xcuitest: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "platform", rename_all = "lowercase")] +enum MobileArtifacts { + Android { + apk: PathBuf, + }, + Ios { + xcframework: PathBuf, + header: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + app: Option, + #[serde(skip_serializing_if = "Option::is_none")] + test_suite: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct RunSummary { + spec: RunSpec, + artifacts: Option, + local_report: Value, + remote_run: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "platform", rename_all = "lowercase")] +enum RemoteRun { + Android { + app_url: String, + build_id: String, + }, + Ios { + app_url: String, + test_suite_url: String, + build_id: String, + }, +} + +fn main() -> Result<()> { + load_dotenv(); + let cli = Cli::parse(); + match cli.command { + Command::Run { + target, + function, + iterations, + warmup, + devices, + config, + output, + local_only, + ios_app, + ios_test_suite, + fetch, + fetch_output_dir, + fetch_poll_interval_secs, + fetch_timeout_secs, + } => { + let spec = resolve_run_spec( + target, + function, + iterations, + warmup, + devices, + config.as_deref(), + ios_app, + ios_test_suite, + )?; + println!( + "Preparing benchmark run for {:?}: {} (iterations={}, warmup={})", + spec.target, spec.function, spec.iterations, spec.warmup + ); + persist_mobile_spec(&spec)?; + if !spec.devices.is_empty() { + println!("Devices: {}", spec.devices.join(", ")); + } + if let Some(path) = &output { + println!("JSON summary will be written to {:?}", path); + } + + let local_report = run_local_smoke(&spec)?; + let mut remote_run = None; + let artifacts = if local_only { + println!("Skipping mobile build: --local-only set"); + None + } else { + match spec.target { + MobileTarget::Android => { + let ndk = std::env::var("ANDROID_NDK_HOME") + .context("ANDROID_NDK_HOME must be set for Android builds")?; + let apk = run_android_build(&ndk)?; + println!("Built Android APK at {:?}", apk); + if spec.devices.is_empty() { + println!("Skipping BrowserStack upload/run: no devices provided"); + Some(MobileArtifacts::Android { apk }) + } else { + let run = trigger_browserstack_espresso(&spec, &apk)?; + remote_run = Some(run); + Some(MobileArtifacts::Android { apk }) + } + } + MobileTarget::Ios => { + let (xcframework, header) = run_ios_build()?; + println!("Built iOS xcframework at {:?}", xcframework); + let ios_xcuitest = spec.ios_xcuitest.clone(); + + if spec.devices.is_empty() { + println!("Skipping BrowserStack upload/run: no devices provided"); + } else { + let xcui = spec.ios_xcuitest.as_ref().context( + "iOS XCUITest artifacts required when targeting BrowserStack devices; provide --ios-app and --ios-test-suite or set ios_xcuitest in the config", + )?; + let run = trigger_browserstack_xcuitest(&spec, xcui)?; + remote_run = Some(run); + } + + Some(MobileArtifacts::Ios { + xcframework, + header, + app: ios_xcuitest.as_ref().map(|a| a.app.clone()), + test_suite: ios_xcuitest.map(|a| a.test_suite), + }) + } + } + }; + + let summary = RunSummary { + spec, + artifacts, + local_report, + remote_run, + }; + write_summary(&summary, output.as_deref())?; + + if fetch { + if let Some(remote) = &summary.remote_run { + let build_id = match remote { + RemoteRun::Android { build_id, .. } => build_id, + RemoteRun::Ios { build_id, .. } => build_id, + }; + let creds = resolve_browserstack_credentials(summary.spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + )?; + let output_root = fetch_output_dir.join(build_id); + fetch_browserstack_artifacts( + &client, + summary.spec.target, + build_id, + &output_root, + true, + fetch_poll_interval_secs, + fetch_timeout_secs, + )?; + } else { + println!("No BrowserStack run to fetch (devices not provided?)"); + } + } + } + Command::Demo { iterations, warmup } => { + let spec = BenchSpec::new("sample_fns::fibonacci", iterations, warmup)?; + let report = run_closure(spec, || { + // This is the shape of the closure that will be invoked on-device; + // for now we reuse it locally. + let _ = sample_fns::fibonacci(24); + Ok(()) + })?; + + let json = serde_json::to_string_pretty(&report)?; + println!("{json}"); + } + Command::Init { output, target } => { + write_config_template(&output, target)?; + println!("Wrote starter config to {:?}", output); + } + Command::Plan { output } => { + write_device_matrix_template(&output)?; + println!("Wrote sample device matrix to {:?}", output); + } + Command::Fetch { + target, + build_id, + output_dir, + wait, + poll_interval_secs, + timeout_secs, + } => { + let creds = resolve_browserstack_credentials(None)?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + )?; + let output_root = output_dir.join(&build_id); + fetch_browserstack_artifacts( + &client, + target, + &build_id, + &output_root, + wait, + poll_interval_secs, + timeout_secs, + )?; + } + } + + Ok(()) +} + +fn write_config_template(path: &Path, target: MobileTarget) -> Result<()> { + ensure_can_write(path)?; + + let ios_xcuitest = if target == MobileTarget::Ios { + Some(IosXcuitestArtifacts { + app: PathBuf::from("target/ios/BenchRunner.ipa"), + test_suite: PathBuf::from("target/ios/BenchRunnerUITests.zip"), + }) + } else { + None + }; + + let cfg = BenchConfig { + target, + function: "sample_fns::fibonacci".into(), + iterations: 100, + warmup: 10, + device_matrix: PathBuf::from("device-matrix.yaml"), + browserstack: BrowserStackConfig { + app_automate_username: "${BROWSERSTACK_USERNAME}".into(), + app_automate_access_key: "${BROWSERSTACK_ACCESS_KEY}".into(), + project: Some("mobile-bench-rs".into()), + }, + ios_xcuitest, + }; + + let contents = toml::to_string_pretty(&cfg)?; + write_file(path, contents.as_bytes()) +} + +fn write_device_matrix_template(path: &Path) -> Result<()> { + ensure_can_write(path)?; + + let matrix = DeviceMatrix { + devices: vec![ + DeviceEntry { + name: "Pixel 7".into(), + os: "android".into(), + os_version: "13.0".into(), + tags: Some(vec!["default".into(), "pixel".into()]), + }, + DeviceEntry { + name: "iPhone 14".into(), + os: "ios".into(), + os_version: "16".into(), + tags: Some(vec!["default".into(), "iphone".into()]), + }, + ], + }; + + let contents = serde_yaml::to_string(&matrix)?; + write_file(path, contents.as_bytes()) +} + +fn fetch_browserstack_artifacts( + client: &BrowserStackClient, + target: MobileTarget, + build_id: &str, + output_root: &Path, + wait: bool, + poll_interval_secs: u64, + timeout_secs: u64, +) -> Result<()> { + fs::create_dir_all(output_root) + .with_context(|| format!("creating output dir {:?}", output_root))?; + + let base = browserstack_base_path(target); + let build_path = format!("{base}/builds/{build_id}"); + let sessions_path = format!("{base}/builds/{build_id}/sessions"); + + if wait { + wait_for_build(client, &build_path, poll_interval_secs, timeout_secs)?; + } + + let build_json = client.get_json(&build_path)?; + write_json(output_root.join("build.json"), &build_json)?; + + let sessions_json = match client.get_json(&sessions_path) { + Ok(value) => { + write_json(output_root.join("sessions.json"), &value)?; + Some(value) + } + Err(err) => { + let msg = shorten_html_error(&err.to_string()); + println!("Sessions endpoint unavailable; falling back to build.json: {msg}"); + None + } + }; + + let session_ids = extract_session_ids(sessions_json.as_ref().unwrap_or(&build_json)); + if session_ids.is_empty() { + println!("No sessions found for build {}", build_id); + return Ok(()); + } + + for session_id in session_ids { + let session_path = format!("{base}/builds/{build_id}/sessions/{session_id}"); + let session_json = client.get_json(&session_path)?; + let session_dir = output_root.join(format!("session-{}", session_id)); + fs::create_dir_all(&session_dir) + .with_context(|| format!("creating session dir {:?}", session_dir))?; + write_json(session_dir.join("session.json"), &session_json)?; + + let mut bench_report: Option = None; + for (key, url) in extract_url_fields(&session_json) { + let file_name = filename_for_url(&key, &url); + let dest = session_dir.join(file_name); + if let Err(err) = client.download_url(&url, &dest) { + println!("Skipping download for {key}: {err}"); + continue; + } + if key.contains("device_log") || key.contains("instrumentation_log") || key.contains("app_log") { + if let Ok(contents) = fs::read_to_string(&dest) { + if let Some(parsed) = extract_bench_json(&contents) { + bench_report = Some(parsed); + } + } + } + } + + if let Some(report) = bench_report { + write_json(session_dir.join("bench-report.json"), &report)?; + } + } + + println!("Fetched BrowserStack artifacts to {:?}", output_root); + Ok(()) +} + +fn browserstack_base_path(target: MobileTarget) -> &'static str { + match target { + MobileTarget::Android => "app-automate/espresso/v2", + MobileTarget::Ios => "app-automate/xcuitest/v2", + } +} + +fn wait_for_build( + client: &BrowserStackClient, + build_path: &str, + poll_interval_secs: u64, + timeout_secs: u64, +) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + loop { + let build_json = client.get_json(build_path)?; + if let Some(status) = build_json + .get("status") + .and_then(|val| val.as_str()) + .map(|val| val.to_lowercase()) + { + if status == "failed" || status == "error" { + println!("Build status: {status}"); + return Ok(()); + } + if status == "done" || status == "passed" || status == "completed" { + println!("Build status: {status}"); + return Ok(()); + } + println!("Build status: {status} (waiting)"); + } else { + println!("Build status missing; continuing without wait"); + return Ok(()); + } + + if Instant::now() >= deadline { + println!("Timed out waiting for build status"); + return Ok(()); + } + std::thread::sleep(Duration::from_secs(poll_interval_secs)); + } +} + +fn extract_session_ids(value: &Value) -> Vec { + let sessions = value + .get("sessions") + .and_then(|val| val.as_array()) + .or_else(|| value.as_array()); + let mut ids = Vec::new(); + if let Some(entries) = sessions { + for entry in entries { + let id = entry + .get("id") + .or_else(|| entry.get("session_id")) + .or_else(|| entry.get("sessionId")) + .and_then(|val| val.as_str()); + if let Some(id) = id { + ids.push(id.to_string()); + } + } + } + if ids.is_empty() { + if let Some(devices) = value.get("devices").and_then(|val| val.as_array()) { + for device in devices { + if let Some(sessions) = device.get("sessions").and_then(|val| val.as_array()) { + for entry in sessions { + if let Some(id) = entry.get("id").and_then(|val| val.as_str()) { + ids.push(id.to_string()); + } + } + } + } + } + } + ids +} + +fn extract_url_fields(value: &Value) -> Vec<(String, String)> { + let mut urls = Vec::new(); + extract_url_fields_recursive(value, "", &mut urls); + urls +} + +fn extract_url_fields_recursive(value: &Value, prefix: &str, out: &mut Vec<(String, String)>) { + match value { + Value::Object(map) => { + for (key, val) in map { + let next = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + if let Value::String(url) = val { + if url.starts_with("http") || url.starts_with("bs://") { + out.push((next.clone(), url.clone())); + } + } + extract_url_fields_recursive(val, &next, out); + } + } + Value::Array(items) => { + for (idx, val) in items.iter().enumerate() { + let next = format!("{}[{}]", prefix, idx); + extract_url_fields_recursive(val, &next, out); + } + } + _ => {} + } +} + +fn filename_for_url(key: &str, url: &str) -> String { + let stripped = url.split('?').next().unwrap_or(url); + let ext = Path::new(stripped) + .extension() + .and_then(|val| val.to_str()) + .unwrap_or("log"); + let mut safe = String::with_capacity(key.len()); + for ch in key.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + safe.push(ch); + } else { + safe.push('_'); + } + } + format!("{}.{}", safe, ext) +} + +fn extract_bench_json(contents: &str) -> Option { + let marker = "BENCH_JSON "; + for line in contents.lines().rev() { + if let Some(idx) = line.find(marker) { + let json_part = &line[idx + marker.len()..]; + if let Ok(value) = serde_json::from_str::(json_part) { + return Some(value); + } + } + } + None +} + +fn write_json(path: PathBuf, value: &Value) -> Result<()> { + let contents = serde_json::to_string_pretty(value)?; + write_file(&path, contents.as_bytes()) +} + +fn shorten_html_error(message: &str) -> String { + if message.contains("") || message.contains(", + config: Option<&Path>, + ios_app: Option, + ios_test_suite: Option, +) -> Result { + if let Some(cfg_path) = config { + let cfg = load_config(cfg_path)?; + let matrix = load_device_matrix(&cfg.device_matrix)?; + let device_names = matrix.devices.into_iter().map(|d| d.name).collect(); + return Ok(RunSpec { + target: cfg.target, + function: cfg.function, + iterations: cfg.iterations, + warmup: cfg.warmup, + devices: device_names, + browserstack: Some(cfg.browserstack), + ios_xcuitest: cfg.ios_xcuitest, + }); + } + + if function.trim().is_empty() { + bail!("function must not be empty"); + } + + let ios_xcuitest = match (ios_app, ios_test_suite) { + (Some(app), Some(test_suite)) => Some(IosXcuitestArtifacts { app, test_suite }), + (None, None) => None, + _ => bail!("both --ios-app and --ios-test-suite must be provided together"), + }; + + if target == MobileTarget::Ios && !devices.is_empty() && ios_xcuitest.is_none() { + bail!( + "iOS BrowserStack runs require --ios-app and --ios-test-suite or an ios_xcuitest config block" + ); + } + + Ok(RunSpec { + target, + function, + iterations, + warmup, + devices, + browserstack: None, + ios_xcuitest, + }) +} + +fn load_config(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("reading config {:?}", path))?; + toml::from_str(&contents).with_context(|| format!("parsing config {:?}", path)) +} + +fn load_device_matrix(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("reading device matrix {:?}", path))?; + serde_yaml::from_str(&contents).with_context(|| format!("parsing device matrix {:?}", path)) +} + +fn run_ios_build() -> Result<(PathBuf, PathBuf)> { + let root = repo_root()?; + run_cmd(ProcessCommand::new(root.join("scripts/build-ios.sh")).current_dir(&root))?; + + Ok(( + root.join("target/ios/sample_fns.xcframework"), + root.join("target/ios/include/sample_fns.h"), + )) +} + +#[derive(Debug, Clone)] +struct ResolvedBrowserStack { + username: String, + access_key: String, + project: Option, +} + +fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path) -> Result { + let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username.clone(), + access_key: creds.access_key.clone(), + }, + creds.project.clone(), + )?; + + // Upload the app-under-test APK. + let upload = client.upload_espresso_app(apk)?; + + // Upload the Espresso test-suite APK produced by Gradle. + // We rely on the standard androidTest debug output path. + let root = repo_root()?; + let test_apk = + root.join("android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk"); + let test_upload = client.upload_espresso_test_suite(&test_apk)?; + + // Schedule the Espresso build with both app and testSuite, as required by BrowserStack. + let run = client.schedule_espresso_run( + &spec.devices, + &upload.app_url, + &test_upload.test_suite_url, + )?; + println!( + "Queued BrowserStack Espresso build {} for devices: {}", + run.build_id, + spec.devices.join(", ") + ); + + Ok(RemoteRun::Android { + app_url: upload.app_url, + build_id: run.build_id, + }) +} + +fn trigger_browserstack_xcuitest( + spec: &RunSpec, + artifacts: &IosXcuitestArtifacts, +) -> Result { + let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username.clone(), + access_key: creds.access_key.clone(), + }, + creds.project.clone(), + )?; + + if !artifacts.app.exists() { + bail!( + "iOS app artifact not found at {:?}; provide a .ipa or zipped .app", + artifacts.app + ); + } + if !artifacts.test_suite.exists() { + bail!( + "iOS XCUITest test suite artifact not found at {:?}; provide the zipped test runner bundle", + artifacts.test_suite + ); + } + + let app_upload = client.upload_xcuitest_app(&artifacts.app)?; + let test_upload = client.upload_xcuitest_test_suite(&artifacts.test_suite)?; + let run = client.schedule_xcuitest_run( + &spec.devices, + &app_upload.app_url, + &test_upload.test_suite_url, + )?; + println!( + "Queued BrowserStack XCUITest build {} for devices: {}", + run.build_id, + spec.devices.join(", ") + ); + + Ok(RemoteRun::Ios { + app_url: app_upload.app_url, + test_suite_url: test_upload.test_suite_url, + build_id: run.build_id, + }) +} + +fn resolve_browserstack_credentials( + config: Option<&BrowserStackConfig>, +) -> Result { + let mut username = None; + let mut access_key = None; + let mut project = None; + + if let Some(cfg) = config { + username = Some(expand_env_var(&cfg.app_automate_username)?); + access_key = Some(expand_env_var(&cfg.app_automate_access_key)?); + project = cfg + .project + .as_ref() + .map(|p| expand_env_var(p)) + .transpose()?; + } + + if username.as_deref().map(str::is_empty).unwrap_or(true) { + if let Ok(val) = env::var("BROWSERSTACK_USERNAME") { + if !val.is_empty() { + username = Some(val); + } + } + } + if access_key.as_deref().map(str::is_empty).unwrap_or(true) { + if let Ok(val) = env::var("BROWSERSTACK_ACCESS_KEY") { + if !val.is_empty() { + access_key = Some(val); + } + } + } + if project.is_none() { + if let Ok(val) = env::var("BROWSERSTACK_PROJECT") { + if !val.is_empty() { + project = Some(val); + } + } + } + + let username = username.filter(|s| !s.is_empty()).ok_or_else(|| { + anyhow!("BrowserStack username missing; set BROWSERSTACK_USERNAME or provide in config") + })?; + let access_key = access_key.filter(|s| !s.is_empty()).ok_or_else(|| { + anyhow!("BrowserStack access key missing; set BROWSERSTACK_ACCESS_KEY or provide in config") + })?; + + Ok(ResolvedBrowserStack { + username, + access_key, + project, + }) +} + +fn expand_env_var(raw: &str) -> Result { + if let Some(stripped) = raw.strip_prefix("${").and_then(|s| s.strip_suffix('}')) { + let val = env::var(stripped) + .with_context(|| format!("resolving env var {stripped} for BrowserStack config"))?; + return Ok(val); + } + Ok(raw.to_string()) +} + +fn run_local_smoke(spec: &RunSpec) -> Result { + let bench_spec = sample_fns::BenchSpec { + name: spec.function.clone(), + iterations: spec.iterations, + warmup: spec.warmup, + }; + + let report = sample_fns::run_benchmark(bench_spec) + .map_err(|e| anyhow!("benchmark failed: {:?}", e))?; + + serde_json::to_value(&report).context("serializing benchmark report") +} + +fn persist_mobile_spec(spec: &RunSpec) -> Result<()> { + let root = repo_root()?; + let payload = json!({ + "function": spec.function, + "iterations": spec.iterations, + "warmup": spec.warmup, + }); + let contents = serde_json::to_string_pretty(&payload)?; + let targets = [ + root.join("target/mobile-spec/android/bench_spec.json"), + root.join("target/mobile-spec/ios/bench_spec.json"), + ]; + for path in targets { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating directory {:?}", parent))?; + } + write_file(&path, contents.as_bytes())?; + } + Ok(()) +} + +fn write_summary(summary: &RunSummary, output: Option<&Path>) -> Result<()> { + let json = serde_json::to_string_pretty(summary)?; + if let Some(path) = output { + write_file(path, json.as_bytes())?; + println!("Wrote run summary to {:?}", path); + } else { + println!("{json}"); + } + Ok(()) +} + +fn run_android_build(ndk_home: &str) -> Result { + let root = repo_root()?; + run_cmd( + ProcessCommand::new(root.join("scripts/build-android.sh")) + .env("ANDROID_NDK_HOME", ndk_home) + .current_dir(&root), + )?; + run_cmd( + ProcessCommand::new(root.join("scripts/sync-android-libs.sh")) + .env("ANDROID_NDK_HOME", ndk_home) + .current_dir(&root), + )?; + run_cmd( + ProcessCommand::new(root.join("android/gradlew")) + .arg(":app:assembleDebug") + .current_dir(root.join("android")), + )?; + + // Also build the androidTest (Espresso) test APK so it can be uploaded as the test suite. + run_cmd( + ProcessCommand::new(root.join("android/gradlew")) + .arg(":app:assembleAndroidTest") + .current_dir(root.join("android")), + )?; + + Ok(root.join("android/app/build/outputs/apk/debug/app-debug.apk")) +} + +fn load_dotenv() { + if let Ok(root) = repo_root() { + let path = root.join(".env.local"); + let _ = dotenvy::from_path(path); + } +} + +fn run_cmd(cmd: &mut ProcessCommand) -> Result<()> { + let desc = format!("{:?}", cmd); + let status = cmd.status().with_context(|| format!("running {desc}"))?; + if !status.success() { + bail!("command failed: {desc}"); + } + Ok(()) +} + +fn repo_root() -> Result { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .context("resolving repo root")?; + Ok(path) +} + +fn ensure_can_write(path: &Path) -> Result<()> { + if path.exists() { + bail!("refusing to overwrite existing file: {:?}", path); + } + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent) + .with_context(|| format!("creating parent directory {:?}", parent))?; + } + Ok(()) +} + +fn write_file(path: &Path, contents: &[u8]) -> Result<()> { + fs::write(path, contents).with_context(|| format!("writing file {:?}", path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_cli_spec() { + let spec = resolve_run_spec( + MobileTarget::Android, + "sample_fns::fibonacci".into(), + 5, + 1, + vec!["pixel".into()], + None, + None, + None, + ) + .unwrap(); + assert_eq!(spec.function, "sample_fns::fibonacci"); + assert_eq!(spec.iterations, 5); + assert_eq!(spec.warmup, 1); + assert_eq!(spec.devices, vec!["pixel".to_string()]); + assert!(spec.browserstack.is_none()); + assert!(spec.ios_xcuitest.is_none()); + } + + #[test] + fn local_smoke_produces_samples() { + let spec = RunSpec { + target: MobileTarget::Android, + function: "sample_fns::fibonacci".into(), + iterations: 3, + warmup: 1, + devices: vec![], + browserstack: None, + ios_xcuitest: None, + }; + let report = run_local_smoke(&spec).expect("local harness"); + assert!(report["samples"].is_array()); + assert_eq!(report["spec"]["name"], "sample_fns::fibonacci"); + } + + #[test] + fn ios_requires_artifacts_for_browserstack() { + let err = resolve_run_spec( + MobileTarget::Ios, + "sample_fns::fibonacci".into(), + 1, + 0, + vec!["iphone".into()], + None, + None, + None, + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("iOS BrowserStack runs require --ios-app and --ios-test-suite") + ); + } +} diff --git a/crates/bench-runner/Cargo.toml b/crates/bench-runner/Cargo.toml new file mode 100644 index 0000000..ba4428a --- /dev/null +++ b/crates/bench-runner/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "bench-runner" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true diff --git a/crates/bench-runner/src/lib.rs b/crates/bench-runner/src/lib.rs new file mode 100644 index 0000000..915109b --- /dev/null +++ b/crates/bench-runner/src/lib.rs @@ -0,0 +1,92 @@ +//! Shared benchmarking harness that will be compiled into mobile targets. +//! For now this runs on the host and provides the same API surface we will +//! expose over FFI to Kotlin/Swift. + +use serde::{Deserialize, Serialize}; +use std::time::{Duration, Instant}; +use thiserror::Error; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} + +impl BenchSpec { + pub fn new(name: impl Into, iterations: u32, warmup: u32) -> Result { + if iterations == 0 { + return Err(BenchError::NoIterations); + } + + Ok(Self { + name: name.into(), + iterations, + warmup, + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BenchSample { + pub duration_ns: u64, +} + +impl BenchSample { + fn from_duration(duration: Duration) -> Self { + Self { + duration_ns: duration.as_nanos() as u64, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BenchReport { + pub spec: BenchSpec, + pub samples: Vec, +} + +#[derive(Debug, Error)] +pub enum BenchError { + #[error("iterations must be greater than zero")] + NoIterations, + #[error("benchmark function failed: {0}")] + Execution(String), +} + +pub fn run_closure(spec: BenchSpec, mut f: F) -> Result +where + F: FnMut() -> Result<(), BenchError>, +{ + if spec.iterations == 0 { + return Err(BenchError::NoIterations); + } + + for _ in 0..spec.warmup { + f()?; + } + + let mut samples = Vec::with_capacity(spec.iterations as usize); + for _ in 0..spec.iterations { + let start = Instant::now(); + f()?; + samples.push(BenchSample::from_duration(start.elapsed())); + } + + Ok(BenchReport { spec, samples }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runs_benchmark() { + let spec = BenchSpec::new("noop", 3, 1).unwrap(); + let report = run_closure(spec, || Ok(())).unwrap(); + + assert_eq!(report.samples.len(), 3); + let non_zero = report.samples.iter().filter(|s| s.duration_ns > 0).count(); + assert!(non_zero >= 1); + } +} diff --git a/crates/sample-fns/Cargo.toml b/crates/sample-fns/Cargo.toml new file mode 100644 index 0000000..1ef096b --- /dev/null +++ b/crates/sample-fns/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "sample-fns" +version.workspace = true +edition = "2021" # UniFFI 0.28 generates code for edition 2021 +license.workspace = true + +[lib] +name = "sample_fns" +crate-type = ["lib", "cdylib", "staticlib"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +bench-runner = { path = "../bench-runner" } +uniffi.workspace = true +thiserror.workspace = true +uniffi_bindgen = { version = "0.28", optional = true } +camino = { version = "1.1", optional = true } + +[build-dependencies] +uniffi = { workspace = true, features = ["build"] } + +[[bin]] +name = "generate-bindings" +path = "src/bin/generate-bindings.rs" +required-features = ["bindgen"] + +[features] +bindgen = ["uniffi_bindgen", "camino"] diff --git a/crates/sample-fns/build.rs b/crates/sample-fns/build.rs new file mode 100644 index 0000000..9c3ed62 --- /dev/null +++ b/crates/sample-fns/build.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::generate_scaffolding("./src/sample_fns.udl").unwrap(); +} diff --git a/crates/sample-fns/src/bin/generate-bindings.rs b/crates/sample-fns/src/bin/generate-bindings.rs new file mode 100644 index 0000000..d262502 --- /dev/null +++ b/crates/sample-fns/src/bin/generate-bindings.rs @@ -0,0 +1,77 @@ +use camino::Utf8PathBuf; +use std::env; +use std::fs; +use std::process; +use uniffi_bindgen::bindings::{KotlinBindingGenerator, SwiftBindingGenerator}; + +fn main() { + let manifest_dir = Utf8PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let root_dir = manifest_dir.parent().unwrap().parent().unwrap(); + let udl_file = manifest_dir.join("src/sample_fns.udl"); + + if !udl_file.exists() { + eprintln!("Error: UDL file not found at {:?}", udl_file); + process::exit(1); + } + + println!("Using UDL file: {:?}", udl_file); + + // Generate Kotlin bindings + let kotlin_out = root_dir.join("android/app/src/main/java"); + fs::create_dir_all(&kotlin_out).unwrap(); + println!("Generating Kotlin bindings to {:?}", kotlin_out); + + uniffi_bindgen::generate_bindings( + &udl_file, + None, // config file + KotlinBindingGenerator, + Some(kotlin_out.as_ref()), + None, // lib file + None, // crate name + false, // try_format_code + ).unwrap(); + + println!("✓ Kotlin bindings generated"); + + // Generate Swift bindings + let swift_out = root_dir.join("ios/BenchRunner/BenchRunner/Generated"); + fs::create_dir_all(&swift_out).unwrap(); + println!("Generating Swift bindings to {:?}", swift_out); + + uniffi_bindgen::generate_bindings( + &udl_file, + None, // config file + SwiftBindingGenerator, + Some(swift_out.as_ref()), + None, // lib file + None, // crate name + false, // try_format_code + ).unwrap(); + + println!("✓ Swift bindings generated"); + + println!("\n✓ All bindings generated successfully"); + + // List generated files + println!("\nGenerated Kotlin files:"); + list_files_recursively(&kotlin_out); + + println!("\nGenerated Swift files:"); + list_files_recursively(&swift_out); +} + +fn list_files_recursively(dir: &Utf8PathBuf) { + if let Ok(entries) = fs::read_dir(dir.as_std_path()) { + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if path.is_dir() { + println!(" Directory: {}", path.display()); + list_files_recursively(&Utf8PathBuf::from_path_buf(path).unwrap()); + } else { + println!(" File: {}", path.display()); + } + } + } + } +} diff --git a/crates/sample-fns/src/lib.rs b/crates/sample-fns/src/lib.rs new file mode 100644 index 0000000..2c6b72b --- /dev/null +++ b/crates/sample-fns/src/lib.rs @@ -0,0 +1,221 @@ +//! Sample benchmark functions for mobile testing using UniFFI (UDL mode). + +use bench_runner::{run_closure, BenchError as BenchRunnerError}; + +// Include UniFFI scaffolding (generated from UDL) +uniffi::include_scaffolding!("sample_fns"); + +const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; + +/// Specification for a benchmark run. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} + +/// A single benchmark sample with timing information. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BenchSample { + pub duration_ns: u64, +} + +/// Complete benchmark report with spec and timing samples. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BenchReport { + pub spec: BenchSpec, + pub samples: Vec, +} + +/// Error types for benchmark operations. +#[derive(Debug, thiserror::Error)] +pub enum BenchError { + #[error("iterations must be greater than zero")] + InvalidIterations, + + #[error("unknown benchmark function: {name}")] + UnknownFunction { name: String }, + + #[error("benchmark execution failed: {reason}")] + ExecutionFailed { reason: String }, +} + +// Conversion from bench-runner types +impl From for BenchSpec { + fn from(spec: bench_runner::BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for bench_runner::BenchSpec { + fn from(spec: BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for BenchSample { + fn from(sample: bench_runner::BenchSample) -> Self { + Self { + duration_ns: sample.duration_ns, + } + } +} + +impl From for BenchReport { + fn from(report: bench_runner::BenchReport) -> Self { + Self { + spec: report.spec.into(), + samples: report.samples.into_iter().map(Into::into).collect(), + } + } +} + +impl From for BenchError { + fn from(err: BenchRunnerError) -> Self { + match err { + BenchRunnerError::NoIterations => BenchError::InvalidIterations, + BenchRunnerError::Execution(msg) => BenchError::ExecutionFailed { reason: msg }, + } + } +} + +/// Run a benchmark by name with the given specification. +pub fn run_benchmark(spec: BenchSpec) -> Result { + let runner_spec: bench_runner::BenchSpec = spec.into(); + + let report = match runner_spec.name.as_str() { + "fibonacci" | "fib" | "sample_fns::fibonacci" => { + run_closure(runner_spec, || { + let result = fibonacci_batch(30, 1000); + // Use the result to prevent optimization + std::hint::black_box(result); + Ok(()) + }) + .map_err(|e: BenchRunnerError| -> BenchError { e.into() })? + } + "checksum" | "checksum_1k" | "sample_fns::checksum" => { + run_closure(runner_spec, || { + // Run checksum 10000 times to make it measurable + let mut sum = 0u64; + for _ in 0..10000 { + sum = sum.wrapping_add(checksum(&CHECKSUM_INPUT)); + } + // Use the result to prevent optimization + std::hint::black_box(sum); + Ok(()) + }) + .map_err(|e: BenchRunnerError| -> BenchError { e.into() })? + } + _ => { + return Err(BenchError::UnknownFunction { + name: runner_spec.name.clone(), + }) + } + }; + + Ok(report.into()) +} + +/// Compute fibonacci number iteratively. +pub fn fibonacci(n: u32) -> u64 { + match n { + 0 => 0, + 1 => 1, + _ => { + let mut a = 0u64; + let mut b = 1u64; + for _ in 2..=n { + let next = a.wrapping_add(b); + a = b; + b = next; + } + b + } + } +} + +/// Compute fibonacci in a more measurable way by doing it multiple times. +pub fn fibonacci_batch(n: u32, iterations: u32) -> u64 { + let mut result = 0u64; + for _ in 0..iterations { + result = result.wrapping_add(fibonacci(n)); + } + result +} + +/// Compute checksum by summing all bytes. +pub fn checksum(bytes: &[u8]) -> u64 { + bytes.iter().map(|&b| b as u64).sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fib_sequence() { + assert_eq!(fibonacci(0), 0); + assert_eq!(fibonacci(1), 1); + assert_eq!(fibonacci(10), 55); + assert_eq!(fibonacci(24), 46368); + } + + #[test] + fn checksum_matches() { + assert_eq!(checksum(&CHECKSUM_INPUT), 1024); + } + + #[test] + fn test_run_benchmark_fibonacci() { + let spec = BenchSpec { + name: "fibonacci".to_string(), + iterations: 3, + warmup: 1, + }; + let report = run_benchmark(spec).unwrap(); + assert_eq!(report.samples.len(), 3); + assert_eq!(report.spec.name, "fibonacci"); + } + + #[test] + fn test_run_benchmark_checksum() { + let spec = BenchSpec { + name: "checksum".to_string(), + iterations: 2, + warmup: 0, + }; + let report = run_benchmark(spec).unwrap(); + assert_eq!(report.samples.len(), 2); + } + + #[test] + fn test_unknown_function_error() { + let spec = BenchSpec { + name: "unknown".to_string(), + iterations: 1, + warmup: 0, + }; + let result = run_benchmark(spec); + assert!(matches!(result, Err(BenchError::UnknownFunction { .. }))); + } + + #[test] + fn test_invalid_iterations() { + let spec = BenchSpec { + name: "fibonacci".to_string(), + iterations: 0, + warmup: 0, + }; + let result = run_benchmark(spec); + assert!(matches!(result, Err(BenchError::InvalidIterations))); + } +} diff --git a/crates/sample-fns/src/sample_fns.udl b/crates/sample-fns/src/sample_fns.udl new file mode 100644 index 0000000..44ce090 --- /dev/null +++ b/crates/sample-fns/src/sample_fns.udl @@ -0,0 +1,26 @@ +namespace sample_fns { + [Throws=BenchError] + BenchReport run_benchmark(BenchSpec spec); +}; + +dictionary BenchSpec { + string name; + u32 iterations; + u32 warmup; +}; + +dictionary BenchSample { + u64 duration_ns; +}; + +dictionary BenchReport { + BenchSpec spec; + sequence samples; +}; + +[Error] +enum BenchError { + "InvalidIterations", + "UnknownFunction", + "ExecutionFailed", +}; diff --git a/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h b/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h new file mode 100644 index 0000000..cf2a868 --- /dev/null +++ b/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h @@ -0,0 +1,8 @@ +// +// BenchRunner-Bridging-Header.h +// BenchRunner +// +// Bridge to import C FFI from Rust (UniFFI-generated) +// + +#import "sample_fnsFFI.h" diff --git a/ios/BenchRunner/BenchRunner/BenchRunnerApp.swift b/ios/BenchRunner/BenchRunner/BenchRunnerApp.swift new file mode 100644 index 0000000..1bcc3d7 --- /dev/null +++ b/ios/BenchRunner/BenchRunner/BenchRunnerApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct BenchRunnerApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift b/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift new file mode 100644 index 0000000..33b8aa9 --- /dev/null +++ b/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift @@ -0,0 +1,118 @@ +import Foundation + +private let defaultFunction = "sample_fns::fibonacci" +private let defaultIterations: UInt32 = 20 +private let defaultWarmup: UInt32 = 3 + +struct BenchParams { + let function: String + let iterations: UInt32 + let warmup: UInt32 + + private struct EncodedBenchSpec: Decodable { + let function: String + let iterations: UInt32 + let warmup: UInt32 + } + + static func fromBundle() -> BenchParams? { + guard let url = Bundle.main.url(forResource: "bench_spec", withExtension: "json") else { + return nil + } + do { + let data = try Data(contentsOf: url) + let decoded = try JSONDecoder().decode(EncodedBenchSpec.self, from: data) + return BenchParams(function: decoded.function, iterations: decoded.iterations, warmup: decoded.warmup) + } catch { + return nil + } + } + + static func fromProcessInfo() -> BenchParams { + let info = ProcessInfo.processInfo + var function = info.environment["BENCH_FUNCTION"] ?? defaultFunction + var iterations = UInt32(info.environment["BENCH_ITERATIONS"] ?? "") ?? defaultIterations + var warmup = UInt32(info.environment["BENCH_WARMUP"] ?? "") ?? defaultWarmup + + for arg in info.arguments { + if arg.hasPrefix("--bench-function=") { + function = String(arg.split(separator: "=", maxSplits: 1).last ?? Substring(function)) + } else if arg.hasPrefix("--bench-iterations=") { + iterations = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? iterations + } else if arg.hasPrefix("--bench-warmup=") { + warmup = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? warmup + } + } + + return BenchParams(function: function, iterations: iterations, warmup: warmup) + } + + static func resolved() -> BenchParams { + if let bundled = fromBundle() { + return bundled + } + return fromProcessInfo() + } +} + +enum BenchRunnerFFI { + static func runCurrentBenchmark() async -> String { + let params = BenchParams.resolved() + return run(params: params) + } + + static func run(params: BenchParams) -> String { + let spec = BenchSpec( + name: params.function, + iterations: params.iterations, + warmup: params.warmup + ) + + do { + let report = try runBenchmark(spec: spec) + return formatBenchReport(report) + } catch let error as BenchError { + return formatBenchError(error) + } catch { + return "Unexpected error: \(error.localizedDescription)" + } + } + + private static func formatBenchReport(_ report: BenchReport) -> String { + var output = "=== Benchmark Results ===\n\n" + output += "Function: \(report.spec.name)\n" + output += "Iterations: \(report.spec.iterations)\n" + output += "Warmup: \(report.spec.warmup)\n\n" + + output += "Samples (\(report.samples.count)):\n" + for (index, sample) in report.samples.enumerated() { + let durationUs = Double(sample.durationNs) / 1_000.0 + output += " \(index + 1). \(String(format: "%.3f", durationUs)) μs (\(sample.durationNs) ns)\n" + } + + if !report.samples.isEmpty { + let durations = report.samples.map { Double($0.durationNs) / 1_000.0 } + let min = durations.min() ?? 0.0 + let max = durations.max() ?? 0.0 + let avg = durations.reduce(0, +) / Double(durations.count) + + output += "\nStatistics:\n" + output += " Min: \(String(format: "%.3f", min)) μs\n" + output += " Max: \(String(format: "%.3f", max)) μs\n" + output += " Avg: \(String(format: "%.3f", avg)) μs\n" + } + + return output + } + + private static func formatBenchError(_ error: BenchError) -> String { + switch error { + case .InvalidIterations(let message): + return "Error (InvalidIterations): \(message)" + case .UnknownFunction(let message): + return "Error (UnknownFunction): \(message)" + case .ExecutionFailed(let message): + return "Error (ExecutionFailed): \(message)" + } + } +} diff --git a/ios/BenchRunner/BenchRunner/ContentView.swift b/ios/BenchRunner/BenchRunner/ContentView.swift new file mode 100644 index 0000000..4ce7837 --- /dev/null +++ b/ios/BenchRunner/BenchRunner/ContentView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct ContentView: View { + @State private var report: String = "Running benchmark..." + + var body: some View { + ScrollView { + Text(report) + .font(.system(.body, design: .monospaced)) + .accessibilityIdentifier("benchmarkReport") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .background(Color(UIColor.systemBackground)) + .onAppear { + Task { + report = await BenchRunnerFFI.runCurrentBenchmark() + } + } + } +} + +#Preview { + ContentView() +} diff --git a/ios/BenchRunner/BenchRunner/Generated/sample_fns.swift b/ios/BenchRunner/BenchRunner/Generated/sample_fns.swift new file mode 100644 index 0000000..532a59b --- /dev/null +++ b/ios/BenchRunner/BenchRunner/Generated/sample_fns.swift @@ -0,0 +1,806 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +// swiftlint:disable all +import Foundation + +// Depending on the consumer's build setup, the low-level FFI code +// might be in a separate module, or it might be compiled inline into +// this module. This is a bit of light hackery to work with both. +#if canImport(sample_fnsFFI) +import sample_fnsFFI +#endif + +fileprivate extension RustBuffer { + // Allocate a new buffer, copying the contents of a `UInt8` array. + init(bytes: [UInt8]) { + let rbuf = bytes.withUnsafeBufferPointer { ptr in + RustBuffer.from(ptr) + } + self.init(capacity: rbuf.capacity, len: rbuf.len, data: rbuf.data) + } + + static func empty() -> RustBuffer { + RustBuffer(capacity: 0, len:0, data: nil) + } + + static func from(_ ptr: UnsafeBufferPointer) -> RustBuffer { + try! rustCall { ffi_sample_fns_rustbuffer_from_bytes(ForeignBytes(bufferPointer: ptr), $0) } + } + + // Frees the buffer in place. + // The buffer must not be used after this is called. + func deallocate() { + try! rustCall { ffi_sample_fns_rustbuffer_free(self, $0) } + } +} + +fileprivate extension ForeignBytes { + init(bufferPointer: UnsafeBufferPointer) { + self.init(len: Int32(bufferPointer.count), data: bufferPointer.baseAddress) + } +} + +// For every type used in the interface, we provide helper methods for conveniently +// lifting and lowering that type from C-compatible data, and for reading and writing +// values of that type in a buffer. + +// Helper classes/extensions that don't change. +// Someday, this will be in a library of its own. + +fileprivate extension Data { + init(rustBuffer: RustBuffer) { + self.init( + bytesNoCopy: rustBuffer.data!, + count: Int(rustBuffer.len), + deallocator: .none + ) + } +} + +// Define reader functionality. Normally this would be defined in a class or +// struct, but we use standalone functions instead in order to make external +// types work. +// +// With external types, one swift source file needs to be able to call the read +// method on another source file's FfiConverter, but then what visibility +// should Reader have? +// - If Reader is fileprivate, then this means the read() must also +// be fileprivate, which doesn't work with external types. +// - If Reader is internal/public, we'll get compile errors since both source +// files will try define the same type. +// +// Instead, the read() method and these helper functions input a tuple of data + +fileprivate func createReader(data: Data) -> (data: Data, offset: Data.Index) { + (data: data, offset: 0) +} + +// Reads an integer at the current offset, in big-endian order, and advances +// the offset on success. Throws if reading the integer would move the +// offset past the end of the buffer. +fileprivate func readInt(_ reader: inout (data: Data, offset: Data.Index)) throws -> T { + let range = reader.offset...size + guard reader.data.count >= range.upperBound else { + throw UniffiInternalError.bufferOverflow + } + if T.self == UInt8.self { + let value = reader.data[reader.offset] + reader.offset += 1 + return value as! T + } + var value: T = 0 + let _ = withUnsafeMutableBytes(of: &value, { reader.data.copyBytes(to: $0, from: range)}) + reader.offset = range.upperBound + return value.bigEndian +} + +// Reads an arbitrary number of bytes, to be used to read +// raw bytes, this is useful when lifting strings +fileprivate func readBytes(_ reader: inout (data: Data, offset: Data.Index), count: Int) throws -> Array { + let range = reader.offset..<(reader.offset+count) + guard reader.data.count >= range.upperBound else { + throw UniffiInternalError.bufferOverflow + } + var value = [UInt8](repeating: 0, count: count) + value.withUnsafeMutableBufferPointer({ buffer in + reader.data.copyBytes(to: buffer, from: range) + }) + reader.offset = range.upperBound + return value +} + +// Reads a float at the current offset. +fileprivate func readFloat(_ reader: inout (data: Data, offset: Data.Index)) throws -> Float { + return Float(bitPattern: try readInt(&reader)) +} + +// Reads a float at the current offset. +fileprivate func readDouble(_ reader: inout (data: Data, offset: Data.Index)) throws -> Double { + return Double(bitPattern: try readInt(&reader)) +} + +// Indicates if the offset has reached the end of the buffer. +fileprivate func hasRemaining(_ reader: (data: Data, offset: Data.Index)) -> Bool { + return reader.offset < reader.data.count +} + +// Define writer functionality. Normally this would be defined in a class or +// struct, but we use standalone functions instead in order to make external +// types work. See the above discussion on Readers for details. + +fileprivate func createWriter() -> [UInt8] { + return [] +} + +fileprivate func writeBytes(_ writer: inout [UInt8], _ byteArr: S) where S: Sequence, S.Element == UInt8 { + writer.append(contentsOf: byteArr) +} + +// Writes an integer in big-endian order. +// +// Warning: make sure what you are trying to write +// is in the correct type! +fileprivate func writeInt(_ writer: inout [UInt8], _ value: T) { + var value = value.bigEndian + withUnsafeBytes(of: &value) { writer.append(contentsOf: $0) } +} + +fileprivate func writeFloat(_ writer: inout [UInt8], _ value: Float) { + writeInt(&writer, value.bitPattern) +} + +fileprivate func writeDouble(_ writer: inout [UInt8], _ value: Double) { + writeInt(&writer, value.bitPattern) +} + +// Protocol for types that transfer other types across the FFI. This is +// analogous to the Rust trait of the same name. +fileprivate protocol FfiConverter { + associatedtype FfiType + associatedtype SwiftType + + static func lift(_ value: FfiType) throws -> SwiftType + static func lower(_ value: SwiftType) -> FfiType + static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType + static func write(_ value: SwiftType, into buf: inout [UInt8]) +} + +// Types conforming to `Primitive` pass themselves directly over the FFI. +fileprivate protocol FfiConverterPrimitive: FfiConverter where FfiType == SwiftType { } + +extension FfiConverterPrimitive { +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lift(_ value: FfiType) throws -> SwiftType { + return value + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lower(_ value: SwiftType) -> FfiType { + return value + } +} + +// Types conforming to `FfiConverterRustBuffer` lift and lower into a `RustBuffer`. +// Used for complex types where it's hard to write a custom lift/lower. +fileprivate protocol FfiConverterRustBuffer: FfiConverter where FfiType == RustBuffer {} + +extension FfiConverterRustBuffer { +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lift(_ buf: RustBuffer) throws -> SwiftType { + var reader = createReader(data: Data(rustBuffer: buf)) + let value = try read(from: &reader) + if hasRemaining(reader) { + throw UniffiInternalError.incompleteData + } + buf.deallocate() + return value + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lower(_ value: SwiftType) -> RustBuffer { + var writer = createWriter() + write(value, into: &writer) + return RustBuffer(bytes: writer) + } +} +// An error type for FFI errors. These errors occur at the UniFFI level, not +// the library level. +fileprivate enum UniffiInternalError: LocalizedError { + case bufferOverflow + case incompleteData + case unexpectedOptionalTag + case unexpectedEnumCase + case unexpectedNullPointer + case unexpectedRustCallStatusCode + case unexpectedRustCallError + case unexpectedStaleHandle + case rustPanic(_ message: String) + + public var errorDescription: String? { + switch self { + case .bufferOverflow: return "Reading the requested value would read past the end of the buffer" + case .incompleteData: return "The buffer still has data after lifting its containing value" + case .unexpectedOptionalTag: return "Unexpected optional tag; should be 0 or 1" + case .unexpectedEnumCase: return "Raw enum value doesn't match any cases" + case .unexpectedNullPointer: return "Raw pointer value was null" + case .unexpectedRustCallStatusCode: return "Unexpected RustCallStatus code" + case .unexpectedRustCallError: return "CALL_ERROR but no errorClass specified" + case .unexpectedStaleHandle: return "The object in the handle map has been dropped already" + case let .rustPanic(message): return message + } + } +} + +fileprivate extension NSLock { + func withLock(f: () throws -> T) rethrows -> T { + self.lock() + defer { self.unlock() } + return try f() + } +} + +fileprivate let CALL_SUCCESS: Int8 = 0 +fileprivate let CALL_ERROR: Int8 = 1 +fileprivate let CALL_UNEXPECTED_ERROR: Int8 = 2 +fileprivate let CALL_CANCELLED: Int8 = 3 + +fileprivate extension RustCallStatus { + init() { + self.init( + code: CALL_SUCCESS, + errorBuf: RustBuffer.init( + capacity: 0, + len: 0, + data: nil + ) + ) + } +} + +private func rustCall(_ callback: (UnsafeMutablePointer) -> T) throws -> T { + let neverThrow: ((RustBuffer) throws -> Never)? = nil + return try makeRustCall(callback, errorHandler: neverThrow) +} + +private func rustCallWithError( + _ errorHandler: @escaping (RustBuffer) throws -> E, + _ callback: (UnsafeMutablePointer) -> T) throws -> T { + try makeRustCall(callback, errorHandler: errorHandler) +} + +private func makeRustCall( + _ callback: (UnsafeMutablePointer) -> T, + errorHandler: ((RustBuffer) throws -> E)? +) throws -> T { + uniffiEnsureInitialized() + var callStatus = RustCallStatus.init() + let returnedVal = callback(&callStatus) + try uniffiCheckCallStatus(callStatus: callStatus, errorHandler: errorHandler) + return returnedVal +} + +private func uniffiCheckCallStatus( + callStatus: RustCallStatus, + errorHandler: ((RustBuffer) throws -> E)? +) throws { + switch callStatus.code { + case CALL_SUCCESS: + return + + case CALL_ERROR: + if let errorHandler = errorHandler { + throw try errorHandler(callStatus.errorBuf) + } else { + callStatus.errorBuf.deallocate() + throw UniffiInternalError.unexpectedRustCallError + } + + case CALL_UNEXPECTED_ERROR: + // When the rust code sees a panic, it tries to construct a RustBuffer + // with the message. But if that code panics, then it just sends back + // an empty buffer. + if callStatus.errorBuf.len > 0 { + throw UniffiInternalError.rustPanic(try FfiConverterString.lift(callStatus.errorBuf)) + } else { + callStatus.errorBuf.deallocate() + throw UniffiInternalError.rustPanic("Rust panic") + } + + case CALL_CANCELLED: + fatalError("Cancellation not supported yet") + + default: + throw UniffiInternalError.unexpectedRustCallStatusCode + } +} + +private func uniffiTraitInterfaceCall( + callStatus: UnsafeMutablePointer, + makeCall: () throws -> T, + writeReturn: (T) -> () +) { + do { + try writeReturn(makeCall()) + } catch let error { + callStatus.pointee.code = CALL_UNEXPECTED_ERROR + callStatus.pointee.errorBuf = FfiConverterString.lower(String(describing: error)) + } +} + +private func uniffiTraitInterfaceCallWithError( + callStatus: UnsafeMutablePointer, + makeCall: () throws -> T, + writeReturn: (T) -> (), + lowerError: (E) -> RustBuffer +) { + do { + try writeReturn(makeCall()) + } catch let error as E { + callStatus.pointee.code = CALL_ERROR + callStatus.pointee.errorBuf = lowerError(error) + } catch { + callStatus.pointee.code = CALL_UNEXPECTED_ERROR + callStatus.pointee.errorBuf = FfiConverterString.lower(String(describing: error)) + } +} +fileprivate class UniffiHandleMap { + private var map: [UInt64: T] = [:] + private let lock = NSLock() + private var currentHandle: UInt64 = 1 + + func insert(obj: T) -> UInt64 { + lock.withLock { + let handle = currentHandle + currentHandle += 1 + map[handle] = obj + return handle + } + } + + func get(handle: UInt64) throws -> T { + try lock.withLock { + guard let obj = map[handle] else { + throw UniffiInternalError.unexpectedStaleHandle + } + return obj + } + } + + @discardableResult + func remove(handle: UInt64) throws -> T { + try lock.withLock { + guard let obj = map.removeValue(forKey: handle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return obj + } + } + + var count: Int { + get { + map.count + } + } +} + + +// Public interface members begin here. + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterUInt32: FfiConverterPrimitive { + typealias FfiType = UInt32 + typealias SwiftType = UInt32 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt32 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterUInt64: FfiConverterPrimitive { + typealias FfiType = UInt64 + typealias SwiftType = UInt64 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt64 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterString: FfiConverter { + typealias SwiftType = String + typealias FfiType = RustBuffer + + public static func lift(_ value: RustBuffer) throws -> String { + defer { + value.deallocate() + } + if value.data == nil { + return String() + } + let bytes = UnsafeBufferPointer(start: value.data!, count: Int(value.len)) + return String(bytes: bytes, encoding: String.Encoding.utf8)! + } + + public static func lower(_ value: String) -> RustBuffer { + return value.utf8CString.withUnsafeBufferPointer { ptr in + // The swift string gives us int8_t, we want uint8_t. + ptr.withMemoryRebound(to: UInt8.self) { ptr in + // The swift string gives us a trailing null byte, we don't want it. + let buf = UnsafeBufferPointer(rebasing: ptr.prefix(upTo: ptr.count - 1)) + return RustBuffer.from(buf) + } + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> String { + let len: Int32 = try readInt(&buf) + return String(bytes: try readBytes(&buf, count: Int(len)), encoding: String.Encoding.utf8)! + } + + public static func write(_ value: String, into buf: inout [UInt8]) { + let len = Int32(value.utf8.count) + writeInt(&buf, len) + writeBytes(&buf, value.utf8) + } +} + + +public struct BenchReport { + public var spec: BenchSpec + public var samples: [BenchSample] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(spec: BenchSpec, samples: [BenchSample]) { + self.spec = spec + self.samples = samples + } +} + + + +extension BenchReport: Equatable, Hashable { + public static func ==(lhs: BenchReport, rhs: BenchReport) -> Bool { + if lhs.spec != rhs.spec { + return false + } + if lhs.samples != rhs.samples { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(spec) + hasher.combine(samples) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeBenchReport: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BenchReport { + return + try BenchReport( + spec: FfiConverterTypeBenchSpec.read(from: &buf), + samples: FfiConverterSequenceTypeBenchSample.read(from: &buf) + ) + } + + public static func write(_ value: BenchReport, into buf: inout [UInt8]) { + FfiConverterTypeBenchSpec.write(value.spec, into: &buf) + FfiConverterSequenceTypeBenchSample.write(value.samples, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBenchReport_lift(_ buf: RustBuffer) throws -> BenchReport { + return try FfiConverterTypeBenchReport.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBenchReport_lower(_ value: BenchReport) -> RustBuffer { + return FfiConverterTypeBenchReport.lower(value) +} + + +public struct BenchSample { + public var durationNs: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(durationNs: UInt64) { + self.durationNs = durationNs + } +} + + + +extension BenchSample: Equatable, Hashable { + public static func ==(lhs: BenchSample, rhs: BenchSample) -> Bool { + if lhs.durationNs != rhs.durationNs { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(durationNs) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeBenchSample: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BenchSample { + return + try BenchSample( + durationNs: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: BenchSample, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.durationNs, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBenchSample_lift(_ buf: RustBuffer) throws -> BenchSample { + return try FfiConverterTypeBenchSample.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBenchSample_lower(_ value: BenchSample) -> RustBuffer { + return FfiConverterTypeBenchSample.lower(value) +} + + +public struct BenchSpec { + public var name: String + public var iterations: UInt32 + public var warmup: UInt32 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(name: String, iterations: UInt32, warmup: UInt32) { + self.name = name + self.iterations = iterations + self.warmup = warmup + } +} + + + +extension BenchSpec: Equatable, Hashable { + public static func ==(lhs: BenchSpec, rhs: BenchSpec) -> Bool { + if lhs.name != rhs.name { + return false + } + if lhs.iterations != rhs.iterations { + return false + } + if lhs.warmup != rhs.warmup { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(iterations) + hasher.combine(warmup) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeBenchSpec: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BenchSpec { + return + try BenchSpec( + name: FfiConverterString.read(from: &buf), + iterations: FfiConverterUInt32.read(from: &buf), + warmup: FfiConverterUInt32.read(from: &buf) + ) + } + + public static func write(_ value: BenchSpec, into buf: inout [UInt8]) { + FfiConverterString.write(value.name, into: &buf) + FfiConverterUInt32.write(value.iterations, into: &buf) + FfiConverterUInt32.write(value.warmup, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBenchSpec_lift(_ buf: RustBuffer) throws -> BenchSpec { + return try FfiConverterTypeBenchSpec.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeBenchSpec_lower(_ value: BenchSpec) -> RustBuffer { + return FfiConverterTypeBenchSpec.lower(value) +} + + +public enum BenchError { + + + + case InvalidIterations(message: String) + + case UnknownFunction(message: String) + + case ExecutionFailed(message: String) + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeBenchError: FfiConverterRustBuffer { + typealias SwiftType = BenchError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BenchError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .InvalidIterations( + message: try FfiConverterString.read(from: &buf) + ) + + case 2: return .UnknownFunction( + message: try FfiConverterString.read(from: &buf) + ) + + case 3: return .ExecutionFailed( + message: try FfiConverterString.read(from: &buf) + ) + + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: BenchError, into buf: inout [UInt8]) { + switch value { + + + + + case .InvalidIterations(_ /* message is ignored*/): + writeInt(&buf, Int32(1)) + case .UnknownFunction(_ /* message is ignored*/): + writeInt(&buf, Int32(2)) + case .ExecutionFailed(_ /* message is ignored*/): + writeInt(&buf, Int32(3)) + + + } + } +} + + +extension BenchError: Equatable, Hashable {} + +extension BenchError: Foundation.LocalizedError { + public var errorDescription: String? { + String(reflecting: self) + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeBenchSample: FfiConverterRustBuffer { + typealias SwiftType = [BenchSample] + + public static func write(_ value: [BenchSample], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeBenchSample.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [BenchSample] { + let len: Int32 = try readInt(&buf) + var seq = [BenchSample]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeBenchSample.read(from: &buf)) + } + return seq + } +} +public func runBenchmark(spec: BenchSpec)throws -> BenchReport { + return try FfiConverterTypeBenchReport.lift(try rustCallWithError(FfiConverterTypeBenchError.lift) { + uniffi_sample_fns_fn_func_run_benchmark( + FfiConverterTypeBenchSpec.lower(spec),$0 + ) +}) +} + +private enum InitializationResult { + case ok + case contractVersionMismatch + case apiChecksumMismatch +} +// Use a global variable to perform the versioning checks. Swift ensures that +// the code inside is only computed once. +private var initializationResult: InitializationResult = { + // Get the bindings contract version from our ComponentInterface + let bindings_contract_version = 26 + // Get the scaffolding contract version by calling the into the dylib + let scaffolding_contract_version = ffi_sample_fns_uniffi_contract_version() + if bindings_contract_version != scaffolding_contract_version { + return InitializationResult.contractVersionMismatch + } + if (uniffi_sample_fns_checksum_func_run_benchmark() != 35019) { + return InitializationResult.apiChecksumMismatch + } + + return InitializationResult.ok +}() + +private func uniffiEnsureInitialized() { + switch initializationResult { + case .ok: + break + case .contractVersionMismatch: + fatalError("UniFFI contract version mismatch: try cleaning and rebuilding your project") + case .apiChecksumMismatch: + fatalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } +} + +// swiftlint:enable all \ No newline at end of file diff --git a/ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h b/ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h new file mode 100644 index 0000000..b5d9594 --- /dev/null +++ b/ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h @@ -0,0 +1,551 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +#pragma once + +#include +#include +#include + +// The following structs are used to implement the lowest level +// of the FFI, and thus useful to multiple uniffied crates. +// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H. +#ifdef UNIFFI_SHARED_H + // We also try to prevent mixing versions of shared uniffi header structs. + // If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V4 + #ifndef UNIFFI_SHARED_HEADER_V4 + #error Combining helper code from multiple versions of uniffi is not supported + #endif // ndef UNIFFI_SHARED_HEADER_V4 +#else +#define UNIFFI_SHARED_H +#define UNIFFI_SHARED_HEADER_V4 +// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️ +// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️ + +typedef struct RustBuffer +{ + uint64_t capacity; + uint64_t len; + uint8_t *_Nullable data; +} RustBuffer; + +typedef struct ForeignBytes +{ + int32_t len; + const uint8_t *_Nullable data; +} ForeignBytes; + +// Error definitions +typedef struct RustCallStatus { + int8_t code; + RustBuffer errorBuf; +} RustCallStatus; + +// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️ +// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️ +#endif // def UNIFFI_SHARED_H +#ifndef UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK +#define UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK +typedef void (*UniffiRustFutureContinuationCallback)(uint64_t, int8_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_FREE +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_FREE +typedef void (*UniffiForeignFutureFree)(uint64_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE +typedef void (*UniffiCallbackInterfaceFree)(uint64_t + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE +#define UNIFFI_FFIDEF_FOREIGN_FUTURE +typedef struct UniffiForeignFuture { + uint64_t handle; + UniffiForeignFutureFree _Nonnull free; +} UniffiForeignFuture; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U8 +typedef struct UniffiForeignFutureStructU8 { + uint8_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU8; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8 +typedef void (*UniffiForeignFutureCompleteU8)(uint64_t, UniffiForeignFutureStructU8 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I8 +typedef struct UniffiForeignFutureStructI8 { + int8_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI8; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8 +typedef void (*UniffiForeignFutureCompleteI8)(uint64_t, UniffiForeignFutureStructI8 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U16 +typedef struct UniffiForeignFutureStructU16 { + uint16_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU16; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16 +typedef void (*UniffiForeignFutureCompleteU16)(uint64_t, UniffiForeignFutureStructU16 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I16 +typedef struct UniffiForeignFutureStructI16 { + int16_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI16; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16 +typedef void (*UniffiForeignFutureCompleteI16)(uint64_t, UniffiForeignFutureStructI16 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U32 +typedef struct UniffiForeignFutureStructU32 { + uint32_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32 +typedef void (*UniffiForeignFutureCompleteU32)(uint64_t, UniffiForeignFutureStructU32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I32 +typedef struct UniffiForeignFutureStructI32 { + int32_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32 +typedef void (*UniffiForeignFutureCompleteI32)(uint64_t, UniffiForeignFutureStructI32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U64 +typedef struct UniffiForeignFutureStructU64 { + uint64_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructU64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64 +typedef void (*UniffiForeignFutureCompleteU64)(uint64_t, UniffiForeignFutureStructU64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I64 +typedef struct UniffiForeignFutureStructI64 { + int64_t returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructI64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64 +typedef void (*UniffiForeignFutureCompleteI64)(uint64_t, UniffiForeignFutureStructI64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F32 +typedef struct UniffiForeignFutureStructF32 { + float returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructF32; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32 +typedef void (*UniffiForeignFutureCompleteF32)(uint64_t, UniffiForeignFutureStructF32 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F64 +typedef struct UniffiForeignFutureStructF64 { + double returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructF64; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64 +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64 +typedef void (*UniffiForeignFutureCompleteF64)(uint64_t, UniffiForeignFutureStructF64 + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_POINTER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_POINTER +typedef struct UniffiForeignFutureStructPointer { + void*_Nonnull returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructPointer; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_POINTER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_POINTER +typedef void (*UniffiForeignFutureCompletePointer)(uint64_t, UniffiForeignFutureStructPointer + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_RUST_BUFFER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_RUST_BUFFER +typedef struct UniffiForeignFutureStructRustBuffer { + RustBuffer returnValue; + RustCallStatus callStatus; +} UniffiForeignFutureStructRustBuffer; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER +typedef void (*UniffiForeignFutureCompleteRustBuffer)(uint64_t, UniffiForeignFutureStructRustBuffer + ); + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_VOID +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_VOID +typedef struct UniffiForeignFutureStructVoid { + RustCallStatus callStatus; +} UniffiForeignFutureStructVoid; + +#endif +#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID +#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID +typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureStructVoid + ); + +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_SAMPLE_FNS_FN_FUNC_RUN_BENCHMARK +#define UNIFFI_FFIDEF_UNIFFI_SAMPLE_FNS_FN_FUNC_RUN_BENCHMARK +RustBuffer uniffi_sample_fns_fn_func_run_benchmark(RustBuffer spec, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUSTBUFFER_ALLOC +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUSTBUFFER_ALLOC +RustBuffer ffi_sample_fns_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUSTBUFFER_FROM_BYTES +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUSTBUFFER_FROM_BYTES +RustBuffer ffi_sample_fns_rustbuffer_from_bytes(ForeignBytes bytes, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUSTBUFFER_FREE +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUSTBUFFER_FREE +void ffi_sample_fns_rustbuffer_free(RustBuffer buf, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUSTBUFFER_RESERVE +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUSTBUFFER_RESERVE +RustBuffer ffi_sample_fns_rustbuffer_reserve(RustBuffer buf, uint64_t additional, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_U8 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_U8 +void ffi_sample_fns_rust_future_poll_u8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_U8 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_U8 +void ffi_sample_fns_rust_future_cancel_u8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_U8 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_U8 +void ffi_sample_fns_rust_future_free_u8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_U8 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_U8 +uint8_t ffi_sample_fns_rust_future_complete_u8(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_I8 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_I8 +void ffi_sample_fns_rust_future_poll_i8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_I8 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_I8 +void ffi_sample_fns_rust_future_cancel_i8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_I8 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_I8 +void ffi_sample_fns_rust_future_free_i8(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_I8 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_I8 +int8_t ffi_sample_fns_rust_future_complete_i8(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_U16 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_U16 +void ffi_sample_fns_rust_future_poll_u16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_U16 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_U16 +void ffi_sample_fns_rust_future_cancel_u16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_U16 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_U16 +void ffi_sample_fns_rust_future_free_u16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_U16 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_U16 +uint16_t ffi_sample_fns_rust_future_complete_u16(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_I16 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_I16 +void ffi_sample_fns_rust_future_poll_i16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_I16 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_I16 +void ffi_sample_fns_rust_future_cancel_i16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_I16 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_I16 +void ffi_sample_fns_rust_future_free_i16(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_I16 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_I16 +int16_t ffi_sample_fns_rust_future_complete_i16(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_U32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_U32 +void ffi_sample_fns_rust_future_poll_u32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_U32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_U32 +void ffi_sample_fns_rust_future_cancel_u32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_U32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_U32 +void ffi_sample_fns_rust_future_free_u32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_U32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_U32 +uint32_t ffi_sample_fns_rust_future_complete_u32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_I32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_I32 +void ffi_sample_fns_rust_future_poll_i32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_I32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_I32 +void ffi_sample_fns_rust_future_cancel_i32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_I32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_I32 +void ffi_sample_fns_rust_future_free_i32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_I32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_I32 +int32_t ffi_sample_fns_rust_future_complete_i32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_U64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_U64 +void ffi_sample_fns_rust_future_poll_u64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_U64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_U64 +void ffi_sample_fns_rust_future_cancel_u64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_U64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_U64 +void ffi_sample_fns_rust_future_free_u64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_U64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_U64 +uint64_t ffi_sample_fns_rust_future_complete_u64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_I64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_I64 +void ffi_sample_fns_rust_future_poll_i64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_I64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_I64 +void ffi_sample_fns_rust_future_cancel_i64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_I64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_I64 +void ffi_sample_fns_rust_future_free_i64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_I64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_I64 +int64_t ffi_sample_fns_rust_future_complete_i64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_F32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_F32 +void ffi_sample_fns_rust_future_poll_f32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_F32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_F32 +void ffi_sample_fns_rust_future_cancel_f32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_F32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_F32 +void ffi_sample_fns_rust_future_free_f32(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_F32 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_F32 +float ffi_sample_fns_rust_future_complete_f32(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_F64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_F64 +void ffi_sample_fns_rust_future_poll_f64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_F64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_F64 +void ffi_sample_fns_rust_future_cancel_f64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_F64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_F64 +void ffi_sample_fns_rust_future_free_f64(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_F64 +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_F64 +double ffi_sample_fns_rust_future_complete_f64(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_POINTER +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_POINTER +void ffi_sample_fns_rust_future_poll_pointer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_POINTER +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_POINTER +void ffi_sample_fns_rust_future_cancel_pointer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_POINTER +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_POINTER +void ffi_sample_fns_rust_future_free_pointer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_POINTER +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_POINTER +void*_Nonnull ffi_sample_fns_rust_future_complete_pointer(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_RUST_BUFFER +void ffi_sample_fns_rust_future_poll_rust_buffer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_RUST_BUFFER +void ffi_sample_fns_rust_future_cancel_rust_buffer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_RUST_BUFFER +void ffi_sample_fns_rust_future_free_rust_buffer(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_RUST_BUFFER +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_RUST_BUFFER +RustBuffer ffi_sample_fns_rust_future_complete_rust_buffer(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_VOID +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_POLL_VOID +void ffi_sample_fns_rust_future_poll_void(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_VOID +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_CANCEL_VOID +void ffi_sample_fns_rust_future_cancel_void(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_VOID +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_FREE_VOID +void ffi_sample_fns_rust_future_free_void(uint64_t handle +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_VOID +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_RUST_FUTURE_COMPLETE_VOID +void ffi_sample_fns_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_SAMPLE_FNS_CHECKSUM_FUNC_RUN_BENCHMARK +#define UNIFFI_FFIDEF_UNIFFI_SAMPLE_FNS_CHECKSUM_FUNC_RUN_BENCHMARK +uint16_t uniffi_sample_fns_checksum_func_run_benchmark(void + +); +#endif +#ifndef UNIFFI_FFIDEF_FFI_SAMPLE_FNS_UNIFFI_CONTRACT_VERSION +#define UNIFFI_FFIDEF_FFI_SAMPLE_FNS_UNIFFI_CONTRACT_VERSION +uint32_t ffi_sample_fns_uniffi_contract_version(void + +); +#endif + diff --git a/ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.modulemap b/ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.modulemap new file mode 100644 index 0000000..d85082b --- /dev/null +++ b/ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.modulemap @@ -0,0 +1,4 @@ +module sample_fnsFFI { + header "sample_fnsFFI.h" + export * +} \ No newline at end of file diff --git a/ios/BenchRunner/BenchRunner/Info.plist b/ios/BenchRunner/BenchRunner/Info.plist new file mode 100644 index 0000000..47ef031 --- /dev/null +++ b/ios/BenchRunner/BenchRunner/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift b/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift new file mode 100644 index 0000000..2f32465 --- /dev/null +++ b/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift @@ -0,0 +1,12 @@ +import XCTest + +final class BenchRunnerUITests: XCTestCase { + func testLaunchShowsBenchmarkReport() { + let app = XCUIApplication() + app.launch() + + let report = app.staticTexts["benchmarkReport"] + let exists = report.waitForExistence(timeout: 30.0) + XCTAssertTrue(exists, "Benchmark report text should appear after launch") + } +} diff --git a/ios/BenchRunner/project.yml b/ios/BenchRunner/project.yml new file mode 100644 index 0000000..d17398f --- /dev/null +++ b/ios/BenchRunner/project.yml @@ -0,0 +1,37 @@ +name: BenchRunner +options: + bundleIdPrefix: dev.world +settings: + base: + SWIFT_VERSION: 5.9 +targets: + BenchRunner: + type: application + platform: iOS + deploymentTarget: "15.0" + sources: + - path: BenchRunner + resources: + - path: BenchRunner/Resources + optional: true + - path: ../../target/mobile-spec/ios + optional: true + info: + path: BenchRunner/Info.plist + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.world.bench + SWIFT_OBJC_BRIDGING_HEADER: BenchRunner/BenchRunner-Bridging-Header.h + HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/BenchRunner/Generated" + dependencies: + - framework: ../../target/ios/sample_fns.xcframework + embed: true + link: true + BenchRunnerUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "15.0" + sources: + - path: BenchRunnerUITests + dependencies: + - target: BenchRunner diff --git a/scripts/bindgen.rs b/scripts/bindgen.rs new file mode 100644 index 0000000..bb721d9 --- /dev/null +++ b/scripts/bindgen.rs @@ -0,0 +1,56 @@ +// Standalone script to generate UniFFI bindings +// Usage: cargo script scripts/bindgen.rs + +use std::env; +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root_dir = manifest_dir.parent().unwrap(); + let lib_path = root_dir.join("target/debug/libsample_fns.dylib"); + + if !lib_path.exists() { + let lib_path_so = root_dir.join("target/debug/libsample_fns.so"); + if lib_path_so.exists() { + generate_bindings(&lib_path_so, root_dir)?; + } else { + eprintln!("Error: Library not found. Run 'cargo build -p sample-fns' first"); + std::process::exit(1); + } + } else { + generate_bindings(&lib_path, root_dir)?; + } + + Ok(()) +} + +fn generate_bindings(lib_path: &Path, root_dir: &Path) -> Result<(), Box> { + use uniffi_bindgen::{bindings, library_mode}; + + // Generate Kotlin bindings + let kotlin_out = root_dir.join("android/app/src/main/java"); + library_mode::generate_bindings( + lib_path, + None, + &bindings::TargetLanguage::Kotlin, + &kotlin_out, + false, + )?; + + println!("✓ Kotlin bindings generated: {:?}", kotlin_out); + + // Generate Swift bindings + let swift_out = root_dir.join("ios/BenchRunner/BenchRunner/Generated"); + std::fs::create_dir_all(&swift_out)?; + library_mode::generate_bindings( + lib_path, + None, + &bindings::TargetLanguage::Swift, + &swift_out, + false, + )?; + + println!("✓ Swift bindings generated: {:?}", swift_out); + + Ok(()) +} diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh new file mode 100755 index 0000000..4c52559 --- /dev/null +++ b/scripts/build-android-app.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Convenience wrapper: build Rust libs for all Android ABIs, sync them into the app, +# then assemble the Android APK. +# +# Requires: +# - cargo-ndk installed +# - Android SDK/Gradle available + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Resolve ANDROID_NDK_HOME if not provided. +if [[ -z "${ANDROID_NDK_HOME:-}" ]]; then + DEFAULT_NDK="${HOME}/Library/Android/sdk/ndk/29.0.14206865" + if [[ -d "${DEFAULT_NDK}" ]]; then + export ANDROID_NDK_HOME="${DEFAULT_NDK}" + echo "ANDROID_NDK_HOME not set; defaulting to ${ANDROID_NDK_HOME}" + else + echo "ANDROID_NDK_HOME is not set and default NDK path not found; please export it before running." >&2 + exit 1 + fi +fi + +pushd "${ROOT_DIR}" >/dev/null +./scripts/build-android.sh +./scripts/sync-android-libs.sh +popd >/dev/null + +pushd "${ROOT_DIR}/android" >/dev/null +./gradlew :app:assembleDebug +popd >/dev/null + +echo "Android build complete." diff --git a/scripts/build-android.sh b/scripts/build-android.sh new file mode 100755 index 0000000..73ca021 --- /dev/null +++ b/scripts/build-android.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build Rust shared libraries for Android targets using cargo-ndk. +# +# NOTE: If you modify the Rust API (sample_fns.udl), run: +# cargo run --bin generate-bindings --features bindgen +# before running this script to regenerate Kotlin bindings. +# +# Prereqs (install manually in CI/local before running): +# - Android NDK and toolchains available on PATH +# - cargo-ndk installed (`cargo install cargo-ndk`) +# +# By default builds sample-fns as a cdylib, producing libsample_fns.so per ABI. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +CRATES=(${CRATES:-sample-fns}) +# Add x86_64 for emulator support; keep ARM for devices. +TARGET_ABIS=("aarch64-linux-android" "armv7-linux-androideabi" "x86_64-linux-android") +API_LEVEL=24 + +for CRATE in "${CRATES[@]}"; do + echo "Building Rust library for Android (crates/${CRATE})" + for ABI in "${TARGET_ABIS[@]}"; do + echo " -> ${ABI}" + cargo ndk \ + -t "${ABI}" \ + -o "${ROOT_DIR}/target/android/${ABI}" \ + --platform "${API_LEVEL}" \ + build -p "${CRATE}" --release + done +done + +echo "Finished. Outputs are under target/android//release." diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh new file mode 100755 index 0000000..6264e21 --- /dev/null +++ b/scripts/build-ios.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build the Rust library for iOS targets and package as xcframework. +# UniFFI-generated headers (sample_fnsFFI.h) are used for the C ABI. +# +# NOTE: If you modify the Rust API (sample_fns.udl), run: +# cargo run --bin generate-bindings --features bindgen +# before running this script to regenerate Swift bindings and headers. +# +# Prereqs (install manually in CI/local before running): +# - Xcode command line tools +# - rustup targets: aarch64-apple-ios, aarch64-apple-ios-sim + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CRATE="sample-fns" +OUTPUT_DIR="${ROOT_DIR}/target/ios" +XCFRAMEWORK_PATH="${OUTPUT_DIR}/sample_fns.xcframework" + +# iOS targets to build +IOS_TARGETS=( + "aarch64-apple-ios" # iOS device (ARM64) + "aarch64-apple-ios-sim" # iOS simulator (ARM64, M1+ Macs) +) + +# Check for required iOS targets +for target in "${IOS_TARGETS[@]}"; do + if ! rustup target list --installed | grep -q "^${target}$"; then + echo "Installing Rust target: ${target}" + rustup target add "${target}" + fi +done + +echo "Building Rust libraries for iOS targets" +for target in "${IOS_TARGETS[@]}"; do + echo " -> Building for ${target}" + cargo build --release --target "${target}" -p "${CRATE}" +done + +echo "Creating xcframework structure" +rm -rf "${XCFRAMEWORK_PATH}" +mkdir -p "${XCFRAMEWORK_PATH}" + +# Create framework for each target +for target in "${IOS_TARGETS[@]}"; do + # Static library name: lib.a (crate name with underscores) + LIB_NAME="libsample_fns.a" + LIB_PATH="${ROOT_DIR}/target/${target}/release/${LIB_NAME}" + + if [[ ! -f "${LIB_PATH}" ]]; then + echo "Error: ${LIB_PATH} not found after build" >&2 + exit 1 + fi + + # Determine platform and architecture + case "${target}" in + aarch64-apple-ios) + PLATFORM="iPhoneOS" + XCFRAMEWORK_PLATFORM="ios" + ARCH="arm64" + FRAMEWORK_NAME="ios-arm64" + ;; + aarch64-apple-ios-sim) + PLATFORM="iPhoneSimulator" + XCFRAMEWORK_PLATFORM="ios-simulator" + ARCH="arm64" + FRAMEWORK_NAME="ios-simulator-arm64" + ;; + *) + echo "Unknown target: ${target}" >&2 + exit 1 + ;; + esac + + FRAMEWORK_DIR="${XCFRAMEWORK_PATH}/${FRAMEWORK_NAME}/sample_fns.framework" + mkdir -p "${FRAMEWORK_DIR}/Headers" + + # Copy library (framework binary should match module name) + cp "${LIB_PATH}" "${FRAMEWORK_DIR}/sample_fns" + + # Copy UniFFI-generated C header + UNIFFI_HEADER="${ROOT_DIR}/ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h" + if [[ ! -f "${UNIFFI_HEADER}" ]]; then + echo "Error: UniFFI header not found at ${UNIFFI_HEADER}" >&2 + echo "Run: cargo run --bin generate-bindings --features bindgen" >&2 + exit 1 + fi + cp "${UNIFFI_HEADER}" "${FRAMEWORK_DIR}/Headers/" + + # Create Info.plist for this framework slice + cat > "${FRAMEWORK_DIR}/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + sample_fns + CFBundleIdentifier + dev.world.sample-fns + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + MinimumOSVersion + 13.0 + CFBundleSupportedPlatforms + + ${PLATFORM} + + + +EOF + + # Create module map for UniFFI C bindings + cat > "${FRAMEWORK_DIR}/Headers/module.modulemap" < "${XCFRAMEWORK_PATH}/Info.plist" < + + + + AvailableLibraries + + + LibraryIdentifier + ios-arm64 + LibraryPath + sample_fns.framework + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + SupportedPlatformVariant + + + + LibraryIdentifier + ios-simulator-arm64 + LibraryPath + sample_fns.framework + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + +EOF + +echo "✓ iOS build complete. XCFramework created at: ${XCFRAMEWORK_PATH}" + +# Code-sign the xcframework (required for Xcode) +echo "Signing xcframework..." +codesign --force --deep --sign - "${XCFRAMEWORK_PATH}" 2>/dev/null || { + echo "⚠️ Warning: Failed to sign xcframework. You may need to sign manually:" + echo " codesign --force --deep --sign - ${XCFRAMEWORK_PATH}" +} + +echo "✓ Build and signing complete" diff --git a/scripts/generate-bindings.sh b/scripts/generate-bindings.sh new file mode 100755 index 0000000..62ce14e --- /dev/null +++ b/scripts/generate-bindings.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Generate Kotlin and Swift bindings using UniFFI + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CRATE_DIR="${ROOT_DIR}/crates/sample-fns" + +# Build the library for host first +echo "Building sample-fns..." +cargo build -p sample-fns + +# Determine library extension based on platform +if [[ "$OSTYPE" == "darwin"* ]]; then + LIB_EXT="dylib" +elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + LIB_EXT="so" +else + echo "Unsupported platform: $OSTYPE" + exit 1 +fi + +LIB_PATH="${ROOT_DIR}/target/debug/libsample_fns.${LIB_EXT}" + +# Generate Kotlin bindings +echo "Generating Kotlin bindings..." +cargo run --bin uniffi-bindgen generate \ + --library "${LIB_PATH}" \ + --language kotlin \ + --out-dir "${ROOT_DIR}/android/app/src/main/java" + +# Generate Swift bindings +echo "Generating Swift bindings..." +mkdir -p "${ROOT_DIR}/ios/BenchRunner/BenchRunner/Generated" +cargo run --bin uniffi-bindgen generate \ + --library "${LIB_PATH}" \ + --language swift \ + --out-dir "${ROOT_DIR}/ios/BenchRunner/BenchRunner/Generated" + +echo "✓ Bindings generated successfully" +echo " - Kotlin: android/app/src/main/java/uniffi/sample_fns/" +echo " - Swift: ios/BenchRunner/BenchRunner/Generated/" diff --git a/scripts/sync-android-libs.sh b/scripts/sync-android-libs.sh new file mode 100755 index 0000000..0ae99ea --- /dev/null +++ b/scripts/sync-android-libs.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Copy built Rust .so files into the Android app's jniLibs structure. +# Run scripts/build-android.sh first. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_JNILIBS="${ROOT_DIR}/android/app/src/main/jniLibs" +LIB_NAME="${LIB_NAME:-sample_fns}" +# JNA expects libuniffi_sample_fns.so, so we rename during copy +TARGET_LIB_NAME="${TARGET_LIB_NAME:-uniffi_sample_fns}" + +declare -A ABI_MAP=( + ["aarch64-linux-android"]="arm64-v8a" + ["armv7-linux-androideabi"]="armeabi-v7a" + ["x86_64-linux-android"]="x86_64" +) + +for TRIPLE in "${!ABI_MAP[@]}"; do + # Cargo NDK may place outputs under /release or directly under the ABI folder. + SRC="${ROOT_DIR}/target/android/${TRIPLE}/release/lib${LIB_NAME}.so" + if [[ ! -f "${SRC}" ]]; then + ALT="${ROOT_DIR}/target/android/${TRIPLE}/${ABI_MAP[$TRIPLE]}/lib${LIB_NAME}.so" + if [[ -f "${ALT}" ]]; then + SRC="${ALT}" + fi + fi + DEST_DIR="${APP_JNILIBS}/${ABI_MAP[$TRIPLE]}" + DEST="${DEST_DIR}/lib${TARGET_LIB_NAME}.so" + if [[ ! -f "${SRC}" ]]; then + echo "Missing ${SRC}; build first with scripts/build-android.sh" >&2 + exit 1 + fi + mkdir -p "${DEST_DIR}" + cp "${SRC}" "${DEST}" + echo "Copied ${SRC} -> ${DEST}" +done + +echo "JNI libs synced." From 76bf41acd3a29c49c0dfdf793880af9363082a01 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 15 Jan 2026 10:50:42 -0300 Subject: [PATCH 006/196] feat: Mobile benchmarking SDK (Phase 1 MVP) + Performance Metrics (v0.1.5) (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial commit * Add .github/workflows/relyance-sci.yml * initial commit * Fix Android UniFFI integration and benchmark display This commit resolves multiple issues preventing the Android app from loading the Rust native library and correctly displaying benchmark results. Changes implemented: 1. Added JNA dependency (android/app/build.gradle) - Added net.java.dev.jna:jna:5.14.0@aar dependency - Required for UniFFI's Kotlin bindings to interface with native code - Also added NDK version specification and benchmark spec asset directories 2. Fixed library naming mismatch (crates/sample-fns/Cargo.toml) - Set explicit lib name = "sample_fns" (not uniffi_sample_fns) - Changed crate-type to ["lib", "cdylib", "staticlib"] for UniFFI - Added UniFFI dependencies and build configuration - Migrated from JNI to UniFFI for cleaner FFI bindings 3. Updated sync script for library renaming (scripts/sync-android-libs.sh) - Added TARGET_LIB_NAME variable to rename .so during copy - Rust builds libsample_fns.so but JNA expects libuniffi_sample_fns.so - Script now copies and renames: libsample_fns.so -> libuniffi_sample_fns.so - Ensures FFI symbol prefixes match UniFFI expectations 4. Updated Android MainActivity (android/app/src/main/java/dev/world/bench/MainActivity.kt) - Changed System.loadLibrary("sample_fns") to System.loadLibrary("uniffi_sample_fns") - Migrated from JNI to UniFFI-generated Kotlin bindings - Updated benchmark display to show microseconds (μs) instead of milliseconds - Added raw nanosecond display alongside formatted values - Improved error handling with UniFFI's BenchException variants - Added support for loading benchmark specs from assets (bench_spec.json) Root cause analysis: - UniFFI generates Kotlin bindings that expect library name "uniffi_" - The namespace from sample_fns.udl is "sample_fns" - JNA looks for libuniffi_sample_fns.so based on this naming convention - Rust builds libsample_fns.so by default (from crate name) - Solution: Keep Rust lib name simple, rename during Android integration Benchmark display fix: - Functions execute in 0-42 nanoseconds (extremely fast) - Previous millisecond display showed "0.000 ms" for all samples - Now displays in microseconds with raw nanosecond values for clarity - Example: "0.042 μs (42 ns)" instead of "0.000 ms" Testing: - App builds successfully with ./gradlew :app:assembleDebug - Native library loads without UnsatisfiedLinkError - Benchmarks execute and display timing results correctly - Fibonacci(24) averages ~17 nanoseconds per iteration on ARM64 * Android: log benchmark JSON and fix test matcher * iOS: add UI test target and accessibility id * bench-cli: add BrowserStack fetch and artifact download * Commit remaining workspace changes * Ignore generated build artifacts * gitignore: exclude CLI-generated artifacts - Add run-summary.json (benchmark run results) - Add bench-config.toml (user configuration template) - Add device-matrix.yaml (device matrix template) These files are generated by bench-cli and should not be tracked in git. * iOS: standardize time units to microseconds Change benchmark result display from milliseconds (ms) to microseconds (μs) to match Android's output format. This provides better granularity for typical function benchmarks and makes cross-platform comparison easier. Before: 0.045 ms After: 45.320 μs (45320 ns) Also add raw nanosecond values in parentheses to match Android formatting. * bench-cli: add comprehensive BrowserStack client tests Add 11 new tests covering: - Client initialization and configuration - URL construction and path handling - Input validation for schedule/upload methods - Error cases for missing artifacts - Both Espresso (Android) and XCUITest (iOS) code paths Also suppress dead_code warning for test-only with_base_url helper. Test coverage increased from 1 to 12 tests for browserstack.rs module. * Migrate from UniFFI UDL to proc macros Replace manual UDL file with proc macro attributes for better developer experience and single source of truth in Rust code. ## Changes ### sample-fns crate: - Add `#[derive(uniffi::Record)]` to BenchSpec, BenchSample, BenchReport - Add `#[derive(uniffi::Error)]` with `#[uniffi(flat_error)]` to BenchError - Add `#[uniffi::export]` to run_benchmark function - Add `uniffi::setup_scaffolding!()` macro to generate scaffolding - Remove UDL file (sample_fns.udl) - Update Cargo.toml: enable uniffi "cli" feature - Update build.rs: remove UDL scaffolding generation (now proc macro-based) - Update generate-bindings.rs: use library_mode instead of UDL-based generation ### Generated bindings: - Kotlin bindings regenerated from proc macros - Swift bindings regenerated from proc macros - FFI interface remains identical (no breaking changes) ## Benefits 1. **Single source of truth**: Rust code is the only place to define types 2. **Better IDE support**: Type checking and autocomplete in Rust 3. **Automatic sync**: No manual UDL maintenance required 4. **Improved DevEx**: Attributes are inline with type definitions 5. **Type safety**: Compiler enforces FFI boundaries ## Migration notes - UniFFI 0.28 supports both UDL and proc macros - Generated bindings are functionally identical - All tests pass (22 tests) - CLI demo verified working - Android/iOS apps use same generated bindings * docs: update for proc macros and dual workflows Update documentation to reflect: 1. UniFFI proc macros (no UDL file needed) 2. Two distinct workflows: Local Development vs BrowserStack ## README.md Changes - Add "Testing Workflows" section with clear separation - Update UniFFI section for proc macro mode with examples - Add comprehensive BrowserStack workflow section: - Android + Espresso step-by-step - iOS + XCUITest step-by-step - Config file examples - BrowserStack features overview - Reorganize into Local Development vs BrowserStack sections - Remove outdated UDL references - Add requirements for both workflows ## CLAUDE.md Changes - Update Mobile Integration Flow to mention proc macros - Rewrite FFI Boundary section with proc macro examples - Update "Adding New Benchmark Functions" workflow - Remove references to sample_fns.udl - Add note that no UDL file is needed These changes make it much clearer for users: - How to test locally (fast iteration) - How to test on BrowserStack (real devices) - How to add new FFI types with proc macros * initial procmacros * feat: Phase 1 - Transform mobile-bench-rs into bench-sdk library crate This commit implements the complete Phase 1 MVP, transforming mobile-bench-rs from a standalone tool into a library crate (bench-sdk) that can be imported into any Rust project for mobile benchmarking. ## Core Features ### 1. Proc Macro System (bench-macros) - Implement #[benchmark] attribute macro for automatic function registration - Use inventory crate for compile-time collection, runtime discovery - Generate fully-qualified function names (e.g., my_crate::my_function) ### 2. Core SDK Library (bench-sdk) - **types.rs**: Core types (BenchError, Target, BuildConfig, BuildProfile, etc.) - **registry.rs**: Function discovery via inventory::collect!() - discover_benchmarks(), find_benchmark(), list_benchmark_names() - **runner.rs**: Execution engine with BenchmarkBuilder fluent API - **codegen.rs**: Template generation system - Generates bench-mobile FFI wrapper crate with UniFFI - Creates Android/iOS project structures - Generates config files and examples - **builders/android.rs**: Complete Android build pipeline - cargo-ndk integration for multi-ABI builds - JNI library syncing - Gradle APK generation - **builders/ios.rs**: Complete iOS build pipeline - Multi-architecture builds (device + simulator) - xcframework creation with proper structure - Automatic code signing - XcodeGen integration ### 3. CLI Integration (bench-cli) - Refactor to use SDK API as thin wrapper - Add new commands: - init-sdk: Initialize benchmark project with templates - build: Build mobile artifacts using SDK builders - list: Discover and list all registered benchmarks - Maintain backward compatibility with existing commands ### 4. Templates (templates/) - **Android**: Gradle project, MainActivity, Espresso tests, resources - Templatized with package name, library name, etc. - Complete build.gradle, settings.gradle, AndroidManifest.xml - **iOS**: XcodeGen project, SwiftUI app, XCUITest tests - Templatized with project name, bundle ID, etc. - Complete project.yml, Swift files, bridging headers ### 5. Example Project (examples/basic-benchmark) - Migrate sample-fns to example demonstrating SDK usage - Use #[benchmark] attributes with registry-based execution - Full test coverage (6 passing tests) ## Architecture Changes ### Workspace Structure ``` mobile-bench-rs/ ├── crates/ │ ├── bench-sdk/ # Core library (NEW, for crates.io) │ ├── bench-macros/ # Proc macro crate (NEW) │ ├── bench-cli/ # CLI tool (REFACTORED) │ ├── bench-runner/ # Timing harness (UNCHANGED) │ └── sample-fns/ # Legacy (DEPRECATED) ├── examples/ │ └── basic-benchmark/ # SDK example (NEW) └── templates/ # Mobile app templates (NEW) ├── android/ └── ios/ ``` ### Public API Surface ```rust // Proc macro #[benchmark] fn my_function() { /* ... */ } // Discovery bench_sdk::discover_benchmarks() -> Vec<&BenchFunction> bench_sdk::find_benchmark(name) -> Option<&BenchFunction> // Execution bench_sdk::run_benchmark(spec) -> Result BenchmarkBuilder::new(name).iterations(100).run() // Builders AndroidBuilder::new(root, crate_name).build(&config) IosBuilder::new(root, crate_name).build(&config) // Codegen generate_project(&InitConfig) -> Result ``` ## Testing All tests passing (25 total): - bench-sdk: 12 tests - basic-benchmark: 6 tests - sample-fns: 6 tests (legacy) - bench-runner: 1 test ## Documentation - Updated README.md with Phase 1 information - Inline rustdoc comments throughout - Working example in examples/basic-benchmark - Updated references in BUILD.md, TESTING.md ## Migration Path Users can now: 1. Add bench-sdk to Cargo.toml 2. Annotate functions with #[benchmark] 3. Run `cargo bench-sdk init-sdk` to generate project 4. Run `cargo bench-sdk build` to build mobile artifacts 5. Run `cargo bench-sdk list` to discover benchmarks ## Future Work Phase 1 provides the foundation for: - Phase 2: Adaptive iterations, warmup detection - Phase 3: Statistical analysis, baselines - Phase 4: Full BrowserStack integration - Phase 5: cargo bench harness, DevEx improvements - Phase 6: Caching, reproducibility, CI optimization * feat: wire sdk scaffolding and bindings - Render Android/iOS templates with project-specific names and link user crate in bench-mobile - Run UniFFI binding generation in SDK builders and ensure headers are packaged - Route CLI builds through SDK builders and detect bench-mobile crate name - Align Android JNI library naming (sample_fns) and update templates/scripts - Improve binding generation tooling for ABI-aware Android builds * docs: align build and binding workflow - Document ABI-aware Android builds with UNIFFI_ANDROID_ABI - Update binding regeneration steps to use scripts/generate-bindings.sh - Simplify Android local/testing steps to use build-android-app.sh * docs: add bench-sdk integration guide - Add step-by-step setup for dependencies and annotations - Document local Android/iOS testing workflow - Document BrowserStack Espresso/XCUITest runs * docs: expand integration guide - Add prerequisites and clarify script availability - Document CLI build path when scripts are absent - Link guide from README * docs: link prerequisite downloads - Add official download links for Rust, Android, JDK, Xcode, and xcodegen * docs: clarify JDK requirement - Use vendor-agnostic JDK 17+ wording and OpenJDK link - Note AGP official support for Java 17 * feat: Rename bench-cli to mobench and add iOS IPA packaging Major improvements to CLI tooling and iOS workflow: 1. Rename bench-cli → mobench - More memorable and concise name (7 chars) - Available on crates.io - Dual binaries: mobench + cargo-mobench - Updated all documentation and references 2. Add iOS IPA packaging with xcodebuild - New command: cargo mobench package-ipa - SigningMethod enum: AdHoc and Development - Ad-hoc signing (no Apple ID needed) for BrowserStack - Development signing for physical devices - Pure xcodebuild implementation (no fastlane dependency) - Automatic ExportOptions.plist generation 3. Eliminate script dependencies for SDK integrators - Added prominent warnings in README and integration docs - Deprecation notices in all shell scripts - CLI-first approach: cargo mobench build replaces ./scripts/ - Templates embedded in binary, no repo checkout needed 4. Add package metadata - Author: Dominik Clemente - dcbuilder.eth - Added to mobench, bench-sdk, and bench-macros - Repository links and keywords for crates.io All changes tested and verified: - 25 tests passing - cargo mobench --help shows all commands - package-ipa supports both adhoc and development methods - Documentation updated across all files * refactor: Rename bench-sdk → mobench-sdk and bench-macros → mobench-macros Complete branding consistency across all packages: 1. Crate renames: - bench-sdk → mobench-sdk - bench-macros → mobench-macros 2. Updated all references: - Package names in Cargo.toml files - Workspace member names - Dependency declarations - Import statements (bench_sdk → mobench_sdk) - Documentation and code examples - CLI help messages 3. All tests passing (25 tests) - mobench-sdk: 12 tests - sample-fns: 6 tests - basic-benchmark: 6 tests - mobench CLI verified Now all three publishable packages have consistent branding: - mobench (CLI) - mobench-sdk (core library) - mobench-macros (proc macros) * feat: Add metadata to bench-runner for crates.io publication * fix: Add version requirements to path dependencies in mobench-sdk * fix: Update repository URLs to worldcoin org and include templates in mobench-sdk package * fix: Include templates directory in mobench-sdk crate for crates.io packaging * chore: prepare mobench for crates.io publication - Update repository URL to worldcoin organization - Add version requirements to published dependencies (mobench-sdk, bench-runner) * chore: make sample-fns dependency optional for publishing - Add 'demo' feature flag for sample-fns dependency - Feature-gate Demo command to only work when demo feature is enabled - Users can install with: cargo install mobench --features demo This allows mobench to be published without requiring sample-fns (which is not published to crates.io). * chore: remove Demo command for crates.io publication - Demo command was only useful for development/testing - Requires unpublished sample-fns crate - Move sample-fns to dev-dependencies only - Published CLI focuses on core functionality * docs: add BrowserStack test run results and manual test script Completed test runs on BrowserStack: - ✅ Android Espresso test PASSED on Pixel 7 (50s duration) - ⚠️ iOS XCUITest encountered packaging/parsing issues Build artifacts created: - Android APK (9.5 MB) + test APK - iOS IPA (284 KB) + test suite (5.6 MB) The Android integration is fully functional. iOS XCUITest requires additional work on test runner packaging to meet BrowserStack's parsing requirements. Added test_browserstack.sh for manual BrowserStack API testing, bypassing CLI's benchmark registry validation. * refactor: rename bench-runner to mobench-runner for consistency - Renamed crates/bench-runner to crates/mobench-runner - Updated package name from bench-runner to mobench-runner - Updated all imports from bench_runner to mobench_runner - Updated workspace and all dependent crates: - mobench-sdk - mobench CLI - sample-fns - examples/basic-benchmark This aligns the runner crate naming with the mobench ecosystem (mobench, mobench-sdk, mobench-macros, mobench-runner). Preparing for republication to crates.io. * chore: bump version to 0.1.1 for republication Updated workspace version to 0.1.1 after: - Publishing mobench-runner v0.1.0 (new package) - Republishing mobench-sdk v0.1.1 (updated dependency) - Republishing mobench v0.1.1 (updated dependency) All packages now use mobench-runner instead of bench-runner. * docs: add comprehensive READMEs to all packages for crates.io Added detailed README files to all packages: - mobench-runner: Explains timing harness, shows basic usage examples - mobench-macros: Documents #[benchmark] attribute, macro expansion - mobench-sdk: Comprehensive SDK guide with examples and architecture - mobench: Complete CLI documentation with all commands and workflows Each README includes: - Feature overview - Installation instructions - Usage examples - API documentation - Best practices - Integration with other packages - Requirements and troubleshooting Updated Cargo.toml files to include readme = "README.md" field for proper display on crates.io package pages. Bumped version to 0.1.2 for republication with documentation. * chore: add MIT license and update all package licenses - Added LICENSE.md with MIT license under World Foundation (2026) - Updated all READMEs to reference MIT license only - Changed workspace license from 'MIT OR Apache-2.0' to 'MIT' - Updated license sections in all 4 package READMEs: - mobench-runner - mobench-macros - mobench-sdk - mobench - Bumped version to 0.1.3 for republication Copyright (c) 2026 World Foundation * chore: reset version to 0.1.0 for fresh release Yanked all previous versions (0.1.0-0.1.3) from crates.io. Starting fresh with proper: - MIT license under World Foundation (2026) - Comprehensive READMEs for all packages - Consistent naming (mobench-runner instead of bench-runner) This is the canonical first release. * chore: use version 0.1.4 as canonical first release Cannot reuse version 0.1.0 even after yanking. Version 0.1.4 will be the canonical first release with: - MIT license under World Foundation (2026) - Comprehensive READMEs - Proper naming (mobench-runner) Versions 0.1.0-0.1.3 are yanked. * docs: update CLAUDE.md to reflect SDK-first architecture and crates.io publication - Update project overview with published packages (v0.1.4) - Document recommended cargo mobench build workflow - Mark scripts/ as legacy for repository development only - Add SDK integration examples and Quick Start - Reorganize file structure documentation - Add template system documentation * fix: address GitHub Advanced Security and Codex review findings 1. Add explicit workflow permissions (contents: read) - Addresses GitHub Advanced Security CodeQL warnings - Limits GITHUB_TOKEN permissions to read-only for CI workflow - Reduces security risk by following principle of least privilege 2. Fix local smoke test for user benchmarks - Only run local smoke test for sample_fns::* functions - Skip gracefully for user-defined benchmarks - Prevents 'unknown benchmark function' errors - User benchmarks aren't linked into CLI binary (will run on device) - Addresses Codex P1 review finding Note: Skipped iOS certificate pinning (Semgrep finding) as the app does not make network requests. Can be revisited if needed later. * mobench: support repository structure and fix iOS IPA packaging Updates CLI and SDK to work seamlessly with both SDK projects (bench-mobile/) and repository structure (crates/sample-fns/), enabling full end-to-end testing using only mobench commands. Changes: - AndroidBuilder/IosBuilder: Add find_crate_dir() to detect benchmark crate in multiple locations (bench-mobile/ or crates/{crate_name}/) - UniFFI binding generation: Make optional, use pre-existing bindings if available, only require uniffi-bindgen CLI for fresh generation - iOS IPA packaging: Complete rewrite from xcodebuild archive to simpler xcodebuild build approach, with ad-hoc signing support for BrowserStack testing (no Apple Developer account needed) - BrowserStack XCUITest: Add only-testing parameter support to specify test methods (required by BrowserStack API) - iOS project: Add schemes configuration to project.yml for proper xcodebuild support Verified working: - cargo mobench build --target android - cargo mobench build --target ios - cargo mobench package-ipa --method adhoc - cargo mobench run (both Android and iOS on BrowserStack) * mobench: add --fetch flag for CI-ready result retrieval Implements automatic polling and result fetching for BrowserStack runs, making the CLI fully CI-ready with one-command benchmark execution. BrowserStack Client API: - poll_build_completion(): Wait for build to finish with configurable timeout - get_espresso_build_status() / get_xcuitest_build_status(): Check build status - get_device_logs(): Fetch logs from specific device sessions - extract_benchmark_results(): Parse benchmark JSON from logs - wait_and_fetch_results(): Complete workflow (poll + fetch + extract) CLI Integration: - --fetch flag on 'run' command triggers automatic result fetching - Merges benchmark_results into output JSON (device -> results map) - Configurable timeout (--fetch-timeout-secs, default: 1800) - Configurable poll interval (--fetch-poll-interval-secs, default: 10) - Graceful error handling with warnings (command succeeds even if fetch fails) Output Format Enhancement: - RunSummary now includes optional benchmark_results field - Results organized by device name - Each device has array of benchmark results with samples, mean, min, max Testing: - 8 new comprehensive tests for result extraction and parsing - Tests for BuildStatus conversion and deserialization - Tests for JSON extraction from logs (single, multiple, invalid, missing) - All tests passing (21 total in browserstack module) Documentation: - FETCH_RESULTS_GUIDE.md: Complete guide for --fetch usage - BROWSERSTACK_CI_INTEGRATION.md: Programmatic API reference - GitHub Actions examples with step summaries - Error handling patterns and best practices Example Usage: cargo mobench run \ --target android \ --function sample_fns::fibonacci \ --iterations 30 \ --warmup 5 \ --devices "Google Pixel 7-13.0" \ --fetch \ --output results.json This release makes mobench fully CI-ready with automatic result retrieval, eliminating the need for manual log parsing or separate fetch commands. * gitignore: exclude BrowserStack run artifacts and temp files * mobench: print dashboard URL and result summary with --fetch Improves CLI output when using --fetch to show: - Dashboard URL immediately after scheduling (not just on error) - Summary of benchmark results for each device - Mean time in both nanoseconds and milliseconds - Number of samples collected - Dashboard URL again after successful fetch for easy access Example output: Waiting for build 88f8c5a... to complete... Dashboard: https://app-automate.browserstack.com/dashboard/v2/builds/88f8c5a... ✓ Successfully fetched results from 1 device(s) Device: Google Pixel 7-13.0 Benchmark 1: sample_fns::fibonacci Mean: 1237000 ns (1.24 ms) Samples: 30 View full results: https://app-automate.browserstack.com/... Makes CI output more informative and actionable without requiring manual JSON parsing or URL construction. * docs: document BrowserStack metrics and performance monitoring Adds comprehensive documentation on what device metrics BrowserStack provides and what mobench currently captures. Key points: - BrowserStack does NOT provide built-in CPU/memory/battery profiling - Performance metrics must be collected by your app code - We currently capture: logs, videos, network traces, screenshots - We extract: benchmark timing data from logs - Future enhancement: Extract custom performance metrics from logs Provides examples for collecting memory/CPU metrics on Android/iOS and logging them as JSON for extraction. References BrowserStack API documentation and platform-specific APIs for memory profiling. * feat(mobench): add performance metrics extraction (v0.1.5) - Add performance metrics types (PerformanceSnapshot, MemoryMetrics, CpuMetrics, etc.) - Implement extract_performance_metrics() to parse memory/CPU data from device logs - Add wait_and_fetch_all_results() to fetch both benchmark and performance results - Update RunSummary to include performance_metrics field - Add comprehensive tests for performance metrics extraction - Update CLI --fetch to display performance metrics alongside benchmark results - Update BROWSERSTACK_METRICS.md to reflect new functionality Performance metrics are automatically extracted when using --fetch flag if the app logs them in JSON format with memory/cpu fields or type='performance'. Example output format: { "memory": {"used_mb": 128.5, "max_mb": 512.0}, "cpu": {"usage_percent": 45.2} } * refactor: remove dead code and fix compiler warnings - Remove unused `wait_and_fetch_results()` from browserstack.rs (superseded by `wait_and_fetch_all_results()`) - Add `#[cfg(test)]` to `run_local_smoke()` in main.rs - Remove unused iOS signing methods (`identity()`, `export_method()`, `create_export_options_plist()`) - Remove unused imports (`std::io::Write`, `BenchSpec`, `run_closure`) - Add explanatory comment for dual binary targets in Cargo.toml This cleanup addresses all compiler warnings and removes 117 lines of dead code. Co-Authored-By: Claude Opus 4.5 * fix: build Android test APK and harden BrowserStack fetch --------- Co-authored-by: tfe-app[bot] <200245884+tfe-app[bot]@users.noreply.github.com> Co-authored-by: wld-terraform Co-authored-by: dcbuilder.eth Co-authored-by: Claude Opus 4.5 --- .github/workflows/mobile-bench.yml | 5 +- .github/workflows/relyance-sci.yml | 3 +- .gitignore | 4 + BENCH_SDK_INTEGRATION.md | 188 +++ BROWSERSTACK_CI_INTEGRATION.md | 314 ++++ BROWSERSTACK_METRICS.md | 288 ++++ BUILD.md | 72 +- CLAUDE.md | 300 +++- Cargo.lock | 116 +- Cargo.toml | 20 +- FETCH_RESULTS_GUIDE.md | 272 ++++ LICENSE.md | 21 + PROJECT_PLAN.md | 6 +- README.md | 407 ++++- TESTING.md | 59 +- .../main/java/dev/world/bench/MainActivity.kt | 2 +- .../main/java/uniffi/sample_fns/sample_fns.kt | 19 +- crates/mobench-macros/Cargo.toml | 18 + crates/mobench-macros/README.md | 194 +++ crates/mobench-macros/src/lib.rs | 65 + crates/mobench-runner/Cargo.toml | 14 + crates/mobench-runner/README.md | 131 ++ crates/mobench-runner/src/lib.rs | 92 ++ crates/mobench-sdk/Cargo.toml | 45 + crates/mobench-sdk/README.md | 402 +++++ crates/mobench-sdk/src/builders/android.rs | 458 ++++++ crates/mobench-sdk/src/builders/ios.rs | 868 +++++++++++ crates/mobench-sdk/src/builders/mod.rs | 11 + crates/mobench-sdk/src/codegen.rs | 501 ++++++ crates/mobench-sdk/src/lib.rs | 111 ++ crates/mobench-sdk/src/registry.rs | 125 ++ crates/mobench-sdk/src/runner.rs | 142 ++ crates/mobench-sdk/src/types.rs | 116 ++ .../templates/android/app/build.gradle | 69 + .../java/MainActivityTest.kt.template | 25 + .../android/app/src/main/AndroidManifest.xml | 21 + .../src/main/java/MainActivity.kt.template | 167 ++ .../app/src/main/res/layout/activity_main.xml | 16 + .../app/src/main/res/values/strings.xml | 4 + .../app/src/main/res/values/themes.xml | 5 + .../templates/android/build.gradle | 21 + .../templates/android/settings.gradle | 2 + .../BenchRunner-Bridging-Header.h.template | 8 + .../BenchRunner/BenchRunnerApp.swift.template | 10 + .../BenchRunner/BenchRunnerFFI.swift.template | 118 ++ .../BenchRunner/ContentView.swift.template | 25 + .../BenchRunnerUITests.swift.template | 12 + .../templates/ios/BenchRunner/project.yml | 37 + crates/mobench/Cargo.toml | 42 + crates/mobench/README.md | 493 ++++++ crates/mobench/src/browserstack.rs | 1343 ++++++++++++++++ crates/mobench/src/main.rs | 1385 +++++++++++++++++ crates/sample-fns/Cargo.toml | 4 +- crates/sample-fns/build.rs | 3 +- .../sample-fns/src/bin/generate-bindings.rs | 69 +- crates/sample-fns/src/lib.rs | 36 +- examples/basic-benchmark/Cargo.toml | 21 + examples/basic-benchmark/build.rs | 4 + examples/basic-benchmark/src/lib.rs | 231 +++ .../BenchRunner/Generated/sample_fns.swift | 17 +- ios/BenchRunner/project.yml | 13 + scripts/build-android-app.sh | 26 + scripts/build-android.sh | 8 + scripts/build-ios.sh | 19 + scripts/generate-bindings.sh | 46 +- scripts/sync-android-libs.sh | 15 +- templates/android/app/build.gradle | 69 + .../java/MainActivityTest.kt.template | 25 + .../android/app/src/main/AndroidManifest.xml | 21 + .../src/main/java/MainActivity.kt.template | 167 ++ .../app/src/main/res/layout/activity_main.xml | 16 + .../app/src/main/res/values/strings.xml | 4 + .../app/src/main/res/values/themes.xml | 5 + templates/android/build.gradle | 21 + templates/android/settings.gradle | 2 + .../BenchRunner-Bridging-Header.h.template | 8 + .../BenchRunner/BenchRunnerApp.swift.template | 10 + .../BenchRunner/BenchRunnerFFI.swift.template | 118 ++ .../BenchRunner/ContentView.swift.template | 25 + .../BenchRunnerUITests.swift.template | 12 + templates/ios/BenchRunner/project.yml | 37 + test_browserstack.sh | 74 + 82 files changed, 10061 insertions(+), 257 deletions(-) create mode 100644 BENCH_SDK_INTEGRATION.md create mode 100644 BROWSERSTACK_CI_INTEGRATION.md create mode 100644 BROWSERSTACK_METRICS.md create mode 100644 FETCH_RESULTS_GUIDE.md create mode 100644 LICENSE.md create mode 100644 crates/mobench-macros/Cargo.toml create mode 100644 crates/mobench-macros/README.md create mode 100644 crates/mobench-macros/src/lib.rs create mode 100644 crates/mobench-runner/Cargo.toml create mode 100644 crates/mobench-runner/README.md create mode 100644 crates/mobench-runner/src/lib.rs create mode 100644 crates/mobench-sdk/Cargo.toml create mode 100644 crates/mobench-sdk/README.md create mode 100644 crates/mobench-sdk/src/builders/android.rs create mode 100644 crates/mobench-sdk/src/builders/ios.rs create mode 100644 crates/mobench-sdk/src/builders/mod.rs create mode 100644 crates/mobench-sdk/src/codegen.rs create mode 100644 crates/mobench-sdk/src/lib.rs create mode 100644 crates/mobench-sdk/src/registry.rs create mode 100644 crates/mobench-sdk/src/runner.rs create mode 100644 crates/mobench-sdk/src/types.rs create mode 100644 crates/mobench-sdk/templates/android/app/build.gradle create mode 100644 crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template create mode 100644 crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml create mode 100644 crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template create mode 100644 crates/mobench-sdk/templates/android/app/src/main/res/layout/activity_main.xml create mode 100644 crates/mobench-sdk/templates/android/app/src/main/res/values/strings.xml create mode 100644 crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml create mode 100644 crates/mobench-sdk/templates/android/build.gradle create mode 100644 crates/mobench-sdk/templates/android/settings.gradle create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h.template create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerApp.swift.template create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/project.yml create mode 100644 crates/mobench/Cargo.toml create mode 100644 crates/mobench/README.md create mode 100644 crates/mobench/src/browserstack.rs create mode 100644 crates/mobench/src/main.rs create mode 100644 examples/basic-benchmark/Cargo.toml create mode 100644 examples/basic-benchmark/build.rs create mode 100644 examples/basic-benchmark/src/lib.rs create mode 100644 templates/android/app/build.gradle create mode 100644 templates/android/app/src/androidTest/java/MainActivityTest.kt.template create mode 100644 templates/android/app/src/main/AndroidManifest.xml create mode 100644 templates/android/app/src/main/java/MainActivity.kt.template create mode 100644 templates/android/app/src/main/res/layout/activity_main.xml create mode 100644 templates/android/app/src/main/res/values/strings.xml create mode 100644 templates/android/app/src/main/res/values/themes.xml create mode 100644 templates/android/build.gradle create mode 100644 templates/android/settings.gradle create mode 100644 templates/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h.template create mode 100644 templates/ios/BenchRunner/BenchRunner/BenchRunnerApp.swift.template create mode 100644 templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template create mode 100644 templates/ios/BenchRunner/BenchRunner/ContentView.swift.template create mode 100644 templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template create mode 100644 templates/ios/BenchRunner/project.yml create mode 100755 test_browserstack.sh diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index ecdbb7a..3facc4b 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -8,6 +8,9 @@ on: required: false default: "android" +permissions: + contents: read + jobs: tests: name: Host tests @@ -117,7 +120,7 @@ jobs: - name: Local bench summary (stub) run: | - cargo run -p bench-cli -- run \ + cargo run -p mobench -- run \ --target android \ --function sample_fns::fibonacci \ --iterations 5 \ diff --git a/.github/workflows/relyance-sci.yml b/.github/workflows/relyance-sci.yml index 95feb3b..fb33cf3 100644 --- a/.github/workflows/relyance-sci.yml +++ b/.github/workflows/relyance-sci.yml @@ -11,8 +11,7 @@ permissions: jobs: execute-relyance-sci: name: Relyance SCI Job - runs-on: - group: arc-public-large-amd64-runner + runs-on: ubuntu-latest permissions: contents: read diff --git a/.gitignore b/.gitignore index 3715aa4..97ca88b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ target/ios/ run-summary.json bench-config.toml device-matrix.yaml +run-*.json +browserstack-*.log +BROWSERSTACK_RUN_*.md +bench-mobile diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md new file mode 100644 index 0000000..b2ed4b7 --- /dev/null +++ b/BENCH_SDK_INTEGRATION.md @@ -0,0 +1,188 @@ +# mobench-sdk Integration Guide + +This guide shows how to integrate `mobench-sdk` into an existing Rust project, run local +mobile benchmarks, and then run them on BrowserStack. + +> **Important**: This guide is for integrators importing `mobench-sdk` as a library. +> You do **NOT** need the `scripts/` directory from this repository. +> All build functionality is available via `cargo mobench` commands. + +## 1) Prerequisites + +Install the following tools (per platform): + +- Rust toolchain (stable) + `rustup`: + - https://www.rust-lang.org/tools/install +- Android: + - Android Studio (SDK + NDK manager): https://developer.android.com/studio + - Android NDK (API 24+): https://developer.android.com/ndk/downloads + - `cargo-ndk` (`cargo install cargo-ndk`): https://github.com/bbqsrc/cargo-ndk + - JDK 17+ (for Gradle; any distribution): https://openjdk.org/install/ + - Note: Android Gradle Plugin (AGP) officially supports Java 17. +- iOS (macOS only): + - Xcode + Command Line Tools: https://developer.apple.com/xcode/ + - Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim` + - https://doc.rust-lang.org/rustup/targets.html + - `xcodegen` (optional): https://github.com/yonaskolb/XcodeGen + +## 2) Add mobench-sdk to your crate + +In your project's `Cargo.toml`: + +```toml +[dependencies] +mobench-sdk = "0.1" +``` + +## 3) Annotate benchmark functions + +Add `#[mobench_sdk::benchmark]` to any function you want to run on devices. + +```rust +use mobench_sdk::benchmark; + +#[benchmark] +fn checksum_bench() { + let data = [1u8; 1024]; + let sum: u64 = data.iter().map(|b| *b as u64).sum(); + std::hint::black_box(sum); +} +``` + +Benchmarks are identified by name at runtime. You can call them by: +- Fully-qualified path (e.g., `my_crate::checksum_bench`) +- Or suffix match (e.g., `checksum_bench`) + +## 4) Scaffold mobile projects + +From your repo root, create a mobile harness with the CLI: + +```bash +cargo mobench init-sdk --target both --project-name my-bench --output-dir . +``` + +This generates: +- `bench-mobile/` (FFI bridge that links your crate) +- `android/` and `ios/` app templates +- `mobench-sdk.toml` configuration + +## 5) Local Android testing + +Build the Android app using the mobench: + +```bash +cargo mobench build --target android +``` + +This automatically: +- Builds Rust libraries for all Android ABIs (arm64-v8a, armeabi-v7a, x86_64) +- Generates UniFFI Kotlin bindings +- Copies .so files to jniLibs +- Runs Gradle to create the APK + +Install and run on emulator or device: + +```bash +adb install -r android/app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n dev.world.bench/.MainActivity +``` + +To override benchmark parameters: + +```bash +adb shell am start -n dev.world.bench/.MainActivity \ + --es bench_function my_crate::checksum_bench \ + --ei bench_iterations 30 \ + --ei bench_warmup 5 +``` + +## 6) Local iOS testing + +Build the iOS xcframework using the mobench: + +```bash +cargo mobench build --target ios +``` + +This automatically: +- Builds Rust libraries for iOS device + simulator +- Generates UniFFI Swift bindings and C headers +- Creates properly structured xcframework +- Code-signs the framework +- Generates Xcode project (if xcodegen is installed) + +Open and run in Xcode: + +```bash +open ios/BenchRunner/BenchRunner.xcodeproj +``` + +The app will read `bench_spec.json` from the bundle or use defaults. + +## 7) BrowserStack (Android Espresso) + +Build APK + test APK: + +```bash +cargo mobench build --target android +cd android +./gradlew :app:assembleDebugAndroidTest +cd .. +``` + +Run on BrowserStack: + +```bash +cargo mobench run \ + --target android \ + --function my_crate::checksum_bench \ + --iterations 100 \ + --warmup 10 \ + --devices "Pixel 7-13.0" +``` + +The CLI will automatically: +- Upload APK and test APK to BrowserStack +- Schedule the test run +- Wait for completion +- Download results and logs + +## 8) BrowserStack (iOS XCUITest) + +Build iOS artifacts and package as IPA: + +```bash +# Build xcframework +cargo mobench build --target ios + +# Package as IPA (ad-hoc signing, no Apple ID needed) +cargo mobench package-ipa --method adhoc + +# Or for development signing (requires Apple Developer account) +cargo mobench package-ipa --method development +``` + +Run on BrowserStack: + +```bash +cargo mobench run \ + --target ios \ + --function my_crate::checksum_bench \ + --iterations 100 \ + --warmup 10 \ + --devices "iPhone 14-16" \ + --ios-app target/ios/BenchRunner.ipa \ + --ios-test-suite target/ios/BenchRunnerUITests.zip +``` + +**IPA Signing Methods:** +- `adhoc`: No Apple ID required, works for BrowserStack device testing +- `development`: Requires Apple Developer account, for physical device testing + +## Notes + +- **No scripts needed**: All functionality is available via `cargo mobench` commands +- If you change FFI types, the build process automatically regenerates bindings +- Android emulator ABI is typically `x86_64` in Android Studio +- BrowserStack credentials must be set via `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` +- For developing this repo (not integrating the SDK), legacy `scripts/` are available but deprecated diff --git a/BROWSERSTACK_CI_INTEGRATION.md b/BROWSERSTACK_CI_INTEGRATION.md new file mode 100644 index 0000000..279faae --- /dev/null +++ b/BROWSERSTACK_CI_INTEGRATION.md @@ -0,0 +1,314 @@ +# BrowserStack CI Integration Guide + +This guide shows how to run benchmarks on BrowserStack and fetch results within CI pipelines. + +## Overview + +The BrowserStack client now supports: +1. **Scheduling runs** - Upload artifacts and start tests +2. **Polling for completion** - Wait for tests to finish (with timeout) +3. **Fetching results** - Download device logs and extract benchmark data + +## Quick Example + +```rust +use mobench::browserstack::{BrowserStackClient, BrowserStackAuth}; + +// 1. Create client +let client = BrowserStackClient::new( + BrowserStackAuth { + username: env::var("BROWSERSTACK_USERNAME")?, + access_key: env::var("BROWSERSTACK_ACCESS_KEY")?, + }, + Some("my-project".to_string()), +)?; + +// 2. Upload artifacts +let app_upload = client.upload_espresso_app(Path::new("app.apk"))?; +let test_upload = client.upload_espresso_test_suite(Path::new("test.apk"))?; + +// 3. Schedule run +let run = client.schedule_espresso_run( + &["Google Pixel 7-13.0"], + &app_upload.app_url, + &test_upload.test_suite_url, +)?; + +println!("Build ID: {}", run.build_id); +println!("Dashboard: https://app-automate.browserstack.com/dashboard/v2/builds/{}", run.build_id); + +// 4. Wait for completion and fetch results +let results = client.wait_and_fetch_results(&run.build_id, "espresso", Some(600))?; + +// 5. Process results +for (device, bench_results) in results { + println!("Device: {}", device); + for result in bench_results { + println!(" Result: {}", serde_json::to_string_pretty(&result)?); + } +} +``` + +## CLI Integration + +### Using `mobench run` with Result Fetching + +The CLI can be extended to wait for results: + +```bash +# Run and wait for results (not yet implemented in CLI) +cargo mobench run \ + --target android \ + --function sample_fns::fibonacci \ + --iterations 30 \ + --warmup 5 \ + --devices "Google Pixel 7-13.0" \ + --wait \ + --timeout 600 \ + --output results.json +``` + +## API Methods + +### 1. Poll for Build Completion + +```rust +pub fn poll_build_completion( + &self, + build_id: &str, + platform: &str, // "espresso" or "xcuitest" + timeout_secs: u64, // Max wait time + poll_interval_secs: u64, // Check interval +) -> Result +``` + +**Example:** +```rust +let status = client.poll_build_completion( + "88f8c5a3134562b8a92004582b757468ee10d08c", + "espresso", + 600, // 10 minute timeout + 10, // Check every 10 seconds +)?; + +println!("Build status: {}", status.status); +println!("Duration: {:?}s", status.duration); +``` + +### 2. Get Build Status (Single Check) + +```rust +// For Espresso +let status = client.get_espresso_build_status(build_id)?; + +// For XCUITest +let status = client.get_xcuitest_build_status(build_id)?; +``` + +**Build Status Values:** +- `"running"` - Tests are executing +- `"done"` - Tests completed successfully +- `"failed"` - Tests failed +- `"error"` - Build error occurred +- `"timeout"` - Exceeded time limit + +### 3. Fetch Device Logs + +```rust +let logs = client.get_device_logs(build_id, session_id, "espresso")?; +println!("Device logs:\n{}", logs); +``` + +### 4. Extract Benchmark Results + +```rust +let logs = client.get_device_logs(build_id, session_id, "espresso")?; +let results = client.extract_benchmark_results(&logs)?; + +for result in results { + if let Some(function) = result.get("function") { + println!("Function: {}", function); + } + if let Some(samples) = result.get("samples").and_then(|s| s.as_array()) { + println!("Samples: {} measurements", samples.len()); + } +} +``` + +### 5. Complete Workflow (Convenience Method) + +```rust +use std::collections::HashMap; + +let results: HashMap> = client.wait_and_fetch_results( + build_id, + "espresso", + Some(600), // 10 minute timeout +)?; + +// Results is a map: device name -> benchmark results +for (device, bench_results) in results { + println!("\nDevice: {}", device); + for result in bench_results { + // Parse benchmark data + if let Some(mean) = result.get("mean_ns") { + println!(" Mean: {} ns", mean); + } + } +} +``` + +## GitHub Actions Example + +```yaml +name: Mobile Benchmarks + +on: + push: + branches: [main] + pull_request: + +jobs: + benchmark-android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install mobench + run: cargo install mobench + + - name: Run benchmarks on BrowserStack + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + run: | + # Build and run (waits for completion) + cargo mobench run \ + --target android \ + --function my_crate::my_benchmark \ + --iterations 30 \ + --warmup 5 \ + --devices "Google Pixel 7-13.0" \ + --wait \ + --timeout 600 \ + --output results.json + + # Extract metrics for comparison + cat results.json | jq '.devices[0].samples | map(.duration_ns) | add / length' + + - name: Upload results + uses: actions/upload-artifact@v4 + with: + name: benchmark-results + path: results.json +``` + +## Advanced: Custom Result Processing + +```rust +use mobench::browserstack::BrowserStackClient; + +fn process_benchmark_results( + client: &BrowserStackClient, + build_id: &str, + platform: &str, +) -> Result<()> { + // Wait for completion + let status = client.poll_build_completion(build_id, platform, 600, 10)?; + + // Process each device + for device in &status.devices { + println!("Processing device: {}", device.device); + + // Fetch logs + let logs = client.get_device_logs(build_id, &device.session_id, platform)?; + + // Extract results + let results = client.extract_benchmark_results(&logs)?; + + // Custom analysis + for result in results { + if let Some(samples) = result.get("samples").and_then(|s| s.as_array()) { + let durations: Vec = samples + .iter() + .filter_map(|s| s.get("duration_ns")?.as_f64()) + .collect(); + + let mean = durations.iter().sum::() / durations.len() as f64; + let min = durations.iter().fold(f64::INFINITY, |a, &b| a.min(b)); + let max = durations.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b)); + + println!(" Mean: {:.2} ns", mean); + println!(" Min: {:.2} ns", min); + println!(" Max: {:.2} ns", max); + } + } + } + + Ok(()) +} +``` + +## Timeout Recommendations + +- **Default**: 600 seconds (10 minutes) +- **Quick tests**: 300 seconds (5 minutes) +- **Extensive benchmarks**: 1200-1800 seconds (20-30 minutes) + +## Error Handling + +```rust +match client.wait_and_fetch_results(build_id, "espresso", Some(600)) { + Ok(results) => { + println!("Successfully fetched results from {} devices", results.len()); + } + Err(e) if e.to_string().contains("Timeout") => { + eprintln!("Build timed out - may still be running"); + eprintln!("Check dashboard: https://app-automate.browserstack.com/dashboard/v2/builds/{}", build_id); + } + Err(e) if e.to_string().contains("failed") => { + eprintln!("Build failed: {}", e); + } + Err(e) => { + eprintln!("Error fetching results: {}", e); + } +} +``` + +## Troubleshooting + +### No benchmark results found + +**Cause**: The benchmark app didn't log results, or logs are in unexpected format. + +**Solution**: +1. Check device logs manually in BrowserStack dashboard +2. Verify your app logs benchmark results as JSON to stdout/logcat +3. Use `client.get_device_logs()` to inspect raw logs + +### Build stuck in "running" state + +**Cause**: App crashed, tests hung, or device disconnected. + +**Solution**: +1. Check the BrowserStack dashboard for device screenshots/video +2. Increase timeout if benchmarks legitimately take longer +3. Add health checks to your benchmark code + +### Rate limiting + +**Cause**: Too many API requests. + +**Solution**: +1. Increase poll interval: `poll_build_completion(id, platform, 600, 30)` (30s interval) +2. Use BrowserStack's webhook notifications instead of polling +3. Check your BrowserStack plan limits + +## Next Steps + +- See `BROWSERSTACK_RUN_2.md` for current test run results +- Check `crates/mobench/src/browserstack.rs` for full API documentation +- Run `cargo doc --open -p mobench` for detailed API docs diff --git a/BROWSERSTACK_METRICS.md b/BROWSERSTACK_METRICS.md new file mode 100644 index 0000000..70e8a8d --- /dev/null +++ b/BROWSERSTACK_METRICS.md @@ -0,0 +1,288 @@ +# BrowserStack Device Metrics + +This document describes what device metrics BrowserStack provides and what we currently capture. + +## Current Implementation + +### ✅ What We Capture Now + +**Build-level:** +- Build ID +- Build status (running, done, failed, error, timeout) +- Build duration (total time in seconds) + +**Session-level:** +- Device name (e.g., "Google Pixel 7-13.0") +- Session ID (for artifact URLs) +- Session status (passed, failed, error) +- Device logs (text, contains logcat/system logs) + +**Artifacts Downloaded:** +We recursively download ALL URLs from session JSON, which typically includes: +- `device_logs` - Device logcat/console output +- `video_url` - Screen recording of test execution +- `appium_logs_url` - Appium automation logs +- `instrumentation_logs` - Espresso/XCUITest instrumentation logs +- `network_logs` - Network traffic logs (HAR format) +- `screenshots` - Screenshots at various test points + +**Benchmark Data:** +- Extracted from device logs (JSON output from your app) +- Timing samples (duration_ns for each iteration) +- Statistical metrics (mean, median, min, max, stddev) + +**Performance Metrics (v0.1.5+):** +- Extracted from device logs (JSON output with `"type": "performance"` or `memory`/`cpu` fields) +- Memory usage (used_mb, max_mb, available_mb, total_mb) + - Aggregate statistics: peak, average, min +- CPU usage (usage_percent) + - Aggregate statistics: peak, average, min +- Automatically included in RunSummary when using `--fetch` flag + +### ⚠️ What We DON'T Capture (But BrowserStack Provides) + +Based on [BrowserStack App Automate API documentation](https://www.browserstack.com/docs/app-automate/api-reference): + +**Session Details (available but not parsed):** +- `duration` - Individual session duration (vs. total build duration) +- `start_time` / `end_time` - Precise timestamps +- `app_details` - App name, version, custom_id +- `reason` - Failure reason if test failed +- `build_tag` - Custom build tags + +**Performance Metrics:** + +BrowserStack does NOT provide built-in CPU/Memory/Battery metrics in standard API responses. However, **mobench v0.1.5+ now supports extracting these metrics** if your app logs them: + +1. **Collect metrics in your app** using Android/iOS APIs: + - Android: `ActivityManager.MemoryInfo`, `Debug.MemoryInfo` + - iOS: `task_info`, `mach_task_basic_info` + +2. **Log to device logs** in JSON format (see example below) + +3. **mobench automatically extracts** them alongside benchmark results when using `--fetch` + +## BrowserStack Limitations + +According to their documentation, BrowserStack App Automate **does not** provide: + +- ❌ Built-in memory profiling +- ❌ Built-in CPU profiling +- ❌ Built-in battery/power profiling +- ❌ Built-in frame rate/rendering metrics + +These metrics must be collected by **your application code** and logged. + +## How to Add Performance Metrics + +### Step 1: Collect Metrics in Your App + +**Android Example:** +```kotlin +fun getMemoryUsage(): MemoryMetrics { + val runtime = Runtime.getRuntime() + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memInfo) + + return MemoryMetrics( + usedMemoryMB = (runtime.totalMemory() - runtime.freeMemory()) / 1_048_576, + maxMemoryMB = runtime.maxMemory() / 1_048_576, + availableMemoryMB = memInfo.availMem / 1_048_576, + totalMemoryMB = memInfo.totalMem / 1_048_576 + ) +} + +// In your benchmark code: +val beforeMemory = getMemoryUsage() +runBenchmark(spec) +val afterMemory = getMemoryUsage() + +// Log as JSON +println(""" + {"type":"performance","timestamp":${System.currentTimeMillis()},"memory":{"before":${beforeMemory},"after":${afterMemory}}} +""".trimIndent()) +``` + +**iOS Example:** +```swift +func getMemoryUsage() -> MemoryMetrics { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size)/4 + let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + + return MemoryMetrics( + residentSizeMB: Double(info.resident_size) / 1_048_576, + virtualSizeMB: Double(info.virtual_size) / 1_048_576 + ) +} +``` + +### Step 2: Log Metrics in Structured Format + +Ensure your app logs performance metrics as JSON to stdout/logcat: + +```json +{ + "type": "performance_snapshot", + "timestamp_ms": 1705238400000, + "memory": { + "used_mb": 128.5, + "max_mb": 512.0, + "available_mb": 383.5 + }, + "cpu": { + "usage_percent": 45.2 + } +} +``` + +### Step 3: Extract Metrics with mobench + +**✅ Implemented in v0.1.5+** + +mobench now automatically extracts both benchmark results and performance metrics: + +```rust +// Extracts benchmark results +let results = client.extract_benchmark_results(&logs)?; + +// Extracts performance metrics +let performance = client.extract_performance_metrics(&logs)?; + +// Or get both at once +let (bench_results, perf_metrics) = client.wait_and_fetch_all_results( + build_id, + platform, + Some(timeout_secs), +)?; +``` + +## Implementation Details + +### API Methods + +```rust +impl BrowserStackClient { + /// Extract performance metrics from device logs + pub fn extract_performance_metrics(&self, logs: &str) -> Result; + + /// Wait for build completion and fetch both benchmark and performance results + pub fn wait_and_fetch_all_results( + &self, + build_id: &str, + platform: &str, + timeout_secs: Option, + ) -> Result<( + HashMap>, // benchmark_results + HashMap, // performance_metrics + )>; +} + +pub struct PerformanceMetrics { + pub sample_count: usize, + pub memory: Option, + pub cpu: Option, + pub snapshots: Vec, +} + +pub struct AggregateMemoryMetrics { + pub peak_mb: f64, + pub average_mb: f64, + pub min_mb: f64, +} + +pub struct AggregateCpuMetrics { + pub peak_percent: f64, + pub average_percent: f64, + pub min_percent: f64, +} +``` + +### RunSummary Output Format + +```json +{ + "spec": {...}, + "benchmark_results": { + "Google Pixel 7-13.0": [...] + }, + "performance_metrics": { + "Google Pixel 7-13.0": { + "sample_count": 30, + "memory": { + "peak_mb": 145.2, + "average_mb": 128.7, + "min_mb": 115.3 + }, + "cpu": { + "peak_percent": 78.5, + "average_percent": 45.2, + "min_percent": 12.1 + }, + "snapshots": [ + { + "timestamp_ms": 1705238400000, + "memory": {"used_mb": 128.5, "max_mb": 512.0}, + "cpu": {"usage_percent": 45.2} + } + ] + } + } +} +``` + +## Third-Party Performance Tools + +For more comprehensive profiling, consider: + +1. **Android Profiler** (Android Studio) + - Requires USB connection, not available on BrowserStack + +2. **Instruments** (Xcode) + - Requires Mac + physical device, not available on BrowserStack + +3. **Firebase Performance Monitoring** + - Can work on BrowserStack devices + - Requires Firebase SDK integration + - Provides CPU, memory, network metrics + +4. **Custom Instrumentation** + - Most flexible for BrowserStack + - Log metrics as JSON from your app + - Extract with mobench CLI + +## Recommendations + +For CI/benchmarking on BrowserStack: + +1. **Implement custom metric collection** in your app +2. **Log metrics as JSON** to stdout/logcat +3. **Extend mobench** to extract performance metrics (future enhancement) +4. **Focus on metrics that matter** for your use case: + - Memory: Peak usage, allocations during benchmark + - CPU: Usage spikes during computation + - Time: Already well-captured by benchmark harness + +## Current Workaround + +Until performance metric extraction is built-in: + +1. Log performance metrics from your app as JSON +2. Use `--fetch` to download device logs +3. Manually parse performance data from logs: + +```bash +cargo mobench run --fetch --output results.json +grep '"type":"performance"' target/browserstack/*/session-*/device-logs.txt | jq . +``` + +## See Also + +- BrowserStack API Docs: https://www.browserstack.com/docs/app-automate/api-reference +- Android MemoryInfo: https://developer.android.com/reference/android/app/ActivityManager.MemoryInfo +- iOS Memory Profiling: https://developer.apple.com/documentation/foundation/task_management diff --git a/BUILD.md b/BUILD.md index c7bd1f6..7ac4060 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,6 +2,13 @@ Complete build instructions for Android and iOS targets. +> **For SDK Integrators**: If you're importing `mobench-sdk` into your project, use the CLI commands: +> - `cargo mobench build --target android` for Android +> - `cargo mobench build --target ios` for iOS +> +> The scripts shown below are legacy tooling for developing this repository. +> See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide. + ## Table of Contents - [Prerequisites](#prerequisites) - [Android Build](#android-build) @@ -19,10 +26,13 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh rustc --version cargo --version ``` +Download: https://www.rust-lang.org/tools/install ### Android ```bash # Install Android NDK via Android Studio or sdkmanager +# Android Studio: https://developer.android.com/studio +# Android NDK: https://developer.android.com/ndk/downloads # Set environment variable (add to ~/.zshrc or ~/.bashrc) export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/29.0.14206865 @@ -31,6 +41,11 @@ rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-and # Install cargo-ndk cargo install cargo-ndk +## cargo-ndk: https://github.com/bbqsrc/cargo-ndk + +# Install JDK 17+ (for Gradle; any distribution) +# https://openjdk.org/install/ +# Note: Android Gradle Plugin (AGP) officially supports Java 17. # Verify NDK installation ls $ANDROID_NDK_HOME @@ -39,15 +54,18 @@ ls $ANDROID_NDK_HOME ### iOS (macOS only) ```bash # Install Xcode from App Store +# https://developer.apple.com/xcode/ # Install command-line tools xcode-select --install # Install xcodegen brew install xcodegen +## https://github.com/yonaskolb/XcodeGen # Install required Rust targets rustup target add aarch64-apple-ios aarch64-apple-ios-sim +## https://doc.rust-lang.org/rustup/targets.html # Verify installation xcodegen --version @@ -59,7 +77,8 @@ xcodebuild -version ### Quick Start (Recommended) ```bash # Build everything and create APK in one command -./scripts/build-android-app.sh +# For Android Studio emulators, use UNIFFI_ANDROID_ABI=x86_64 +UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh # Install on connected device or emulator adb install -r android/app/build/outputs/apk/debug/app-debug.apk @@ -70,9 +89,10 @@ adb shell am start -n dev.world.bench/.MainActivity ### Step-by-Step Build -#### Step 1: Build Rust Libraries +#### Step 1: Build Rust Libraries + Bindings ```bash -./scripts/build-android.sh +# ABI-aware binding generation to avoid UniFFI checksum mismatches. +UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh ``` This compiles Rust code for three Android ABIs: @@ -82,14 +102,9 @@ This compiles Rust code for three Android ABIs: Output: `target/android/{abi}/release/libsample_fns.so` -#### Step 2: Sync Libraries to Android Project -```bash -./scripts/sync-android-libs.sh -``` - This copies `.so` files to `android/app/src/main/jniLibs/{abi}/libsample_fns.so` where Android's build system expects them. -#### Step 3: Build APK with Gradle +#### Step 2: Build APK with Gradle ```bash cd android ./gradlew :app:assembleDebug @@ -98,7 +113,7 @@ cd .. Output: `android/app/build/outputs/apk/debug/app-debug.apk` -#### Step 4: Install and Run +#### Step 3: Install and Run ```bash # Install adb install -r android/app/build/outputs/apk/debug/app-debug.apk @@ -381,13 +396,18 @@ xcodegen generate - Check `scripts/build-ios.sh` uses `dev.world.sample-fns` for framework - App uses `dev.world.bench` -## UniFFI Bindings +## UniFFI Bindings (Proc Macros) + +This project uses UniFFI **proc macros** - no UDL file needed! FFI types are defined with attributes in Rust code. -If you modify the Rust API (`crates/sample-fns/src/sample_fns.udl`): +If you modify FFI types in Rust (`crates/sample-fns/src/lib.rs`): ```bash -# Regenerate bindings -cargo run --bin generate-bindings --features bindgen +# Build library to generate metadata +cargo build -p sample-fns + +# Regenerate bindings from proc macros +./scripts/generate-bindings.sh # This updates: # - android/app/src/main/java/uniffi/sample_fns/sample_fns.kt (Kotlin) @@ -395,21 +415,35 @@ cargo run --bin generate-bindings --features bindgen # - ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h (C header) # Then rebuild mobile apps -./scripts/build-android-app.sh # Android -./scripts/build-ios.sh # iOS -codesign --force --deep --sign - target/ios/sample_fns.xcframework +UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh # Android +./scripts/build-ios.sh # iOS (includes automatic code signing) ``` +**Example**: Adding a new FFI type: +```rust +#[derive(uniffi::Record)] +pub struct MyNewType { + pub field: String, +} + +#[uniffi::export] +pub fn my_new_function(arg: MyNewType) -> Result { + Ok(arg.field) +} +``` + +Then regenerate bindings as shown above. + ## Performance Testing Run benchmarks locally without mobile builds: ```bash -cargo run -p bench-cli -- demo --iterations 100 --warmup 10 +cargo mobench demo --iterations 100 --warmup 10 ``` ## Additional Documentation - **`TESTING.md`**: Comprehensive testing guide with troubleshooting - **`README.md`**: Project overview and quick start -- **`CLAUDE.md`**: Developer guide for working with this codebase +- **`CLAUDE.md`**: Developer guide for this codebase - **`PROJECT_PLAN.md`**: Architecture and roadmap diff --git a/CLAUDE.md b/CLAUDE.md index f8f1456..6338c56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,22 +4,50 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -mobile-bench-rs is a benchmarking tool for Rust functions on mobile devices (Android/iOS) using BrowserStack AppAutomate. It packages Rust functions into mobile binaries, runs them on real devices, and collects timing metrics. +mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. + +**Published on crates.io as the mobench ecosystem (v0.1.4):** +- **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with build automation +- **[mobench-macros](https://crates.io/crates/mobench-macros)** - `#[benchmark]` attribute proc macro +- **[mobench-runner](https://crates.io/crates/mobench-runner)** - Lightweight timing harness for mobile devices + +All packages are licensed under MIT (World Foundation, 2026). + +### Quick Start (SDK Users) + +```bash +# Install CLI +cargo install mobench + +# Add to your project +cargo add mobench-sdk + +# Mark functions for benchmarking +use mobench_sdk::benchmark; + +#[benchmark] +fn my_benchmark() { + // Your code here +} +``` ## Core Architecture ### Workspace Structure -The repository is organized as a Cargo workspace with three main crates: +The repository is organized as a Cargo workspace: -- **`bench-cli`**: CLI orchestrator that drives the entire workflow - building artifacts, uploading to BrowserStack, executing runs, and collecting results. Entry point for all operations. -- **`bench-runner`**: Lightweight harness library that gets embedded in mobile binaries. Provides timing infrastructure for benchmarks. -- **`sample-fns`**: Example benchmark functions with UniFFI bindings for mobile platforms. Compiled as `cdylib`, `staticlib`, and `rlib` for different mobile targets. +- **`crates/mobench`**: CLI orchestrator that drives the entire workflow - building artifacts, uploading to BrowserStack, executing runs, and collecting results. Entry point for all operations. +- **`crates/mobench-sdk`**: Core SDK library with registry system, builders (AndroidBuilder, IosBuilder), template generation, and BrowserStack integration. +- **`crates/mobench-macros`**: Proc macro crate providing the `#[benchmark]` attribute for marking functions. +- **`crates/mobench-runner`**: Lightweight timing harness library that gets embedded in mobile binaries. Provides timing infrastructure for benchmarks. +- **`examples/basic-benchmark`**: Example benchmark functions with UniFFI bindings for mobile platforms. Demonstrates the SDK usage pattern. ### Mobile Integration Flow 1. **Build Phase**: Rust functions are compiled to native libraries (`.so` for Android, `.a` for iOS) -2. **Bindings Generation**: UniFFI generates type-safe Kotlin/Swift bindings from `sample_fns.udl` +2. **Bindings Generation**: UniFFI **proc macros** generate type-safe Kotlin/Swift bindings from Rust code (no UDL file needed) 3. **Packaging**: Libraries and generated bindings are embedded into mobile apps (Android APK, iOS xcframework) 4. **Execution**: Apps read benchmark specs from: - Android: Intent extras or `bench_spec.json` asset @@ -43,7 +71,7 @@ The CLI supports both Espresso (Android) and XCUITest (iOS) test automation fram For comprehensive testing instructions, see **`TESTING.md`** which includes: - Prerequisites and setup - Host testing (cargo test, CLI demo) -- Android testing (emulator, device, Android Studio) +- Android testing (emulator, device, Android Studio; use `UNIFFI_ANDROID_ABI=x86_64` for default emulators) - iOS testing (simulator, device, Xcode) - Troubleshooting common issues - Advanced testing scenarios @@ -53,24 +81,60 @@ Quick test commands: # Run all Rust tests cargo test --all -# Test host harness -cargo run -p bench-cli -- demo --iterations 10 --warmup 2 +# Initialize SDK project +cargo mobench init --target android --output bench-sdk.toml -# Android e2e (requires Android NDK) -scripts/build-android-app.sh -adb install -r android/app/build/outputs/apk/debug/app-debug.apk -adb shell am start -n dev.world.bench/.MainActivity +# Build mobile artifacts (recommended approach) +cargo mobench build --target android +cargo mobench build --target ios -# iOS e2e (requires Xcode) +# List discovered benchmarks +cargo mobench list + +# Legacy: Direct script usage (for repository development only) +scripts/build-android-app.sh scripts/build-ios.sh -cd ios/BenchRunner && xcodegen generate && open BenchRunner.xcodeproj ``` ## Common Commands -### Building +### Building with CLI (Recommended) + +The `cargo mobench` CLI provides a unified build experience: + +```bash +# Install the CLI +cargo install mobench + +# Initialize project (generates config and scaffolding) +cargo mobench init --target android --output bench-sdk.toml -#### Android +# Build for Android +cargo mobench build --target android + +# Build for iOS +cargo mobench build --target ios + +# Build for both platforms +cargo mobench build --target android +cargo mobench build --target ios + +# Package iOS IPA (for BrowserStack or physical devices) +cargo mobench package-ipa --method adhoc +``` + +**What the CLI does:** +- Automatically builds Rust libraries with correct targets +- Generates or updates mobile app projects from embedded templates +- Syncs native libraries into platform-specific directories +- Builds APK (Android) or xcframework (iOS) +- No manual script execution needed + +### Legacy Script-Based Building (Repository Development) + +**Note:** The `scripts/` directory contains legacy tooling used for developing this repository. SDK users should use `cargo mobench build` instead. + +#### Android (Legacy) ```bash # Build Rust shared libraries for Android (requires Android NDK) scripts/build-android.sh @@ -81,21 +145,22 @@ scripts/sync-android-libs.sh # Build complete APK with Gradle cd android && gradle :app:assembleDebug -# Or use the full build script -scripts/build-android-app.sh +# Or use the all-in-one script +UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh ``` Requirements: - `ANDROID_NDK_HOME` environment variable set - `cargo-ndk` installed: `cargo install cargo-ndk` - Android SDK/NDK available (API level 24+) +- Set `UNIFFI_ANDROID_ABI=x86_64` for default Android Studio emulators -#### iOS +#### iOS (Legacy) ```bash # Build Rust xcframework for iOS (includes UniFFI headers and automatic signing) scripts/build-ios.sh -# Generate Xcode project from project.yml +# Generate Xcode project from project.yml (if using repository's iOS app) cd ios/BenchRunner && xcodegen generate # Open in Xcode @@ -105,7 +170,7 @@ open BenchRunner.xcodeproj Requirements: - Xcode command-line tools - Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim` -- `xcodegen` installed: `brew install xcodegen` +- `xcodegen` installed: `brew install xcodegen` (only for repository development) **Important iOS Build Details:** @@ -147,11 +212,12 @@ If automatic signing fails, the script will display a warning with instructions Note: UniFFI C headers are generated automatically during the build process and copied into each framework slice. -### Benchmarking +### Running Benchmarks -#### Local Smoke Test +#### Local Testing (No BrowserStack) ```bash -cargo run -p bench-cli -- run \ +# Run benchmark locally on emulator/simulator +cargo mobench run \ --target android \ --function sample_fns::fibonacci \ --iterations 100 \ @@ -162,7 +228,12 @@ cargo run -p bench-cli -- run \ #### BrowserStack Run (Android) ```bash -cargo run -p bench-cli -- run \ +# Set credentials +export BROWSERSTACK_USERNAME="your_username" +export BROWSERSTACK_ACCESS_KEY="your_access_key" + +# Run on real devices +cargo mobench run \ --target android \ --function sample_fns::checksum \ --iterations 30 \ @@ -173,53 +244,113 @@ cargo run -p bench-cli -- run \ #### BrowserStack Run (iOS) ```bash -cargo run -p bench-cli -- run \ +cargo mobench run \ --target ios \ --function sample_fns::fibonacci \ --iterations 20 \ --warmup 3 \ --devices "iPhone 14-16" \ --ios-app target/ios/BenchRunner.ipa \ - --ios-test-suite target/ios/BenchRunnerUITests.zip + --ios-test-suite target/ios/BenchRunnerUITests.zip \ + --output run-summary.json ``` #### Using Config Files ```bash # Generate starter config -cargo run -p bench-cli -- init --output bench-config.toml --target android +cargo mobench init --output bench-config.toml --target android # Generate device matrix -cargo run -p bench-cli -- plan --output device-matrix.yaml +cargo mobench plan --output device-matrix.yaml # Run with config -cargo run -p bench-cli -- run --config bench-config.toml +cargo mobench run --config bench-config.toml +``` + +#### Fetch BrowserStack Results +```bash +# Download results from previous run +cargo mobench fetch \ + --target android \ + --build-id abc123def456 \ + --output-dir ./results ``` ## Key Implementation Details -### FFI Boundary (`sample-fns`) +### SDK Integration Pattern -This crate uses **UniFFI** to generate type-safe bindings for Kotlin and Swift. The API is defined in `crates/sample-fns/src/sample_fns.udl`: +Users import `mobench-sdk` and use the `#[benchmark]` macro: -- **`runBenchmark(spec: BenchSpec) -> BenchReport`**: Main benchmark entrypoint with structured input/output -- **`BenchSpec`**: Struct containing `name` (function path), `iterations`, and `warmup` parameters -- **`BenchReport`**: Struct containing the original spec and a list of `BenchSample` timing results -- **`BenchError`**: Error enum with variants: `InvalidIterations`, `UnknownFunction`, `ExecutionFailed` +```rust +use mobench_sdk::benchmark; -Regenerate bindings after modifying the UDL: +#[benchmark] +fn my_expensive_operation() { + let result = compute_something(); + std::hint::black_box(result); +} +``` + +The macro automatically registers functions at compile time via the `inventory` crate. + +### FFI Boundary (`examples/basic-benchmark`) + +The example crate uses **UniFFI proc macros** to generate type-safe bindings for Kotlin and Swift. The API is defined directly in Rust code with attributes: + +```rust +#[derive(uniffi::Record)] +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} + +#[derive(uniffi::Error)] +pub enum BenchError { + InvalidIterations, + UnknownFunction { name: String }, + ExecutionFailed { reason: String }, +} + +#[uniffi::export] +pub fn run_benchmark(spec: BenchSpec) -> Result { + // Implementation +} + +uniffi::setup_scaffolding!(); // Auto-uses crate name as namespace +``` + +Regenerate bindings after modifying FFI types (for repository development): ```bash -cargo run --bin generate-bindings --features bindgen +# Build library to generate metadata +cargo build -p basic-benchmark + +# Generate Kotlin + Swift bindings +./scripts/generate-bindings.sh ``` -Generated files (committed to git): +Generated files (committed to git for the example app): - Kotlin: `android/app/src/main/java/uniffi/sample_fns/sample_fns.kt` - Swift: `ios/BenchRunner/BenchRunner/Generated/sample_fns.swift` - C header: `ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h` +### Template System + +The SDK embeds Android and iOS app templates using the `include_dir!` macro: +- Templates located in `crates/mobench-sdk/templates/` +- Embedded at compile time (no runtime file access needed) +- Generated projects are created in user's workspace via `cargo mobench init` + ### Mobile Spec Injection The CLI writes benchmark parameters to `target/mobile-spec/{android,ios}/bench_spec.json` during build. Mobile apps read this at runtime to know which function to benchmark. +When using SDK-generated projects: +- Templates include spec reading logic +- Apps automatically parse `bench_spec.json` from assets/bundle +- Supports runtime parameter override via Intent extras (Android) or environment variables (iOS) + ### BrowserStack Credentials Credentials are resolved in this order: @@ -237,15 +368,48 @@ The workflow supports manual dispatch with platform selection: ## Development Notes -### Adding New Benchmark Functions +### Using mobench-sdk in Your Project + +1. Add dependencies to your `Cargo.toml`: +```toml +[dependencies] +mobench-sdk = "0.1" +inventory = "0.3" +``` -1. Add function to `crates/sample-fns/src/lib.rs` +2. Mark functions with `#[benchmark]`: +```rust +use mobench_sdk::benchmark; + +#[benchmark] +fn my_function() { + // Your code + std::hint::black_box(result); +} +``` + +3. Build for mobile: +```bash +cargo mobench build --target android +cargo mobench build --target ios +``` + +4. Run benchmarks: +```bash +cargo mobench run --target android --function my_function +``` + +### Adding New Benchmark Functions to Repository Example + +1. Add function to `examples/basic-benchmark/src/lib.rs` 2. Add function dispatch to `run_benchmark()` match statement (e.g., `"my_func" => run_closure(spec, || my_func())`) -3. If the API surface changes (new public types or functions), update `sample_fns.udl` -4. Regenerate bindings: `cargo run --bin generate-bindings --features bindgen` -5. Rebuild native libraries: `scripts/build-android.sh` and/or `scripts/build-ios.sh` +3. If adding new FFI types, add proc macro attributes (`#[derive(uniffi::Record)]`, `#[uniffi::export]`, etc.) +4. Regenerate bindings: `./scripts/generate-bindings.sh` +5. Rebuild native libraries: `cargo mobench build --target ` or use legacy scripts 6. Mobile apps will automatically use the updated bindings +**Note**: No UDL file needed! Proc macros automatically detect FFI types from Rust code. + ### Target Architectures - **Android**: `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` (emulator) @@ -359,13 +523,47 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) ## Important Files -- **`PROJECT_PLAN.md`**: Goals, architecture, task backlog +### Core SDK Crates +- **`crates/mobench/`**: CLI tool (published to crates.io) + - `src/main.rs`: CLI entry point with commands (init, build, run, fetch, etc.) + - `src/browserstack.rs`: BrowserStack REST API client +- **`crates/mobench-sdk/`**: Core SDK library (published to crates.io) + - `src/lib.rs`: Public API surface + - `src/registry.rs`: Function discovery via `inventory` crate + - `src/runner.rs`: Timing harness integration + - `src/builders/android.rs`: Android build automation + - `src/builders/ios.rs`: iOS build automation + - `src/codegen.rs`: Template generation from embedded files + - `templates/`: Embedded Android/iOS app templates (via `include_dir!`) +- **`crates/mobench-macros/`**: Proc macro crate (published to crates.io) + - `src/lib.rs`: `#[benchmark]` attribute implementation +- **`crates/mobench-runner/`**: Timing harness (published to crates.io) + - `src/lib.rs`: Core timing and reporting logic + +### Example & Testing +- **`examples/basic-benchmark/`**: Example benchmark crate demonstrating SDK usage + - `src/lib.rs`: Sample benchmark functions with UniFFI bindings + - `src/bin/generate-bindings.rs`: Binding generation for Kotlin/Swift +- **`android/`**: Android test app (for repository development) + - `app/src/main/java/dev/world/bench/MainActivity.kt`: Android app entry point + - `app/src/main/java/uniffi/sample_fns/sample_fns.kt`: Generated Kotlin bindings +- **`ios/BenchRunner/`**: iOS test app (for repository development) + - `BenchRunner/BenchRunnerFFI.swift`: iOS FFI wrapper + - `BenchRunner/BenchRunner-Bridging-Header.h`: Objective-C bridging header + - `BenchRunner/Generated/`: UniFFI-generated Swift bindings and C headers + - `project.yml`: XcodeGen project specification + +### Documentation +- **`BUILD.md`**: Complete build reference with prerequisites and troubleshooting - **`TESTING.md`**: Comprehensive testing guide with detailed troubleshooting +- **`BENCH_SDK_INTEGRATION.md`**: Integration guide for SDK users +- **`PROJECT_PLAN.md`**: Goals, architecture, task backlog +- **`CLAUDE.md`**: This file - developer guide for the codebase + +### Legacy Build Scripts (Repository Development Only) - **`scripts/build-android.sh`**: Builds Rust libs with cargo-ndk for Android targets - **`scripts/build-ios.sh`**: Builds iOS xcframework with correct structure and code signing - **`scripts/sync-android-libs.sh`**: Copies .so files into Android jniLibs structure -- **`android/app/src/main/java/dev/world/bench/MainActivity.kt`**: Android app entry point -- **`ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift`**: iOS FFI wrapper -- **`ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h`**: Objective-C bridging header for C FFI types -- **`ios/BenchRunner/project.yml`**: XcodeGen project specification -- **`crates/bench-cli/src/browserstack.rs`**: BrowserStack REST API client +- **`scripts/generate-bindings.sh`**: Regenerates UniFFI bindings for Kotlin/Swift + +**Note**: SDK users should use `cargo mobench build` instead of calling scripts directly. diff --git a/Cargo.lock b/Cargo.lock index 30f40e2..4f77f69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,37 +118,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - -[[package]] -name = "bench-cli" +name = "basic-benchmark" version = "0.1.0" dependencies = [ - "anyhow", - "bench-runner", - "clap", - "dotenvy", - "reqwest", - "sample-fns", + "inventory", + "mobench-sdk", "serde", "serde_json", - "serde_yaml", - "tempfile", - "toml 0.8.23", + "thiserror 1.0.69", + "uniffi", ] [[package]] -name = "bench-runner" -version = "0.1.0" +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" dependencies = [ "serde", - "thiserror 1.0.69", ] [[package]] @@ -650,6 +637,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -660,6 +666,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -767,6 +782,57 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mobench" +version = "0.1.4" +dependencies = [ + "anyhow", + "clap", + "dotenvy", + "inventory", + "mobench-runner", + "mobench-sdk", + "reqwest", + "sample-fns", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "mobench-macros" +version = "0.1.4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mobench-runner" +version = "0.1.4" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "mobench-sdk" +version = "0.1.4" +dependencies = [ + "anyhow", + "include_dir", + "inventory", + "mobench-macros", + "mobench-runner", + "serde", + "serde_json", + "thiserror 1.0.69", + "toml 0.8.23", +] + [[package]] name = "nom" version = "7.1.3" @@ -1068,10 +1134,10 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.0" +version = "0.1.4" dependencies = [ - "bench-runner", "camino", + "mobench-runner", "serde", "serde_json", "thiserror 1.0.69", @@ -1531,7 +1597,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" dependencies = [ "anyhow", + "camino", "cargo_metadata", + "clap", "uniffi_bindgen", "uniffi_build", "uniffi_core", diff --git a/Cargo.toml b/Cargo.toml index 50b805a..93d72f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,18 @@ [workspace] members = [ - "crates/bench-cli", - "crates/bench-runner", + "crates/mobench", + "crates/mobench-runner", + "crates/mobench-macros", + "crates/mobench-sdk", "crates/sample-fns", + "examples/basic-benchmark", ] resolver = "2" [workspace.package] edition = "2024" -license = "MIT OR Apache-2.0" -version = "0.1.0" +license = "MIT" +version = "0.1.4" [workspace.dependencies] anyhow = "1" @@ -20,3 +23,12 @@ thiserror = "1" serde_yaml = "0.9" toml = "0.8" uniffi = "0.28" + +# Phase 1: Registry and proc macros +inventory = "0.3" +syn = { version = "2", features = ["full"] } +quote = "1" +proc-macro2 = "1" + +# Phase 1: Template embedding +include_dir = "0.7" diff --git a/FETCH_RESULTS_GUIDE.md b/FETCH_RESULTS_GUIDE.md new file mode 100644 index 0000000..0490fc4 --- /dev/null +++ b/FETCH_RESULTS_GUIDE.md @@ -0,0 +1,272 @@ +# Fetching BrowserStack Results in CI + +This guide shows how to use the `--fetch` flag to wait for BrowserStack tests to complete and retrieve results in CI pipelines. + +## Quick Start + +The `--fetch` flag makes `cargo mobench run` wait for BrowserStack tests to complete and automatically fetch benchmark results: + +```bash +cargo mobench run \ + --target android \ + --function sample_fns::fibonacci \ + --iterations 30 \ + --warmup 5 \ + --devices "Google Pixel 7-13.0" \ + --fetch \ + --output results.json +``` + +## How It Works + +When `--fetch` is enabled: + +1. **Builds and uploads** artifacts to BrowserStack +2. **Schedules** test run on specified devices +3. **Polls** for build completion (checks every 10 seconds) +4. **Fetches** device logs from all sessions +5. **Extracts** benchmark results as JSON +6. **Merges** results into output file + +## Output Format + +With `--fetch`, the output JSON includes a `benchmark_results` field: + +```json +{ + "spec": { + "target": "android", + "function": "sample_fns::fibonacci", + "iterations": 30, + "warmup": 5, + "devices": ["Google Pixel 7-13.0"] + }, + "remote_run": { + "platform": "android", + "app_url": "bs://...", + "build_id": "88f8c5a..." + }, + "benchmark_results": { + "Google Pixel 7-13.0": [ + { + "function": "sample_fns::fibonacci", + "iterations": 30, + "warmup": 5, + "samples": [ + {"duration_ns": 1234000}, + {"duration_ns": 1240000}, + ... + ], + "mean_ns": 1237000, + "median_ns": 1236500, + "min_ns": 1230000, + "max_ns": 1245000 + } + ] + } +} +``` + +## Configuration Options + +### Timeout + +Control how long to wait for build completion (default: 1800 seconds / 30 minutes): + +```bash +cargo mobench run \ + --target android \ + --function my_func \ + --devices "..." \ + --fetch \ + --fetch-timeout-secs 600 # Wait up to 10 minutes +``` + +### Poll Interval + +Control how often to check build status (default: 10 seconds): + +```bash +cargo mobench run \ + --target android \ + --function my_func \ + --devices "..." \ + --fetch \ + --fetch-poll-interval-secs 30 # Check every 30 seconds +``` + +### Output Directory + +Detailed artifacts (logs, screenshots, videos) are saved separately: + +```bash +cargo mobench run \ + --target android \ + --function my_func \ + --devices "..." \ + --fetch \ + --fetch-output-dir target/browserstack # Default location +``` + +Directory structure: +``` +target/browserstack/ +└── {build_id}/ + ├── build.json # Build metadata + ├── sessions.json # Session list + └── session-{id}/ + ├── session.json # Session details + ├── bench-report.json # Extracted benchmark data + ├── device-logs.txt # Raw device logs + └── *.mp4, *.png # Videos and screenshots +``` + +## GitHub Actions Example + +```yaml +name: Mobile Benchmarks + +on: + push: + branches: [main] + pull_request: + +jobs: + benchmark-android: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup Android NDK + uses: android-actions/setup-android@v3 + with: + packages: ndk;26.1.10909125 + + - name: Install mobench + run: cargo install mobench + + - name: Run benchmarks on BrowserStack + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/26.1.10909125 + run: | + cargo mobench run \ + --target android \ + --function my_crate::my_benchmark \ + --iterations 30 \ + --warmup 5 \ + --devices "Google Pixel 7-13.0" \ + --fetch \ + --fetch-timeout-secs 900 \ + --output results.json + + - name: Extract metrics + run: | + echo "## Benchmark Results" >> $GITHUB_STEP_SUMMARY + jq -r '.benchmark_results | to_entries[] | "### \(.key)\n- Mean: \(.value[0].mean_ns)ns\n- Min: \(.value[0].min_ns)ns\n- Max: \(.value[0].max_ns)ns"' results.json >> $GITHUB_STEP_SUMMARY + + - name: Upload results + uses: actions/upload-artifact@v4 + with: + name: benchmark-results + path: | + results.json + target/browserstack/ +``` + +## Error Handling + +### Build Timeout + +If the build exceeds the timeout, you'll see: + +``` +Warning: Failed to fetch benchmark results: Timeout waiting for build 88f8c5a... to complete (waited 600 seconds) +Build may still be accessible at: https://app-automate.browserstack.com/dashboard/v2/builds/88f8c5a... +``` + +The command will still succeed and write partial results. You can manually check the dashboard or use the separate `fetch` command: + +```bash +cargo mobench fetch \ + --target android \ + --build-id 88f8c5a3134562b8a92004582b757468ee10d08c \ + --output-dir target/browserstack +``` + +### Build Failed + +If the test fails on BrowserStack: + +``` +Warning: Failed to fetch benchmark results: Build 88f8c5a... failed with status: failed +``` + +Check the dashboard for error details. Common causes: +- App crashed during startup +- Test timed out +- Device disconnected + +### No Results Found + +If logs don't contain benchmark JSON: + +``` +Warning: Failed to fetch benchmark results: No benchmark results found in device logs +``` + +This means: +- Your app didn't log benchmark results +- Logs are in unexpected format +- Tests didn't actually run + +Verify your app logs JSON to stdout/logcat in the correct format. + +## Best Practices + +1. **Always use --fetch in CI** for automated pipelines +2. **Set reasonable timeouts** based on your benchmark duration +3. **Check exit codes** - command succeeds even if fetch warns +4. **Archive results** as CI artifacts for historical tracking +5. **Use GitHub Actions summaries** to display results inline + +## Comparison with Manual Workflow + +### Without --fetch +```bash +# 1. Run and schedule +cargo mobench run --target android --function my_func --devices "..." + +# 2. Wait manually +# (check dashboard periodically) + +# 3. Fetch later +cargo mobench fetch --target android --build-id + +# 4. Parse logs manually +cat target/browserstack/.../device-logs.txt | grep '{"function"' +``` + +### With --fetch +```bash +# One command does everything +cargo mobench run \ + --target android \ + --function my_func \ + --devices "..." \ + --fetch \ + --output results.json + +# Results already in results.json! +``` + +## See Also + +- `BROWSERSTACK_CI_INTEGRATION.md` - Programmatic API for custom workflows +- `BROWSERSTACK_RUN_2.md` - Example test run documentation +- `cargo mobench run --help` - Full CLI options diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..084d6e2 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2026 World Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 2092598..777b246 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -15,7 +15,7 @@ ## Architecture Outline -- `bench-cli`: Orchestrates builds, packaging, upload, AppAutomate sessions, and result collation. +- `mobench`: Orchestrates builds, packaging, upload, AppAutomate sessions, and result collation. - `bench-runner`: Minimal Rust harness compiled into mobile libs; exposes FFI entrypoints for target functions and collects timings. - Mobile bindings: - Android: Kotlin wrapper + APK test harness embedding Rust lib (cargo-ndk); uses Espresso/Appium-style entrypoints for AppAutomate. @@ -27,11 +27,11 @@ - Benchmark a single exported Rust function with configurable iterations. - Build Android APK + iOS app/xcframework locally and in CI. - Trigger one Android device run on BrowserStack and capture timing JSON. -- CLI command: `bench-cli run --target android --function path::to::fn --devices "pixel_7"` producing a report. +- CLI command: `mobench run --target android --function path::to::fn --devices "pixel_7"` producing a report. ## Task Backlog (initial) -- [ ] Repo bootstrap: Cargo workspace, `bench-cli` binary crate, `bench-runner` library crate, example `sample-fns` crate. +- [ ] Repo bootstrap: Cargo workspace, `mobench` binary crate, `bench-runner` library crate, example `sample-fns` crate. - [ ] Define FFI boundary: macro/attribute to mark benchmarkable Rust functions; export through C ABI; basic timing harness. - [ ] Android packaging: cargo-ndk config, Kotlin wrapper module, minimal test/activity to trigger Rust bench entrypoint. - [ ] iOS packaging: xcframework build script (cargo lipo or cargo-apple), C header generation (cbindgen), Swift wrapper, test host. diff --git a/README.md b/README.md index a795b06..ec6c3a7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,78 @@ -# mobile-bench-rs -Benchmarking tool for Rust functions on mobile devices using BrowserStack. +# mobile-bench-rs → mobench-sdk + +**Mobile benchmarking SDK for Rust** - Run Rust benchmarks on real Android and iOS devices. + +> **Phase 1 MVP Complete!** This project has been transformed into an importable library crate (`mobench-sdk`) that can be published to crates.io. + +## 🎯 For SDK Integrators + +**Importing mobench-sdk into your project?** You do **NOT** need the `scripts/` directory! + +- ✅ Use `cargo mobench build --target ` for all builds +- ✅ All build logic is in pure Rust (no shell scripts required) +- ✅ Templates are embedded in the binary +- ✅ See **[BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md)** for the integration guide + +**The `scripts/` directory** is legacy tooling for developing this repository. SDK users should ignore it. + +--- + +## 🚀 What's New in Phase 1 + +### Library-First Design + +Use `mobench-sdk` in any Rust project: + +```toml +[dependencies] +mobench-sdk = "0.1" +inventory = "0.3" # Required for registry +``` + +### #[benchmark] Macro + +Mark functions for benchmarking: + +```rust +use mobench_sdk::benchmark; + +#[benchmark] +fn my_expensive_operation() { + let result = compute_something(); + std::hint::black_box(result); +} +``` + +### New CLI Commands + +```bash +# Initialize SDK project +cargo mobench init-sdk --target android --project-name my-bench + +# Build mobile artifacts +cargo mobench build --target android + +# Package iOS app as IPA (for BrowserStack or physical devices) +cargo mobench package-ipa --method adhoc + +# List discovered benchmarks +cargo mobench list +``` + +### Architecture + +- **mobench-sdk**: Core library (registry, runner, builders, codegen) +- **mobench-macros**: `#[benchmark]` proc macro +- **mobench**: CLI tool for building, testing, and running benchmarks +- **examples/basic-benchmark**: Example using the new SDK + +--- + +## Original README (Legacy Information) ## Layout -- `crates/bench-cli`: CLI orchestrator for building/packaging benchmarks and driving BrowserStack runs (stubbed). + +- `crates/mobench`: CLI orchestrator for building/packaging benchmarks and driving BrowserStack runs. - `crates/bench-runner`: Shared harness that will be embedded in Android/iOS binaries; currently host-side only. - `crates/sample-fns`: Small Rust functions used as demo benchmarks with UniFFI bindings for mobile platforms. - `PROJECT_PLAN.md`: Goals, architecture outline, and initial task backlog. @@ -11,52 +81,98 @@ Benchmarking tool for Rust functions on mobile devices using BrowserStack. ## Quick Start ### Host Demo (No Mobile Build Required) + Test the benchmarking harness locally: + ```bash -cargo run -p bench-cli -- demo --iterations 10 --warmup 2 +cargo mobench demo --iterations 10 --warmup 2 ``` ### Mobile Testing + For complete end-to-end testing on Android/iOS, see the **[End-to-End Testing](#end-to-end-testing)** section below. **Quick commands:** + - **Android**: `scripts/build-android-app.sh` then install APK - **iOS**: `scripts/build-ios.sh` then open in Xcode ### Generate Config Files + ```bash -cargo run -p bench-cli -- init --output bench-config.toml -cargo run -p bench-cli -- plan --output device-matrix.yaml +cargo mobench init --output bench-config.toml +cargo mobench plan --output device-matrix.yaml ``` -## UniFFI Bindings +## UniFFI Bindings (Proc Macro Mode) + +This project uses [UniFFI](https://mozilla.github.io/uniffi-rs/) with **proc macros** to generate type-safe Kotlin and Swift bindings from Rust code. + +### Adding New FFI Types + +No UDL file needed! Just add proc macro attributes to your Rust types: + +```rust +#[derive(uniffi::Record)] +pub struct MyBenchmark { + pub name: String, + pub iterations: u32, +} -This project uses [UniFFI](https://mozilla.github.io/uniffi-rs/) to generate type-safe Kotlin and Swift bindings from Rust. +#[uniffi::export] +pub fn run_my_benchmark(spec: MyBenchmark) -> Result { + // Your implementation +} + +uniffi::setup_scaffolding!(); // Auto-uses crate name as namespace +``` + +### Regenerating Bindings + +After modifying FFI types in `crates/sample-fns/src/lib.rs`: -To regenerate bindings after modifying the API (`crates/sample-fns/src/sample_fns.udl`): ```bash -cargo run --bin generate-bindings --features bindgen +# Build the library first +cargo build -p sample-fns + +# Generate Kotlin + Swift bindings +./scripts/generate-bindings.sh ``` Generated files (committed to git for reproducibility): + - **Kotlin**: `android/app/src/main/java/uniffi/sample_fns/sample_fns.kt` - **Swift**: `ios/BenchRunner/BenchRunner/Generated/sample_fns.swift` - **C header**: `ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h` The UniFFI API exposes: + - `runBenchmark(spec: BenchSpec) -> BenchReport`: Run a benchmark by name - `BenchSpec(name, iterations, warmup)`: Benchmark configuration - `BenchReport`: Contains timing samples and statistics - `BenchError`: Type-safe error handling (InvalidIterations, UnknownFunction, ExecutionFailed) -## End-to-End Testing +## Testing Workflows -### Android Testing +mobile-bench-rs supports two testing workflows: + +1. **[Local Development](#local-development-workflow)**: Test on emulators/simulators or connected devices using Android Studio/Xcode +2. **[BrowserStack Testing](#browserstack-workflow)**: Test on real devices in the cloud using BrowserStack App Automate + +--- + +## Local Development Workflow + +Test your benchmarks locally using Android Studio or Xcode. This is the fastest way to iterate during development. + +### Android (Local) #### Quick Start (All-in-One) + ```bash # Build everything and create APK -scripts/build-android-app.sh +# Set UNIFFI_ANDROID_ABI for emulator ABI (x86_64 for default Android Studio emulators). +UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh # Install and launch on emulator/device adb install -r android/app/build/outputs/apk/debug/app-debug.apk @@ -64,22 +180,21 @@ adb shell am start -n dev.world.bench/.MainActivity ``` #### Step-by-Step -```bash -# 1. Build Rust libraries for all Android ABIs (arm64-v8a, armeabi-v7a, x86_64) -scripts/build-android.sh -# 2. Sync .so files into Android project structure (jniLibs) -scripts/sync-android-libs.sh +```bash +# 1. Build Rust libraries + regenerate bindings (ABI-aware) + sync jniLibs +UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh -# 3. Build the APK with Gradle +# 2. Build the APK with Gradle cd android && ./gradlew :app:assembleDebug -# 4. Install and launch +# 3. Install and launch adb install -r app/build/outputs/apk/debug/app-debug.apk adb shell am start -n dev.world.bench/.MainActivity ``` #### Testing with Custom Parameters + ```bash # Launch with custom benchmark function and parameters adb shell am start -n dev.world.bench/.MainActivity \ @@ -89,6 +204,7 @@ adb shell am start -n dev.world.bench/.MainActivity \ ``` #### Using Android Studio + 1. Open the `android/` directory in Android Studio 2. Ensure Rust libraries are built: `scripts/build-android.sh` 3. Sync libs: `scripts/sync-android-libs.sh` @@ -97,9 +213,10 @@ adb shell am start -n dev.world.bench/.MainActivity \ **Expected Output**: The app displays formatted benchmark results with individual sample timings and statistics (min/max/avg). -### iOS Testing +### iOS (Local) #### Prerequisites + ```bash # Install xcodegen if not already installed brew install xcodegen @@ -109,6 +226,7 @@ rustup target add aarch64-apple-ios aarch64-apple-ios-sim ``` #### Step-by-Step + ```bash # 1. Build Rust xcframework for iOS (includes UniFFI headers and automatic code signing) scripts/build-ios.sh @@ -129,6 +247,7 @@ open BenchRunner.xcodeproj ``` Then in Xcode: + 1. Select a simulator (e.g., iPhone 15) or connected device 2. Click Run (⌘R) or Product → Run 3. The app will display benchmark results @@ -138,6 +257,7 @@ Then in Xcode: #### Testing with Custom Parameters **Method 1: Edit Scheme (Xcode)** + 1. Product → Scheme → Edit Scheme... 2. Run → Arguments → Environment Variables 3. Add variables: @@ -147,6 +267,7 @@ Then in Xcode: 4. Run the app **Method 2: Command Line (simulator only)** + ```bash # Build and run with xcrun xcrun simctl launch booted dev.world.bench.BenchRunner \ @@ -157,44 +278,236 @@ xcrun simctl launch booted dev.world.bench.BenchRunner \ **Expected Output**: The app displays formatted benchmark results with individual sample timings and statistics (min/max/avg). -### Key Differences from Pre-UniFFI +### Key Features -The build process is **simpler** now: +The build process is **streamlined** with UniFFI proc macros: + +- ✅ No UDL file needed - proc macros define the FFI from Rust code - ✅ No need to run `cbindgen` manually - ✅ UniFFI headers (`sample_fnsFFI.h`) are automatically generated during `build-ios.sh` - ✅ Kotlin/Swift bindings are already committed to git -- ✅ Only regenerate bindings if you change `sample_fns.udl` (via `cargo run --bin generate-bindings --features bindgen`) -- ✅ Apps show formatted output with statistics instead of raw JSON +- ✅ Only regenerate bindings if you change FFI types in Rust (via `./scripts/generate-bindings.sh`) +- ✅ Apps show formatted output with statistics (min/max/avg in microseconds) - ✅ Type-safe error handling (no more string parsing) -### Requirements +--- + +## BrowserStack Workflow + +Test your benchmarks on real devices in the cloud using BrowserStack App Automate. This workflow uploads your app to BrowserStack, runs tests remotely, and downloads results. + +### Prerequisites + +1. **BrowserStack Account**: Sign up at [browserstack.com](https://www.browserstack.com/) +2. **Credentials**: Set environment variables: + ```bash + export BROWSERSTACK_USERNAME="your_username" + export BROWSERSTACK_ACCESS_KEY="your_access_key" + ``` +3. **Built Artifacts**: Build your app and test suite first (see below) + +### Android + BrowserStack (Espresso) + +#### Step 1: Build Artifacts + +```bash +# Build Android app APK and test suite +UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh + +# Build test APK (if needed) +cd android +./gradlew :app:assembleDebugAndroidTest +cd .. +``` + +Artifacts created: +- **App APK**: `android/app/build/outputs/apk/debug/app-debug.apk` +- **Test APK**: `android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk` + +#### Step 2: Run on BrowserStack + +```bash +# Run benchmark on specific device +cargo mobench run \ + --target android \ + --function sample_fns::fibonacci \ + --iterations 100 \ + --warmup 10 \ + --devices "Pixel 7-13" \ + --output run-summary.json +``` + +**What happens:** +1. CLI uploads APKs to BrowserStack +2. Schedules Espresso test run on specified device +3. Waits for completion +4. Downloads logs and results +5. Saves summary to `run-summary.json` + +#### Step 3: View Results + +```bash +# Results are in run-summary.json +cat run-summary.json + +# BrowserStack artifacts downloaded to: +# target/browserstack/{build_id}/session-{session_id}/ +``` + +**BrowserStack Dashboard**: View live test execution at https://app-automate.browserstack.com/dashboard + +### iOS + BrowserStack (XCUITest) + +#### Step 1: Build Artifacts + +```bash +# Build iOS app and xcframework +./scripts/build-ios.sh + +# Generate Xcode project +cd ios/BenchRunner +xcodegen generate + +# Build app for device (requires signing) +xcodebuild -project BenchRunner.xcodeproj \ + -scheme BenchRunner \ + -sdk iphoneos \ + -configuration Release \ + -derivedDataPath build \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO + +# Create IPA +mkdir -p Payload +cp -r build/Build/Products/Release-iphoneos/BenchRunner.app Payload/ +zip -r BenchRunner.ipa Payload/ +mv BenchRunner.ipa ../../target/ios/ + +# Build XCUITest runner +xcodebuild build-for-testing \ + -project BenchRunner.xcodeproj \ + -scheme BenchRunner \ + -sdk iphoneos \ + -derivedDataPath build + +# Package test runner +cd build/Build/Products/Release-iphoneos +zip -r BenchRunnerUITests-Runner.zip BenchRunnerUITests-Runner.app +mv BenchRunnerUITests-Runner.zip ../../../../target/ios/ +cd ../../../.. +``` + +Artifacts created: +- **App IPA**: `target/ios/BenchRunner.ipa` +- **Test Suite**: `target/ios/BenchRunnerUITests-Runner.zip` + +#### Step 2: Run on BrowserStack + +```bash +# Run benchmark on specific device +cargo mobench run \ + --target ios \ + --function sample_fns::fibonacci \ + --iterations 100 \ + --warmup 10 \ + --devices "iPhone 14-16" \ + --ios-app target/ios/BenchRunner.ipa \ + --ios-test-suite target/ios/BenchRunnerUITests-Runner.zip \ + --output run-summary.json +``` + +**What happens:** +1. CLI uploads IPA and test suite to BrowserStack +2. Schedules XCUITest run on specified device +3. Waits for completion +4. Downloads logs and results +5. Saves summary to `run-summary.json` + +### Using Config Files (Recommended) + +For repeated runs, use config files: + +```bash +# Generate templates +cargo mobench init --output bench-config.toml --target android +cargo mobench plan --output device-matrix.yaml +``` + +**bench-config.toml:** +```toml +target = "android" +function = "sample_fns::fibonacci" +iterations = 100 +warmup = 10 +device_matrix = "device-matrix.yaml" + +[browserstack] +app_automate_username = "${BROWSERSTACK_USERNAME}" +app_automate_access_key = "${BROWSERSTACK_ACCESS_KEY}" +project = "mobile-bench-rs" +``` + +**device-matrix.yaml:** +```yaml +devices: + - name: Pixel 7 + os: android + os_version: "13.0" + tags: [default, pixel] + - name: Samsung Galaxy S23 + os: android + os_version: "13.0" + tags: [samsung] +``` + +**Run with config:** +```bash +cargo mobench run --config bench-config.toml +``` + +### BrowserStack Features + +- **Device Logs**: Automatically downloaded to `target/browserstack/{build_id}/session-{session_id}/` +- **Screenshots/Video**: Available in BrowserStack dashboard +- **Parallel Testing**: Specify multiple devices to run in parallel +- **Network Conditions**: Configure via BrowserStack dashboard +- **Real Devices**: Tests run on actual hardware, not emulators + +--- -**Android:** -- Android SDK/NDK (API level 24+) +## Requirements + +### Android + +- Android Studio (SDK + NDK manager): https://developer.android.com/studio +- Android NDK (API level 24+): https://developer.android.com/ndk/downloads - `ANDROID_NDK_HOME` environment variable set -- `cargo-ndk` installed: `cargo install cargo-ndk` -- Android emulator or physical device +- `cargo-ndk` installed: `cargo install cargo-ndk` (https://github.com/bbqsrc/cargo-ndk) +- JDK 17+ (for Gradle; any distribution): https://openjdk.org/install/ + - Note: Android Gradle Plugin (AGP) officially supports Java 17. +- For local testing: Android emulator or physical device +- For BrowserStack: BrowserStack account and credentials + +### iOS + +- macOS with Xcode command-line tools: https://developer.apple.com/xcode/ +- Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim` (https://doc.rust-lang.org/rustup/targets.html) +- `xcodegen` installed (optional): https://github.com/yonaskolb/XcodeGen +- For local testing: iOS Simulator or physical device (requires code signing) +- For BrowserStack: BrowserStack account and credentials -**iOS:** -- macOS with Xcode command-line tools -- Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim` -- `xcodegen` installed: `brew install xcodegen` -- iOS Simulator or physical device (requires code signing) +--- ## Additional Documentation - **`BUILD.md`**: Complete build reference guide for Android and iOS (prerequisites, step-by-step instructions, troubleshooting) - **`TESTING.md`**: Comprehensive testing guide with troubleshooting and advanced scenarios - **`PROJECT_PLAN.md`**: Project goals, architecture, and task backlog -- **`CLAUDE.md`**: Developer guide for working with this codebase (for Claude Code and developers) - -## BrowserStack XCUITest (iOS) -- Provide signed artifacts for BrowserStack real devices: the app bundle (`.ipa` or zipped `.app`) and the XCUITest runner package (`.zip` or `.ipa` containing the test bundle). -- CLI flags (when not using a config file): `--ios-app` and `--ios-test-suite` must both be set whenever `--target ios` is paired with `--devices`. -- Config block example: - ```toml - [ios_xcuitest] - app = "target/ios/BenchRunner.ipa" - test_suite = "target/ios/BenchRunnerUITests.zip" - ``` -- Example run (requires BrowserStack credentials in env vars): `cargo run -p bench-cli -- run --target ios --function sample_fns::checksum --iterations 10 --warmup 2 --devices "iPhone 14-16" --ios-app target/ios/BenchRunner.ipa --ios-test-suite target/ios/BenchRunnerUITests.zip` +- **`CLAUDE.md`**: Developer guide for this codebase + +--- + +## License + +MIT OR Apache-2.0 diff --git a/TESTING.md b/TESTING.md index d93629f..0f18d5b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2,6 +2,11 @@ This document provides comprehensive testing instructions for mobile-bench-rs. +> **For SDK Integrators**: If you're importing `mobench-sdk` into your project, use: +> - `cargo mobench build --target ` for builds +> - Scripts shown below are legacy tooling for this repository +> - See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide + > **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see **[BUILD.md](BUILD.md)**. This document focuses on testing scenarios and troubleshooting. ## Table of Contents @@ -17,18 +22,25 @@ This document provides comprehensive testing instructions for mobile-bench-rs. ```bash # Install Rust if not already installed curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +# https://www.rust-lang.org/tools/install # Install required targets rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android rustup target add aarch64-apple-ios aarch64-apple-ios-sim +# https://doc.rust-lang.org/rustup/targets.html # Install cargo-ndk for Android builds cargo install cargo-ndk +# https://github.com/bbqsrc/cargo-ndk ``` ### Android ```bash # Install Android SDK and NDK (via Android Studio or command line) +# Android Studio: https://developer.android.com/studio +# Android NDK: https://developer.android.com/ndk/downloads +# JDK 17+ (for Gradle; any distribution): https://openjdk.org/install/ +# Note: Android Gradle Plugin (AGP) officially supports Java 17. # Set environment variable (add to ~/.zshrc or ~/.bashrc) export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/29.0.14206865 @@ -39,11 +51,14 @@ ls $ANDROID_NDK_HOME ### iOS (macOS only) ```bash # Install Xcode from App Store +# https://developer.apple.com/xcode/ # Install command-line tools xcode-select --install # Install xcodegen brew install xcodegen +# https://github.com/yonaskolb/XcodeGen +brew install xcodegen ``` ## Host Testing @@ -59,7 +74,7 @@ Expected output: All tests pass (11 tests total as of UniFFI migration). ### CLI Demo Test the benchmarking harness without mobile builds: ```bash -cargo run -p bench-cli -- demo --iterations 10 --warmup 2 +cargo mobench demo --iterations 10 --warmup 2 ``` Expected output: JSON report with timing samples for `fibonacci` function. @@ -67,7 +82,7 @@ Expected output: JSON report with timing samples for `fibonacci` function. ### Testing Different Functions ```bash # Test fibonacci (default) -cargo run -p bench-cli -- demo --iterations 5 --warmup 1 +cargo mobench demo --iterations 5 --warmup 1 # Currently supports: # - fibonacci / fib / sample_fns::fibonacci @@ -80,7 +95,8 @@ cargo run -p bench-cli -- demo --iterations 5 --warmup 1 ```bash # Build everything and create APK -scripts/build-android-app.sh +# For Android Studio emulators, use UNIFFI_ANDROID_ABI=x86_64 +UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh # Install on connected device/emulator adb install -r android/app/build/outputs/apk/debug/app-debug.apk @@ -92,20 +108,10 @@ adb shell am start -n dev.world.bench/.MainActivity ### Method 2: Step-by-Step Build ```bash -# Step 1: Build Rust libraries for Android -scripts/build-android.sh - -# This compiles for three ABIs: -# - aarch64-linux-android (arm64-v8a) -# - armv7-linux-androideabi (armeabi-v7a) -# - x86_64-linux-android (x86_64, for emulator) +# Step 1: Build Rust libraries + bindings (ABI-aware) +UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh -# Step 2: Copy .so files to Android project -scripts/sync-android-libs.sh - -# This copies from target/android/{abi}/release/ to android/app/src/main/jniLibs/{abi}/ - -# Step 3: Build APK +# Step 2: Build APK cd android ./gradlew :app:assembleDebug cd .. @@ -436,16 +442,17 @@ codesign --force --deep --sign - target/ios/sample_fns.xcframework - Trust developer certificate on device: Settings → General → VPN & Device Management - The xcframework must be signed: `codesign --force --deep --sign - target/ios/sample_fns.xcframework` -### UniFFI Bindings +### UniFFI Bindings (Proc Macros) -**Problem**: Changes to `sample_fns.udl` not reflected in mobile apps +**Problem**: Changes to FFI types in `crates/sample-fns/src/lib.rs` not reflected in mobile apps ```bash -# Solution: Regenerate bindings -cargo run --bin generate-bindings --features bindgen +# Solution: Rebuild library and regenerate bindings +cargo build -p sample-fns +./scripts/generate-bindings.sh # Then rebuild mobile apps -scripts/build-android-app.sh # For Android -scripts/build-ios.sh # For iOS +UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh # For Android +scripts/build-ios.sh # For iOS (includes signing) ``` **Problem**: "error: cannot find type `BenchSpec` in the crate root" @@ -464,7 +471,7 @@ cargo test --all # Common causes: # - Missing serde dependency (check Cargo.toml) -# - API signature changes (update UDL and regenerate bindings) +# - API signature changes (update FFI types with proc macros and regenerate bindings) # - Test assertions need updating ``` @@ -485,7 +492,7 @@ See the main [README.md](README.md) for BrowserStack testing instructions. Compare benchmark results across builds: ```bash # Run benchmark and save results -cargo run -p bench-cli -- run \ +cargo mobench run \ --target android \ --function sample_fns::fibonacci \ --iterations 100 \ @@ -493,7 +500,7 @@ cargo run -p bench-cli -- run \ --output results-v1.json # After changes, run again -cargo run -p bench-cli -- run \ +cargo mobench run \ --target android \ --function sample_fns::fibonacci \ --iterations 100 \ @@ -561,4 +568,4 @@ To trigger manually: - [Android NDK Documentation](https://developer.android.com/ndk) - [Rust Cross-Compilation Guide](https://rust-lang.github.io/rustup/cross-compilation.html) - [PROJECT_PLAN.md](PROJECT_PLAN.md) - Roadmap and architecture -- [CLAUDE.md](CLAUDE.md) - Developer guide for working with this codebase +- [CLAUDE.md](CLAUDE.md) - Developer guide for this codebase diff --git a/android/app/src/main/java/dev/world/bench/MainActivity.kt b/android/app/src/main/java/dev/world/bench/MainActivity.kt index fd40708..cf46069 100644 --- a/android/app/src/main/java/dev/world/bench/MainActivity.kt +++ b/android/app/src/main/java/dev/world/bench/MainActivity.kt @@ -25,7 +25,7 @@ class MainActivity : AppCompatActivity() { private const val SPEC_ASSET = "bench_spec.json" init { - System.loadLibrary("uniffi_sample_fns") + System.loadLibrary("sample_fns") } } diff --git a/android/app/src/main/java/uniffi/sample_fns/sample_fns.kt b/android/app/src/main/java/uniffi/sample_fns/sample_fns.kt index a9e9050..57a3ced 100644 --- a/android/app/src/main/java/uniffi/sample_fns/sample_fns.kt +++ b/android/app/src/main/java/uniffi/sample_fns/sample_fns.kt @@ -374,7 +374,7 @@ private fun findLibraryName(componentName: String): String { if (libOverride != null) { return libOverride } - return "uniffi_sample_fns" + return "sample_fns" } private inline fun loadIndirect( @@ -861,7 +861,7 @@ private fun uniffiCheckContractApiVersion(lib: UniffiLib) { @Suppress("UNUSED_PARAMETER") private fun uniffiCheckApiChecksums(lib: UniffiLib) { - if (lib.uniffi_sample_fns_checksum_func_run_benchmark() != 35019.toShort()) { + if (lib.uniffi_sample_fns_checksum_func_run_benchmark() != 38523.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } } @@ -1016,6 +1016,9 @@ public object FfiConverterString: FfiConverter { +/** + * Complete benchmark report with spec and timing samples. + */ data class BenchReport ( var `spec`: BenchSpec, var `samples`: List @@ -1048,6 +1051,9 @@ public object FfiConverterTypeBenchReport: FfiConverterRustBuffer { +/** + * A single benchmark sample with timing information. + */ data class BenchSample ( var `durationNs`: kotlin.ULong ) { @@ -1076,6 +1082,9 @@ public object FfiConverterTypeBenchSample: FfiConverterRustBuffer { +/** + * Specification for a benchmark run. + */ data class BenchSpec ( var `name`: kotlin.String, var `iterations`: kotlin.UInt, @@ -1114,6 +1123,9 @@ public object FfiConverterTypeBenchSpec: FfiConverterRustBuffer { +/** + * Error types for benchmark operations. + */ sealed class BenchException(message: String): kotlin.Exception(message) { class InvalidIterations(message: String) : BenchException(message) @@ -1193,6 +1205,9 @@ public object FfiConverterSequenceTypeBenchSample: FfiConverterRustBuffer diff --git a/crates/mobench-macros/Cargo.toml b/crates/mobench-macros/Cargo.toml new file mode 100644 index 0000000..f857b09 --- /dev/null +++ b/crates/mobench-macros/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "mobench-macros" +version.workspace = true +edition.workspace = true +license.workspace = true +authors = ["Dominik Clemente - dcbuilder.eth "] +description = "Proc macros for bench-sdk - #[benchmark] attribute" +repository = "https://github.com/worldcoin/mobile-bench-rs" +readme = "README.md" +keywords = ["benchmark", "mobile", "macro", "procedural"] + +[lib] +proc-macro = true + +[dependencies] +syn.workspace = true +quote.workspace = true +proc-macro2.workspace = true diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md new file mode 100644 index 0000000..1927f1c --- /dev/null +++ b/crates/mobench-macros/README.md @@ -0,0 +1,194 @@ +# mobench-macros + +Procedural macros for the [mobench](https://crates.io/crates/mobench) mobile benchmarking framework. + +This crate provides the `#[benchmark]` attribute macro that automatically registers functions for mobile benchmarking. It uses compile-time registration via the `inventory` crate to build a registry of benchmark functions. + +## Features + +- **`#[benchmark]` attribute**: Mark functions as benchmarks +- **Automatic registration**: No manual registry maintenance required +- **Type safety**: Compile-time validation of benchmark functions +- **Zero runtime overhead**: Registration happens at compile time + +## Usage + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +mobench-macros = "0.1" +mobench-sdk = "0.1" # For the runtime +``` + +### Basic Example + +```rust +use mobench_macros::benchmark; + +#[benchmark] +fn fibonacci_benchmark() { + let result = fibonacci(30); + std::hint::black_box(result); +} + +#[benchmark] +fn sorting_benchmark() { + let mut data = vec![5, 2, 8, 1, 9]; + data.sort(); + std::hint::black_box(data); +} + +fn fibonacci(n: u32) -> u64 { + match n { + 0 => 0, + 1 => 1, + _ => fibonacci(n - 1) + fibonacci(n - 2), + } +} +``` + +### With mobench-sdk + +The macros work seamlessly with mobench-sdk: + +```rust +use mobench_macros::benchmark; +use mobench_sdk::{run_benchmark, BenchSpec}; + +#[benchmark] +fn my_expensive_operation() { + // Your benchmark code +} + +fn main() -> Result<(), Box> { + // Run the benchmark + let spec = BenchSpec::new("my_expensive_operation", 100, 10)?; + let report = run_benchmark(spec)?; + + println!("Mean: {} ns", report.mean_ns()); + Ok(()) +} +``` + +## How It Works + +The `#[benchmark]` macro: + +1. **Preserves your function**: The original function remains unchanged +2. **Generates registration code**: Creates an `inventory::submit!` call +3. **Wraps in closure**: Converts your function into a callable closure +4. **Registers at compile time**: Adds to the global benchmark registry + +### Macro Expansion + +When you write: + +```rust +#[benchmark] +fn my_benchmark() { + expensive_operation(); +} +``` + +The macro expands to something like: + +```rust +fn my_benchmark() { + expensive_operation(); +} + +inventory::submit! { + BenchFunction { + name: "my_benchmark", + invoke: |_args| { + my_benchmark(); + Ok(()) + } + } +} +``` + +## Requirements + +- Functions must be regular functions (not async) +- Functions should not take parameters +- Functions should use `std::hint::black_box()` to prevent optimization of results + +## Best Practices + +### Prevent Compiler Optimization + +Always use `black_box` for benchmark results: + +```rust +use mobench_macros::benchmark; + +#[benchmark] +fn good_benchmark() { + let result = expensive_computation(); + std::hint::black_box(result); // ✓ Prevents optimization +} + +#[benchmark] +fn bad_benchmark() { + let result = expensive_computation(); // ✗ May be optimized away +} +``` + +### Benchmark Naming + +Use descriptive names that indicate what's being measured: + +```rust +#[benchmark] +fn hash_1kb_data() { /* ... */ } + +#[benchmark] +fn parse_json_small() { /* ... */ } + +#[benchmark] +fn encrypt_aes_256() { /* ... */ } +``` + +### Isolate Benchmarks + +Keep benchmarks focused on one operation: + +```rust +// Good: Measures one thing +#[benchmark] +fn sha256_hash() { + let hash = sha256(&DATA); + std::hint::black_box(hash); +} + +// Bad: Measures multiple things +#[benchmark] +fn hash_and_encode() { + let hash = sha256(&DATA); + let encoded = base64_encode(hash); + std::hint::black_box(encoded); +} +``` + +## Part of mobench + +This crate is part of the mobench ecosystem for mobile benchmarking: + +- **[mobench](https://crates.io/crates/mobench)** - CLI tool for running benchmarks +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - SDK for integrating benchmarks +- **[mobench-macros](https://crates.io/crates/mobench-macros)** - This crate (proc macros) +- **[mobench-runner](https://crates.io/crates/mobench-runner)** - Timing harness + +## See Also + +- [mobench-sdk](https://crates.io/crates/mobench-sdk) for the complete SDK +- [mobench](https://crates.io/crates/mobench) for the CLI tool +- [inventory](https://crates.io/crates/inventory) for the registration mechanism + +## License + +Licensed under the MIT License. See [LICENSE.md](../../LICENSE.md) for details. + +Copyright (c) 2026 World Foundation diff --git a/crates/mobench-macros/src/lib.rs b/crates/mobench-macros/src/lib.rs new file mode 100644 index 0000000..8096414 --- /dev/null +++ b/crates/mobench-macros/src/lib.rs @@ -0,0 +1,65 @@ +//! Procedural macros for bench-sdk +//! +//! This crate provides the `#[benchmark]` attribute macro for marking functions +//! as benchmarkable. Functions marked with this attribute are automatically +//! registered and can be discovered at runtime. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{ItemFn, parse_macro_input}; + +/// Marks a function as a benchmark. +/// +/// This macro registers the function in the global benchmark registry and +/// makes it available for execution via the bench-sdk runtime. +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::benchmark; +/// +/// #[benchmark] +/// fn fibonacci_bench() { +/// let result = fibonacci(30); +/// std::hint::black_box(result); +/// } +/// ``` +/// +/// The macro preserves the original function and creates a registration entry +/// that allows the benchmark to be discovered and invoked by name. +#[proc_macro_attribute] +pub fn benchmark(_attr: TokenStream, item: TokenStream) -> TokenStream { + let input_fn = parse_macro_input!(item as ItemFn); + + let fn_name = &input_fn.sig.ident; + let fn_name_str = fn_name.to_string(); + let vis = &input_fn.vis; + let sig = &input_fn.sig; + let block = &input_fn.block; + let attrs = &input_fn.attrs; + + // Get the module path for fully-qualified name + // Note: This will generate the fully-qualified name at compile time + let module_path = quote! { module_path!() }; + + let expanded = quote! { + // Preserve the original function + #(#attrs)* + #vis #sig { + #block + } + + // Register the function with inventory + ::inventory::submit! { + ::mobench_sdk::registry::BenchFunction { + name: ::std::concat!(#module_path, "::", #fn_name_str), + invoke: |_args| { + #fn_name(); + Ok(()) + }, + } + } + }; + + TokenStream::from(expanded) +} diff --git a/crates/mobench-runner/Cargo.toml b/crates/mobench-runner/Cargo.toml new file mode 100644 index 0000000..ccac3bd --- /dev/null +++ b/crates/mobench-runner/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "mobench-runner" +version.workspace = true +edition.workspace = true +license.workspace = true +authors = ["Dominik Clemente - dcbuilder.eth "] +description = "Lightweight benchmarking harness for mobile devices" +repository = "https://github.com/worldcoin/mobile-bench-rs" +readme = "README.md" +keywords = ["benchmark", "mobile", "timing", "profiling"] + +[dependencies] +serde.workspace = true +thiserror.workspace = true diff --git a/crates/mobench-runner/README.md b/crates/mobench-runner/README.md new file mode 100644 index 0000000..64ee97a --- /dev/null +++ b/crates/mobench-runner/README.md @@ -0,0 +1,131 @@ +# mobench-runner + +Lightweight benchmarking harness for mobile devices. + +This crate provides the core timing infrastructure for running benchmarks on mobile platforms (Android and iOS). It's designed to be embedded in mobile apps and provides accurate timing measurements with configurable iterations and warmup cycles. + +## Features + +- **Accurate timing**: High-precision timing measurements for benchmarks +- **Configurable**: Set iterations and warmup cycles +- **Mobile-optimized**: Designed for resource-constrained mobile environments +- **Serializable results**: Results can be serialized with serde for transmission to host +- **No dependencies**: Minimal dependencies for fast compilation + +## Usage + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +mobench-runner = "0.1" +``` + +### Basic Example + +```rust +use mobench_runner::{BenchSpec, run_closure}; + +// Create a benchmark specification +let spec = BenchSpec { + name: "my_benchmark".to_string(), + iterations: 100, + warmup: 10, +}; + +// Run a closure as a benchmark +let report = run_closure(spec, || { + // Your benchmark code here + let result = expensive_computation(); + std::hint::black_box(result); // Prevent optimization + Ok(()) +})?; + +// Access timing results +println!("Mean: {} ns", report.mean_ns()); +println!("Median: {} ns", report.median_ns()); +println!("Min: {} ns", report.min_ns()); +println!("Max: {} ns", report.max_ns()); +``` + +### With Error Handling + +```rust +use mobench_runner::{BenchSpec, BenchError, run_closure}; + +fn my_benchmark() -> Result<(), String> { + // Your code that might fail + Ok(()) +} + +let spec = BenchSpec::new("my_benchmark", 50, 5)?; + +let report = run_closure(spec, || { + my_benchmark().map_err(|e| BenchError::Execution(e)) +})?; +``` + +## Types + +### `BenchSpec` + +Specification for a benchmark run: + +```rust +pub struct BenchSpec { + pub name: String, // Benchmark name + pub iterations: u32, // Number of iterations to run + pub warmup: u32, // Number of warmup iterations +} +``` + +### `BenchReport` + +Results from a benchmark run: + +```rust +pub struct BenchReport { + pub spec: BenchSpec, + pub samples: Vec, +} +``` + +Provides helper methods: +- `mean_ns()` - Mean execution time +- `median_ns()` - Median execution time +- `min_ns()` - Minimum execution time +- `max_ns()` - Maximum execution time +- `stddev_ns()` - Standard deviation + +### `BenchSample` + +Individual timing sample: + +```rust +pub struct BenchSample { + pub duration_ns: u64, // Duration in nanoseconds +} +``` + +## Use Cases + +This crate is typically used as a dependency in larger benchmarking systems: + +1. **Mobile benchmark harness**: Embed in mobile apps to run benchmarks on real devices +2. **Cross-platform timing**: Consistent timing API across platforms +3. **Remote benchmarking**: Serialize results and send to analysis tools + +## Part of mobench + +This crate is part of the [mobench](https://crates.io/crates/mobench) ecosystem for mobile benchmarking: + +- **[mobench](https://crates.io/crates/mobench)** - CLI tool for running benchmarks +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - SDK for integrating benchmarks +- **[mobench-macros](https://crates.io/crates/mobench-macros)** - Proc macros for `#[benchmark]` +- **[mobench-runner](https://crates.io/crates/mobench-runner)** - This crate (timing harness) + +## License + +Licensed under the MIT License. See [LICENSE.md](../../LICENSE.md) for details. + +Copyright (c) 2026 World Foundation diff --git a/crates/mobench-runner/src/lib.rs b/crates/mobench-runner/src/lib.rs new file mode 100644 index 0000000..915109b --- /dev/null +++ b/crates/mobench-runner/src/lib.rs @@ -0,0 +1,92 @@ +//! Shared benchmarking harness that will be compiled into mobile targets. +//! For now this runs on the host and provides the same API surface we will +//! expose over FFI to Kotlin/Swift. + +use serde::{Deserialize, Serialize}; +use std::time::{Duration, Instant}; +use thiserror::Error; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} + +impl BenchSpec { + pub fn new(name: impl Into, iterations: u32, warmup: u32) -> Result { + if iterations == 0 { + return Err(BenchError::NoIterations); + } + + Ok(Self { + name: name.into(), + iterations, + warmup, + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BenchSample { + pub duration_ns: u64, +} + +impl BenchSample { + fn from_duration(duration: Duration) -> Self { + Self { + duration_ns: duration.as_nanos() as u64, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BenchReport { + pub spec: BenchSpec, + pub samples: Vec, +} + +#[derive(Debug, Error)] +pub enum BenchError { + #[error("iterations must be greater than zero")] + NoIterations, + #[error("benchmark function failed: {0}")] + Execution(String), +} + +pub fn run_closure(spec: BenchSpec, mut f: F) -> Result +where + F: FnMut() -> Result<(), BenchError>, +{ + if spec.iterations == 0 { + return Err(BenchError::NoIterations); + } + + for _ in 0..spec.warmup { + f()?; + } + + let mut samples = Vec::with_capacity(spec.iterations as usize); + for _ in 0..spec.iterations { + let start = Instant::now(); + f()?; + samples.push(BenchSample::from_duration(start.elapsed())); + } + + Ok(BenchReport { spec, samples }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runs_benchmark() { + let spec = BenchSpec::new("noop", 3, 1).unwrap(); + let report = run_closure(spec, || Ok(())).unwrap(); + + assert_eq!(report.samples.len(), 3); + let non_zero = report.samples.iter().filter(|s| s.duration_ns > 0).count(); + assert!(non_zero >= 1); + } +} diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml new file mode 100644 index 0000000..9b55601 --- /dev/null +++ b/crates/mobench-sdk/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "mobench-sdk" +version.workspace = true +edition.workspace = true +license.workspace = true +authors = ["Dominik Clemente - dcbuilder.eth "] +description = "Mobile benchmarking SDK for Rust - run benchmarks on real devices" +repository = "https://github.com/worldcoin/mobile-bench-rs" +readme = "README.md" +keywords = ["benchmark", "mobile", "android", "ios", "performance"] +categories = ["development-tools::profiling", "development-tools::testing"] +include = [ + "src/**/*", + "templates/**/*", + "Cargo.toml", + "README.md" +] + +[lib] +crate-type = ["lib"] + +[dependencies] +# Core dependencies +mobench-runner = { version = "0.1", path = "../mobench-runner" } +mobench-macros = { version = "0.1", path = "../mobench-macros" } + +# Registry +inventory.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# Error handling +thiserror.workspace = true +anyhow.workspace = true + +# Template embedding (Phase 1) +include_dir.workspace = true + +# Build automation (Phase 1) +toml.workspace = true + +[dev-dependencies] +# Test dependencies will be added as needed diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md new file mode 100644 index 0000000..67efba4 --- /dev/null +++ b/crates/mobench-sdk/README.md @@ -0,0 +1,402 @@ +# mobench-sdk + +Mobile benchmarking SDK for Rust - run benchmarks on real Android and iOS devices. + +Transform your Rust project into a mobile benchmarking suite. This SDK provides everything you need to benchmark your Rust code on real mobile devices via BrowserStack or local emulators/simulators. + +## Features + +- **`#[benchmark]` macro**: Mark functions for mobile benchmarking +- **Automatic registry**: Compile-time function discovery +- **Mobile app generation**: Create Android/iOS apps from templates +- **Build automation**: Cross-compile and package for mobile platforms +- **Statistical analysis**: Mean, median, stddev, percentiles +- **BrowserStack integration**: Test on real devices in the cloud +- **UniFFI bindings**: Automatic FFI generation for mobile platforms + +## Quick Start + +Add mobench-sdk to your project: + +```toml +[dependencies] +mobench-sdk = "0.1" +``` + +Mark functions to benchmark: + +```rust +use mobench_sdk::benchmark; + +#[benchmark] +fn fibonacci() { + let result = fib(30); + std::hint::black_box(result); +} + +#[benchmark] +fn json_parsing() { + let data = serde_json::from_str::(JSON_DATA).unwrap(); + std::hint::black_box(data); +} + +fn fib(n: u32) -> u64 { + match n { + 0 => 0, + 1 => 1, + _ => fib(n - 1) + fib(n - 2), + } +} +``` + +Run programmatically: + +```rust +use mobench_sdk::{run_benchmark, BenchSpec}; + +fn main() -> Result<(), Box> { + let spec = BenchSpec::new("fibonacci", 100, 10)?; + let report = run_benchmark(spec)?; + + println!("Mean: {} ns", report.mean_ns()); + println!("Median: {} ns", report.median_ns()); + println!("Std dev: {} ns", report.stddev_ns()); + + Ok(()) +} +``` + +## Project Setup + +### 1. Initialize Mobile Benchmarking + +Use the [mobench CLI](https://crates.io/crates/mobench) to scaffold your project: + +```bash +cargo install mobench +cargo mobench init --target android # or ios, or both +``` + +This creates: +- `bench-mobile/` - FFI wrapper crate +- `android/` or `ios/` - Mobile app projects +- `bench-sdk.toml` - Configuration file + +### 2. Add Benchmarks + +```rust +use mobench_sdk::benchmark; + +#[benchmark] +fn my_benchmark() { + // Your code here +} +``` + +### 3. Build for Mobile + +```bash +cargo mobench build --target android +``` + +### 4. Run on Devices + +Local emulator: +```bash +cargo mobench run my_benchmark --local-only +``` + +BrowserStack: +```bash +export BROWSERSTACK_USERNAME=your_username +export BROWSERSTACK_ACCESS_KEY=your_key + +cargo mobench run my_benchmark --devices "Pixel 7-13,iPhone 14-16" +``` + +## API Documentation + +### Core Functions + +#### `run_benchmark` + +Run a registered benchmark by name: + +```rust +use mobench_sdk::{run_benchmark, BenchSpec}; + +let spec = BenchSpec::new("my_function", 50, 5)?; +let report = run_benchmark(spec)?; +``` + +#### `BenchmarkBuilder` + +Fluent API for building and running benchmarks: + +```rust +use mobench_sdk::BenchmarkBuilder; + +let report = BenchmarkBuilder::new("my_function") + .iterations(100) + .warmup(10) + .run()?; +``` + +### Types + +#### `BenchSpec` + +Benchmark specification: + +```rust +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} +``` + +#### `RunnerReport` + +Benchmark results with statistical analysis: + +```rust +pub struct RunnerReport { + pub spec: BenchSpec, + pub samples: Vec, +} + +impl RunnerReport { + pub fn mean_ns(&self) -> f64; + pub fn median_ns(&self) -> u64; + pub fn min_ns(&self) -> u64; + pub fn max_ns(&self) -> u64; + pub fn stddev_ns(&self) -> f64; + pub fn percentile(&self, p: f64) -> u64; +} +``` + +### Build API + +#### Generate Mobile Projects + +```rust +use mobench_sdk::{InitConfig, Target, generate_project}; + +let config = InitConfig { + project_name: "my-benchmarks".to_string(), + output_dir: PathBuf::from("./bench-output"), + target: Target::Both, // Android + iOS + generate_examples: true, +}; + +let project_path = generate_project(&config)?; +``` + +#### Build for Android + +```rust +use mobench_sdk::AndroidBuilder; + +let builder = AndroidBuilder::new(PathBuf::from("."), "debug")?; +let apk = builder.build_apk()?; +println!("Built APK: {:?}", apk); +``` + +#### Build for iOS + +```rust +use mobench_sdk::IosBuilder; + +let builder = IosBuilder::new(PathBuf::from("."), "release")?; +let xcframework = builder.build_xcframework()?; +println!("Built xcframework: {:?}", xcframework); +``` + +## Examples + +### Crypto Benchmarks + +```rust +use mobench_sdk::benchmark; +use sha2::{Sha256, Digest}; +use aes::Aes256; + +#[benchmark] +fn sha256_1kb() { + let data = vec![0u8; 1024]; + let hash = Sha256::digest(&data); + std::hint::black_box(hash); +} + +#[benchmark] +fn aes256_encrypt() { + let key = [0u8; 32]; + let cipher = Aes256::new(&key.into()); + // ... encryption code + std::hint::black_box(cipher); +} +``` + +### JSON Parsing Benchmarks + +```rust +use mobench_sdk::benchmark; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +struct User { + name: String, + email: String, + age: u32, +} + +const JSON_DATA: &str = r#"{"name":"Alice","email":"alice@example.com","age":30}"#; + +#[benchmark] +fn parse_json_small() { + let user: User = serde_json::from_str(JSON_DATA).unwrap(); + std::hint::black_box(user); +} + +#[benchmark] +fn serialize_json_small() { + let user = User { + name: "Alice".to_string(), + email: "alice@example.com".to_string(), + age: 30, + }; + let json = serde_json::to_string(&user).unwrap(); + std::hint::black_box(json); +} +``` + +### Data Structure Benchmarks + +```rust +use mobench_sdk::benchmark; +use std::collections::{HashMap, BTreeMap}; + +#[benchmark] +fn hashmap_insert_1000() { + let mut map = HashMap::new(); + for i in 0..1000 { + map.insert(i, i * 2); + } + std::hint::black_box(map); +} + +#[benchmark] +fn btreemap_insert_1000() { + let mut map = BTreeMap::new(); + for i in 0..1000 { + map.insert(i, i * 2); + } + std::hint::black_box(map); +} +``` + +## Architecture + +### Workflow + +1. **Development**: Write benchmarks with `#[benchmark]` +2. **Compilation**: Benchmarks registered at compile time via `inventory` +3. **FFI Generation**: UniFFI creates type-safe Kotlin/Swift bindings +4. **Mobile Build**: Cross-compile to mobile platforms +5. **Execution**: Run on real devices or emulators +6. **Analysis**: Collect and analyze timing data + +### Components + +``` +┌─────────────────────────────────────────┐ +│ Your Rust Code + #[benchmark] │ +└──────────────┬──────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────┐ +│ mobench-sdk (Registry + Build Tools) │ +└──────────────┬──────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────┐ +│ UniFFI (FFI Bindings Generation) │ +└──────────────┬──────────────────────────┘ + │ + ┌───────┴───────┐ + ↓ ↓ +┌─────────────┐ ┌─────────────┐ +│ Android APK │ │ iOS IPA │ +└──────┬──────┘ └──────┬──────┘ + │ │ + └───────┬───────┘ + ↓ + ┌──────────────────────┐ + │ Real Mobile Devices │ + │ (BrowserStack/Local) │ + └──────────────────────┘ +``` + +## Configuration + +### `bench-sdk.toml` + +```toml +[project] +name = "my-benchmarks" +target = "both" # android, ios, or both + +[build] +profile = "release" # or "debug" + +[browserstack] +username = "${BROWSERSTACK_USERNAME}" +access_key = "${BROWSERSTACK_ACCESS_KEY}" +project = "my-project-benchmarks" + +[[devices]] +name = "Pixel 7" +os = "android" +os_version = "13.0" + +[[devices]] +name = "iPhone 14" +os = "ios" +os_version = "16" +``` + +## Requirements + +### For Android + +- Android NDK +- `cargo-ndk`: `cargo install cargo-ndk` +- Android SDK (API level 24+) + +### For iOS + +- macOS with Xcode +- Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim` +- `xcodegen`: `brew install xcodegen` + +## Part of mobench + +This is the core SDK of the mobench ecosystem: + +- **[mobench](https://crates.io/crates/mobench)** - CLI tool (recommended for most users) +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - This crate (SDK library) +- **[mobench-macros](https://crates.io/crates/mobench-macros)** - Proc macros +- **[mobench-runner](https://crates.io/crates/mobench-runner)** - Timing harness + +## See Also + +- [CLI Documentation](https://crates.io/crates/mobench) for command-line usage +- [UniFFI Documentation](https://mozilla.github.io/uniffi-rs/) for FFI details +- [BrowserStack App Automate](https://www.browserstack.com/app-automate) for device testing + +## License + +Licensed under the MIT License. See [LICENSE.md](../../LICENSE.md) for details. + +Copyright (c) 2026 World Foundation diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs new file mode 100644 index 0000000..ed6dd10 --- /dev/null +++ b/crates/mobench-sdk/src/builders/android.rs @@ -0,0 +1,458 @@ +//! Android build automation +//! +//! This module provides functionality to build Rust libraries for Android and +//! package them into an APK using Gradle. + +use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; +use std::env; +use std::path::PathBuf; +use std::process::Command; + +/// Android builder that handles the complete build pipeline +pub struct AndroidBuilder { + /// Root directory of the project + project_root: PathBuf, + /// Name of the bench-mobile crate + crate_name: String, + /// Whether to use verbose output + verbose: bool, +} + +impl AndroidBuilder { + /// Creates a new Android builder + /// + /// # Arguments + /// + /// * `project_root` - Root directory containing the bench-mobile crate + /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile") + pub fn new(project_root: impl Into, crate_name: impl Into) -> Self { + Self { + project_root: project_root.into(), + crate_name: crate_name.into(), + verbose: false, + } + } + + /// Enables verbose output + pub fn verbose(mut self, verbose: bool) -> Self { + self.verbose = verbose; + self + } + + /// Builds the Android app with the given configuration + /// + /// This performs the following steps: + /// 1. Build Rust libraries for Android ABIs using cargo-ndk + /// 2. Generate UniFFI Kotlin bindings + /// 3. Copy .so files to jniLibs directories + /// 4. Run Gradle to build the APK + /// + /// # Returns + /// + /// * `Ok(BuildResult)` containing the path to the built APK + /// * `Err(BenchError)` if the build fails + pub fn build(&self, config: &BuildConfig) -> Result { + // Step 1: Build Rust libraries + println!("Building Rust libraries for Android..."); + self.build_rust_libraries(config)?; + + // Step 2: Generate UniFFI bindings + println!("Generating UniFFI Kotlin bindings..."); + self.generate_uniffi_bindings()?; + + // Step 3: Copy .so files to jniLibs + println!("Copying native libraries to jniLibs..."); + self.copy_native_libraries(config)?; + + // Step 4: Build APK with Gradle + println!("Building Android APK with Gradle..."); + let apk_path = self.build_apk(config)?; + + // Step 5: Build Android test APK for BrowserStack + println!("Building Android test APK..."); + let test_suite_path = self.build_test_apk(config)?; + + Ok(BuildResult { + platform: Target::Android, + app_path: apk_path, + test_suite_path: Some(test_suite_path), + }) + } + + /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) + fn find_crate_dir(&self) -> Result { + // Try bench-mobile/ first (SDK projects) + let bench_mobile_dir = self.project_root.join("bench-mobile"); + if bench_mobile_dir.exists() { + return Ok(bench_mobile_dir); + } + + // Try crates/{crate_name}/ (repository structure) + let crates_dir = self.project_root.join("crates").join(&self.crate_name); + if crates_dir.exists() { + return Ok(crates_dir); + } + + Err(BenchError::Build(format!( + "Benchmark crate '{}' not found. Tried:\n - {:?}\n - {:?}", + self.crate_name, bench_mobile_dir, crates_dir + ))) + } + + /// Builds Rust libraries for Android using cargo-ndk + fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> { + let crate_dir = self.find_crate_dir()?; + + // Check if cargo-ndk is installed + self.check_cargo_ndk()?; + + // Android ABIs to build for + let abis = vec!["arm64-v8a", "armeabi-v7a", "x86_64"]; + + for abi in abis { + if self.verbose { + println!(" Building for {}", abi); + } + + let mut cmd = Command::new("cargo"); + cmd.arg("ndk") + .arg("--target") + .arg(abi) + .arg("--platform") + .arg("24") // minSdk + .arg("build"); + + // Add release flag if needed + if matches!(config.profile, BuildProfile::Release) { + cmd.arg("--release"); + } + + // Set working directory + cmd.current_dir(&crate_dir); + + // Execute build + let output = cmd + .output() + .map_err(|e| BenchError::Build(format!("Failed to run cargo-ndk: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "cargo-ndk build failed for {}: {}", + abi, stderr + ))); + } + } + + Ok(()) + } + + /// Checks if cargo-ndk is installed + fn check_cargo_ndk(&self) -> Result<(), BenchError> { + let output = Command::new("cargo").arg("ndk").arg("--version").output(); + + match output { + Ok(output) if output.status.success() => Ok(()), + _ => Err(BenchError::Build( + "cargo-ndk is not installed. Install it with: cargo install cargo-ndk".to_string(), + )), + } + } + + /// Generates UniFFI Kotlin bindings + fn generate_uniffi_bindings(&self) -> Result<(), BenchError> { + let crate_dir = self.find_crate_dir()?; + let crate_name_underscored = self.crate_name.replace("-", "_"); + + // Check if bindings already exist (for repository testing with pre-generated bindings) + let bindings_path = self + .project_root + .join("android") + .join("app") + .join("src") + .join("main") + .join("java") + .join("uniffi") + .join(&crate_name_underscored) + .join(format!("{}.kt", crate_name_underscored)); + + if bindings_path.exists() { + if self.verbose { + println!(" Using existing Kotlin bindings at {:?}", bindings_path); + } + return Ok(()); + } + + // Check if uniffi-bindgen is available + let uniffi_available = Command::new("uniffi-bindgen") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !uniffi_available { + return Err(BenchError::Build( + "uniffi-bindgen not found and no pre-generated bindings exist.\n\ + Install it with: cargo install uniffi-bindgen\n\ + Or use pre-generated bindings by copying them to the expected location." + .to_string(), + )); + } + + // Build host library to feed uniffi-bindgen + let mut build_cmd = Command::new("cargo"); + build_cmd.arg("build"); + build_cmd.current_dir(&crate_dir); + run_command(build_cmd, "cargo build (host)")?; + + let lib_path = host_lib_path(&crate_dir, &self.crate_name)?; + let out_dir = self + .project_root + .join("android") + .join("app") + .join("src") + .join("main") + .join("java"); + + let mut cmd = Command::new("uniffi-bindgen"); + cmd.arg("generate") + .arg("--library") + .arg(&lib_path) + .arg("--language") + .arg("kotlin") + .arg("--out-dir") + .arg(&out_dir); + run_command(cmd, "uniffi-bindgen kotlin")?; + + if self.verbose { + println!(" Generated UniFFI Kotlin bindings at {:?}", out_dir); + } + Ok(()) + } + + /// Copies .so files to Android jniLibs directories + fn copy_native_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> { + let profile_dir = match config.profile { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + }; + + let target_dir = self.project_root.join("target"); + let jni_libs_dir = self.project_root.join("android/app/src/main/jniLibs"); + + // Create jniLibs directories if they don't exist + std::fs::create_dir_all(&jni_libs_dir) + .map_err(|e| BenchError::Build(format!("Failed to create jniLibs directory: {}", e)))?; + + // Map cargo-ndk ABIs to Android jniLibs ABIs + let abi_mappings = vec![ + ("aarch64-linux-android", "arm64-v8a"), + ("armv7-linux-androideabi", "armeabi-v7a"), + ("x86_64-linux-android", "x86_64"), + ]; + + for (rust_target, android_abi) in abi_mappings { + let src = target_dir + .join(rust_target) + .join(profile_dir) + .join(format!("lib{}.so", self.crate_name.replace("-", "_"))); + + let dest_dir = jni_libs_dir.join(android_abi); + std::fs::create_dir_all(&dest_dir).map_err(|e| { + BenchError::Build(format!("Failed to create {} directory: {}", android_abi, e)) + })?; + + let dest = dest_dir.join(format!("lib{}.so", self.crate_name.replace("-", "_"))); + + if src.exists() { + std::fs::copy(&src, &dest).map_err(|e| { + BenchError::Build(format!("Failed to copy {} library: {}", android_abi, e)) + })?; + + if self.verbose { + println!(" Copied {} -> {}", src.display(), dest.display()); + } + } else if self.verbose { + println!(" Warning: {} not found, skipping", src.display()); + } + } + + Ok(()) + } + + /// Builds the Android APK using Gradle + fn build_apk(&self, config: &BuildConfig) -> Result { + let android_dir = self.project_root.join("android"); + + if !android_dir.exists() { + return Err(BenchError::Build(format!( + "Android project not found at {:?}", + android_dir + ))); + } + + // Determine Gradle task + let gradle_task = match config.profile { + BuildProfile::Debug => "assembleDebug", + BuildProfile::Release => "assembleRelease", + }; + + // Run Gradle build + let mut cmd = Command::new("./gradlew"); + cmd.arg(gradle_task).current_dir(&android_dir); + + if self.verbose { + cmd.arg("--info"); + } + + let output = cmd + .output() + .map_err(|e| BenchError::Build(format!("Failed to run Gradle: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "Gradle build failed: {}", + stderr + ))); + } + + // Determine APK path + let profile_name = match config.profile { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + }; + + let apk_path = android_dir + .join("app/build/outputs/apk") + .join(profile_name) + .join(format!("app-{}.apk", profile_name)); + + if !apk_path.exists() { + return Err(BenchError::Build(format!( + "APK not found at expected location: {:?}", + apk_path + ))); + } + + Ok(apk_path) + } + + /// Builds the Android test APK using Gradle + fn build_test_apk(&self, config: &BuildConfig) -> Result { + let android_dir = self.project_root.join("android"); + + if !android_dir.exists() { + return Err(BenchError::Build(format!( + "Android project not found at {:?}", + android_dir + ))); + } + + let gradle_task = match config.profile { + BuildProfile::Debug => "assembleDebugAndroidTest", + BuildProfile::Release => "assembleReleaseAndroidTest", + }; + + let mut cmd = Command::new("./gradlew"); + cmd.arg(gradle_task).current_dir(&android_dir); + + if self.verbose { + cmd.arg("--info"); + } + + let output = cmd + .output() + .map_err(|e| BenchError::Build(format!("Failed to run Gradle: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "Gradle test APK build failed: {}", + stderr + ))); + } + + let profile_name = match config.profile { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + }; + + let apk_path = android_dir + .join("app/build/outputs/apk/androidTest") + .join(profile_name) + .join(format!("app-{}-androidTest.apk", profile_name)); + + if !apk_path.exists() { + return Err(BenchError::Build(format!( + "Android test APK not found at expected location: {:?}", + apk_path + ))); + } + + Ok(apk_path) + } +} + +// Shared helpers +fn host_lib_path(project_dir: &PathBuf, crate_name: &str) -> Result { + let lib_prefix = if cfg!(target_os = "windows") { + "" + } else { + "lib" + }; + let lib_ext = match env::consts::OS { + "macos" => "dylib", + "linux" => "so", + other => { + return Err(BenchError::Build(format!( + "unsupported host OS for binding generation: {}", + other + ))); + } + }; + let path = project_dir.join("target").join("debug").join(format!( + "{}{}.{}", + lib_prefix, + crate_name.replace('-', "_"), + lib_ext + )); + if !path.exists() { + return Err(BenchError::Build(format!( + "host library for UniFFI not found at {:?}", + path + ))); + } + Ok(path) +} + +fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> { + let output = cmd + .output() + .map_err(|e| BenchError::Build(format!("Failed to run {}: {}", description, e)))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "{} failed: {}", + description, stderr + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_android_builder_creation() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + assert!(!builder.verbose); + } + + #[test] + fn test_android_builder_verbose() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true); + assert!(builder.verbose); + } +} diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs new file mode 100644 index 0000000..368921c --- /dev/null +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -0,0 +1,868 @@ +//! iOS build automation +//! +//! This module provides functionality to build Rust libraries for iOS and +//! create an xcframework that can be used in Xcode projects. + +use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// iOS builder that handles the complete build pipeline +pub struct IosBuilder { + /// Root directory of the project + project_root: PathBuf, + /// Name of the bench-mobile crate + crate_name: String, + /// Whether to use verbose output + verbose: bool, +} + +impl IosBuilder { + /// Creates a new iOS builder + /// + /// # Arguments + /// + /// * `project_root` - Root directory containing the bench-mobile crate + /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile") + pub fn new(project_root: impl Into, crate_name: impl Into) -> Self { + Self { + project_root: project_root.into(), + crate_name: crate_name.into(), + verbose: false, + } + } + + /// Enables verbose output + pub fn verbose(mut self, verbose: bool) -> Self { + self.verbose = verbose; + self + } + + /// Builds the iOS app with the given configuration + /// + /// This performs the following steps: + /// 1. Build Rust libraries for iOS targets (device + simulator) + /// 2. Generate UniFFI Swift bindings and C headers + /// 3. Create xcframework with proper structure + /// 4. Code-sign the xcframework + /// 5. Generate Xcode project with xcodegen (if project.yml exists) + /// + /// # Returns + /// + /// * `Ok(BuildResult)` containing the path to the xcframework + /// * `Err(BenchError)` if the build fails + pub fn build(&self, config: &BuildConfig) -> Result { + let framework_name = self.crate_name.replace("-", "_"); + // Step 1: Build Rust libraries + println!("Building Rust libraries for iOS..."); + self.build_rust_libraries(config)?; + + // Step 2: Generate UniFFI bindings + println!("Generating UniFFI Swift bindings..."); + self.generate_uniffi_bindings()?; + + // Step 3: Create xcframework + println!("Creating xcframework..."); + let xcframework_path = self.create_xcframework(config)?; + + // Step 4: Code-sign xcframework + println!("Code-signing xcframework..."); + self.codesign_xcframework(&xcframework_path)?; + + // Copy header to include/ for consumers (handy for CLI uploads) + let header_src = self + .find_uniffi_header(&format!("{}FFI.h", framework_name)) + .ok_or_else(|| { + BenchError::Build(format!( + "UniFFI header {}FFI.h not found after generation", + framework_name + )) + })?; + let include_dir = self.project_root.join("target/ios/include"); + fs::create_dir_all(&include_dir) + .map_err(|e| BenchError::Build(format!("Failed to create include dir: {}", e)))?; + let header_dest = include_dir.join(format!("{}.h", framework_name)); + fs::copy(&header_src, &header_dest).map_err(|e| { + BenchError::Build(format!( + "Failed to copy UniFFI header to {:?}: {}", + header_dest, e + )) + })?; + + // Step 5: Generate Xcode project if needed + self.generate_xcode_project()?; + + Ok(BuildResult { + platform: Target::Ios, + app_path: xcframework_path, + test_suite_path: None, + }) + } + + /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) + fn find_crate_dir(&self) -> Result { + // Try bench-mobile/ first (SDK projects) + let bench_mobile_dir = self.project_root.join("bench-mobile"); + if bench_mobile_dir.exists() { + return Ok(bench_mobile_dir); + } + + // Try crates/{crate_name}/ (repository structure) + let crates_dir = self.project_root.join("crates").join(&self.crate_name); + if crates_dir.exists() { + return Ok(crates_dir); + } + + Err(BenchError::Build(format!( + "Benchmark crate '{}' not found. Tried:\n - {:?}\n - {:?}", + self.crate_name, bench_mobile_dir, crates_dir + ))) + } + + /// Builds Rust libraries for iOS targets + fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> { + let crate_dir = self.find_crate_dir()?; + + // iOS targets: device and simulator + let targets = vec![ + "aarch64-apple-ios", // Device (ARM64) + "aarch64-apple-ios-sim", // Simulator (M1+ Macs) + ]; + + // Check if targets are installed + self.check_rust_targets(&targets)?; + + for target in targets { + if self.verbose { + println!(" Building for {}", target); + } + + let mut cmd = Command::new("cargo"); + cmd.arg("build").arg("--target").arg(target).arg("--lib"); + + // Add release flag if needed + if matches!(config.profile, BuildProfile::Release) { + cmd.arg("--release"); + } + + // Set working directory + cmd.current_dir(&crate_dir); + + // Execute build + let output = cmd + .output() + .map_err(|e| BenchError::Build(format!("Failed to run cargo: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "cargo build failed for {}: {}", + target, stderr + ))); + } + } + + Ok(()) + } + + /// Checks if required Rust targets are installed + fn check_rust_targets(&self, targets: &[&str]) -> Result<(), BenchError> { + let output = Command::new("rustup") + .arg("target") + .arg("list") + .arg("--installed") + .output() + .map_err(|e| BenchError::Build(format!("Failed to check rustup targets: {}", e)))?; + + let installed = String::from_utf8_lossy(&output.stdout); + + for target in targets { + if !installed.contains(target) { + return Err(BenchError::Build(format!( + "Rust target {} is not installed. Install it with: rustup target add {}", + target, target + ))); + } + } + + Ok(()) + } + + /// Generates UniFFI Swift bindings + fn generate_uniffi_bindings(&self) -> Result<(), BenchError> { + let crate_dir = self.find_crate_dir()?; + let crate_name_underscored = self.crate_name.replace("-", "_"); + + // Check if bindings already exist (for repository testing with pre-generated bindings) + let bindings_path = self + .project_root + .join("ios") + .join("BenchRunner") + .join("BenchRunner") + .join("Generated") + .join(format!("{}.swift", crate_name_underscored)); + + if bindings_path.exists() { + if self.verbose { + println!(" Using existing Swift bindings at {:?}", bindings_path); + } + return Ok(()); + } + + // Check if uniffi-bindgen is available + let uniffi_available = Command::new("uniffi-bindgen") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !uniffi_available { + return Err(BenchError::Build( + "uniffi-bindgen not found and no pre-generated bindings exist.\n\ + Install it with: cargo install uniffi-bindgen\n\ + Or use pre-generated bindings by copying them to the expected location." + .to_string(), + )); + } + + // Build host library to feed uniffi-bindgen + let mut build_cmd = Command::new("cargo"); + build_cmd.arg("build"); + build_cmd.current_dir(&crate_dir); + run_command(build_cmd, "cargo build (host)")?; + + let lib_path = host_lib_path(&crate_dir, &self.crate_name)?; + let out_dir = self + .project_root + .join("ios") + .join("BenchRunner") + .join("BenchRunner") + .join("Generated"); + fs::create_dir_all(&out_dir).map_err(|e| { + BenchError::Build(format!("Failed to create Swift bindings dir: {}", e)) + })?; + + let mut cmd = Command::new("uniffi-bindgen"); + cmd.arg("generate") + .arg("--library") + .arg(&lib_path) + .arg("--language") + .arg("swift") + .arg("--out-dir") + .arg(&out_dir); + run_command(cmd, "uniffi-bindgen swift")?; + + if self.verbose { + println!(" Generated UniFFI Swift bindings at {:?}", out_dir); + } + + Ok(()) + } + + /// Creates an xcframework from the built libraries + fn create_xcframework(&self, config: &BuildConfig) -> Result { + let profile_dir = match config.profile { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + }; + + let target_dir = self.project_root.join("target"); + let xcframework_dir = target_dir.join("ios"); + let framework_name = &self.crate_name.replace("-", "_"); + let xcframework_path = xcframework_dir.join(format!("{}.xcframework", framework_name)); + + // Remove existing xcframework if it exists + if xcframework_path.exists() { + fs::remove_dir_all(&xcframework_path).map_err(|e| { + BenchError::Build(format!("Failed to remove old xcframework: {}", e)) + })?; + } + + // Create xcframework directory + fs::create_dir_all(&xcframework_dir).map_err(|e| { + BenchError::Build(format!("Failed to create xcframework directory: {}", e)) + })?; + + // Build framework structure for each platform + self.create_framework_slice( + &target_dir.join("aarch64-apple-ios").join(profile_dir), + &xcframework_path.join("ios-arm64"), + framework_name, + "ios", + )?; + + self.create_framework_slice( + &target_dir.join("aarch64-apple-ios-sim").join(profile_dir), + &xcframework_path.join("ios-simulator-arm64"), + framework_name, + "ios-simulator", + )?; + + // Create xcframework Info.plist + self.create_xcframework_plist(&xcframework_path, framework_name)?; + + Ok(xcframework_path) + } + + /// Creates a framework slice for a specific platform + fn create_framework_slice( + &self, + lib_path: &Path, + output_dir: &Path, + framework_name: &str, + platform: &str, + ) -> Result<(), BenchError> { + let framework_dir = output_dir.join(format!("{}.framework", framework_name)); + let headers_dir = framework_dir.join("Headers"); + + // Create directories + fs::create_dir_all(&headers_dir).map_err(|e| { + BenchError::Build(format!("Failed to create framework directories: {}", e)) + })?; + + // Copy static library + let src_lib = lib_path.join(format!("lib{}.a", framework_name)); + let dest_lib = framework_dir.join(framework_name); + + if !src_lib.exists() { + return Err(BenchError::Build(format!( + "Static library not found: {:?}", + src_lib + ))); + } + + fs::copy(&src_lib, &dest_lib) + .map_err(|e| BenchError::Build(format!("Failed to copy static library: {}", e)))?; + + // Copy UniFFI-generated header into the framework + let header_name = format!("{}FFI.h", framework_name); + let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| { + BenchError::Build(format!( + "UniFFI header {} not found; run binding generation before building", + header_name + )) + })?; + fs::copy(&header_path, headers_dir.join(&header_name)).map_err(|e| { + BenchError::Build(format!( + "Failed to copy UniFFI header from {:?}: {}", + header_path, e + )) + })?; + + // Create module.modulemap + let modulemap_content = format!( + "framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}", + framework_name, framework_name + ); + fs::write(headers_dir.join("module.modulemap"), modulemap_content) + .map_err(|e| BenchError::Build(format!("Failed to write module.modulemap: {}", e)))?; + + // Create framework Info.plist + self.create_framework_plist(&framework_dir, framework_name, platform)?; + + Ok(()) + } + + /// Creates Info.plist for a framework slice + fn create_framework_plist( + &self, + framework_dir: &Path, + framework_name: &str, + platform: &str, + ) -> Result<(), BenchError> { + let plist_content = format!( + r#" + + + + CFBundleExecutable + {} + CFBundleIdentifier + dev.world.{} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + {} + CFBundlePackageType + FMWK + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + CFBundleSupportedPlatforms + + {} + + +"#, + framework_name, + framework_name, + framework_name, + if platform == "ios" { + "iPhoneOS" + } else { + "iPhoneSimulator" + } + ); + + fs::write(framework_dir.join("Info.plist"), plist_content).map_err(|e| { + BenchError::Build(format!("Failed to write framework Info.plist: {}", e)) + })?; + + Ok(()) + } + + /// Creates xcframework Info.plist + fn create_xcframework_plist( + &self, + xcframework_path: &Path, + framework_name: &str, + ) -> Result<(), BenchError> { + let plist_content = format!( + r#" + + + + AvailableLibraries + + + LibraryIdentifier + ios-arm64 + LibraryPath + {}.framework + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + + + LibraryIdentifier + ios-simulator-arm64 + LibraryPath + {}.framework + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + +"#, + framework_name, framework_name + ); + + fs::write(xcframework_path.join("Info.plist"), plist_content).map_err(|e| { + BenchError::Build(format!("Failed to write xcframework Info.plist: {}", e)) + })?; + + Ok(()) + } + + /// Code-signs the xcframework + fn codesign_xcframework(&self, xcframework_path: &Path) -> Result<(), BenchError> { + let output = Command::new("codesign") + .arg("--force") + .arg("--deep") + .arg("--sign") + .arg("-") + .arg(xcframework_path) + .output(); + + match output { + Ok(output) if output.status.success() => { + if self.verbose { + println!(" Successfully code-signed xcframework"); + } + Ok(()) + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("Warning: Code signing failed: {}", stderr); + println!("You may need to manually sign the xcframework"); + Ok(()) // Don't fail the build for signing issues + } + Err(e) => { + println!("Warning: Could not run codesign: {}", e); + println!("You may need to manually sign the xcframework"); + Ok(()) // Don't fail the build if codesign is not available + } + } + } + + /// Generates Xcode project using xcodegen if project.yml exists + fn generate_xcode_project(&self) -> Result<(), BenchError> { + let ios_dir = self.project_root.join("ios"); + let project_yml = ios_dir.join("BenchRunner/project.yml"); + + if !project_yml.exists() { + if self.verbose { + println!(" No project.yml found, skipping xcodegen"); + } + return Ok(()); + } + + if self.verbose { + println!(" Generating Xcode project with xcodegen"); + } + + let output = Command::new("xcodegen") + .arg("generate") + .current_dir(ios_dir.join("BenchRunner")) + .output(); + + match output { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(BenchError::Build(format!("xcodegen failed: {}", stderr))) + } + Err(e) => { + println!("Warning: xcodegen not found or failed: {}", e); + println!("Install xcodegen with: brew install xcodegen"); + Ok(()) // Don't fail if xcodegen is not available + } + } + } + + /// Locate the generated UniFFI header for the crate + fn find_uniffi_header(&self, header_name: &str) -> Option { + // Check generated Swift bindings directory first + let swift_dir = self + .project_root + .join("ios/BenchRunner/BenchRunner/Generated"); + let candidate_swift = swift_dir.join(header_name); + if candidate_swift.exists() { + return Some(candidate_swift); + } + + let target_dir = self.project_root.join("target"); + // Common UniFFI output location when using uniffi::generate_scaffolding + let candidate = target_dir.join("uniffi").join(header_name); + if candidate.exists() { + return Some(candidate); + } + + // Fallback: walk the target directory for the header + let mut stack = vec![target_dir]; + while let Some(dir) = stack.pop() { + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + // Limit depth by skipping non-target subtrees such as incremental caches + if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && name == "incremental" + { + continue; + } + stack.push(path); + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && name == header_name + { + return Some(path); + } + } + } + } + + None + } +} + +// Shared helpers (duplicated with android builder) +fn host_lib_path(project_dir: &PathBuf, crate_name: &str) -> Result { + let lib_prefix = if cfg!(target_os = "windows") { + "" + } else { + "lib" + }; + let lib_ext = match env::consts::OS { + "macos" => "dylib", + "linux" => "so", + other => { + return Err(BenchError::Build(format!( + "unsupported host OS for binding generation: {}", + other + ))); + } + }; + let path = project_dir.join("target").join("debug").join(format!( + "{}{}.{}", + lib_prefix, + crate_name.replace('-', "_"), + lib_ext + )); + if !path.exists() { + return Err(BenchError::Build(format!( + "host library for UniFFI not found at {:?}", + path + ))); + } + Ok(path) +} + +fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> { + let output = cmd + .output() + .map_err(|e| BenchError::Build(format!("Failed to run {}: {}", description, e)))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "{} failed: {}", + description, stderr + ))); + } + Ok(()) +} + +/// iOS code signing methods for IPA packaging +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigningMethod { + /// Ad-hoc signing (no Apple ID required, works for BrowserStack testing) + AdHoc, + /// Development signing (requires Apple Developer account and provisioning profile) + Development, +} + +impl IosBuilder { + /// Packages the iOS app as an IPA file for distribution or testing + /// + /// This requires the app to have been built first with `build()`. + /// The IPA can be used for: + /// - BrowserStack device testing (ad-hoc signing) + /// - Physical device testing (development signing) + /// + /// # Arguments + /// + /// * `scheme` - The Xcode scheme to build (e.g., "BenchRunner") + /// * `method` - The signing method (AdHoc or Development) + /// + /// # Returns + /// + /// * `Ok(PathBuf)` - Path to the generated IPA file + /// * `Err(BenchError)` - If the build or packaging fails + /// + /// # Example + /// + /// ```no_run + /// use mobench_sdk::builders::{IosBuilder, SigningMethod}; + /// + /// let builder = IosBuilder::new(".", "bench-mobile"); + /// let ipa_path = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?; + /// println!("IPA created at: {:?}", ipa_path); + /// # Ok::<(), mobench_sdk::BenchError>(()) + /// ``` + pub fn package_ipa(&self, scheme: &str, method: SigningMethod) -> Result { + // For repository structure: ios/BenchRunner/BenchRunner.xcodeproj + // The directory and scheme happen to have the same name + let ios_dir = self.project_root.join("ios").join(scheme); + let project_path = ios_dir.join(format!("{}.xcodeproj", scheme)); + + // Verify Xcode project exists + if !project_path.exists() { + return Err(BenchError::Build(format!( + "Xcode project not found at {:?}. Run `cargo mobench build --target ios` first.", + project_path + ))); + } + + let export_path = self.project_root.join("target/ios"); + let ipa_path = export_path.join(format!("{}.ipa", scheme)); + + // Create target/ios directory if it doesn't exist + fs::create_dir_all(&export_path) + .map_err(|e| BenchError::Build(format!("Failed to create export directory: {}", e)))?; + + println!("Building {} for device...", scheme); + + // Step 1: Build the app for device (simpler than archiving) + let build_dir = self.project_root.join("target/ios/build"); + let mut cmd = Command::new("xcodebuild"); + cmd.args(&[ + "-project", + project_path.to_str().unwrap(), + "-scheme", + scheme, + "-destination", + "generic/platform=iOS", + "-configuration", + "Release", + "-derivedDataPath", + build_dir.to_str().unwrap(), + "build", + ]); + + // Add signing parameters based on method + match method { + SigningMethod::AdHoc => { + // Ad-hoc signing (works for BrowserStack, no Apple ID needed) + // For ad-hoc, we disable signing during build and sign manually after + cmd.args(&["CODE_SIGNING_REQUIRED=NO", "CODE_SIGNING_ALLOWED=NO"]); + } + SigningMethod::Development => { + // Development signing (requires Apple Developer account) + cmd.args(&[ + "CODE_SIGN_STYLE=Automatic", + "CODE_SIGN_IDENTITY=iPhone Developer", + ]); + } + } + + if self.verbose { + println!(" Running: {:?}", cmd); + } + + // Run the build - may fail on validation but still produce the .app + let build_result = cmd.output(); + + // Step 2: Check if the .app bundle was created (even if validation failed) + let app_path = build_dir + .join("Build/Products/Release-iphoneos") + .join(format!("{}.app", scheme)); + + if !app_path.exists() { + // Only fail if the .app wasn't created + if let Ok(output) = build_result { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "xcodebuild build failed and app bundle not found: {}", + stderr + ))); + } else { + return Err(BenchError::Build(format!( + "App bundle not found at {:?}. Build may have failed.", + app_path + ))); + } + } + + if self.verbose { + println!(" App bundle created successfully at {:?}", app_path); + } + + if matches!(method, SigningMethod::AdHoc) { + let output = Command::new("codesign") + .arg("--force") + .arg("--deep") + .arg("--sign") + .arg("-") + .arg(&app_path) + .output(); + + match output { + Ok(output) if output.status.success() => { + if self.verbose { + println!(" Signed app bundle with ad-hoc identity"); + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("Warning: Ad-hoc signing failed: {}", stderr); + } + Err(err) => { + println!("Warning: Could not run codesign: {}", err); + } + } + } + + println!("Creating IPA from app bundle..."); + + // Step 3: Create IPA (which is just a zip of Payload/{app}) + let payload_dir = export_path.join("Payload"); + if payload_dir.exists() { + fs::remove_dir_all(&payload_dir).map_err(|e| { + BenchError::Build(format!("Failed to remove old Payload dir: {}", e)) + })?; + } + fs::create_dir_all(&payload_dir) + .map_err(|e| BenchError::Build(format!("Failed to create Payload dir: {}", e)))?; + + // Copy app bundle into Payload/ + let dest_app = payload_dir.join(format!("{}.app", scheme)); + self.copy_dir_recursive(&app_path, &dest_app)?; + + // Create zip archive + if ipa_path.exists() { + fs::remove_file(&ipa_path) + .map_err(|e| BenchError::Build(format!("Failed to remove old IPA: {}", e)))?; + } + + let mut cmd = Command::new("zip"); + cmd.args(&["-qr", ipa_path.to_str().unwrap(), "Payload"]) + .current_dir(&export_path); + + if self.verbose { + println!(" Running: {:?}", cmd); + } + + run_command(cmd, "zip IPA")?; + + // Clean up Payload directory + fs::remove_dir_all(&payload_dir) + .map_err(|e| BenchError::Build(format!("Failed to clean up Payload dir: {}", e)))?; + + println!("✓ IPA created: {:?}", ipa_path); + Ok(ipa_path) + } + + /// Recursively copies a directory + fn copy_dir_recursive(&self, src: &Path, dest: &Path) -> Result<(), BenchError> { + fs::create_dir_all(dest).map_err(|e| { + BenchError::Build(format!("Failed to create directory {:?}: {}", dest, e)) + })?; + + for entry in fs::read_dir(src) + .map_err(|e| BenchError::Build(format!("Failed to read directory {:?}: {}", src, e)))? + { + let entry = + entry.map_err(|e| BenchError::Build(format!("Failed to read entry: {}", e)))?; + let path = entry.path(); + let file_name = path + .file_name() + .ok_or_else(|| BenchError::Build(format!("Invalid file name in {:?}", path)))?; + let dest_path = dest.join(file_name); + + if path.is_dir() { + self.copy_dir_recursive(&path, &dest_path)?; + } else { + fs::copy(&path, &dest_path).map_err(|e| { + BenchError::Build(format!( + "Failed to copy {:?} to {:?}: {}", + path, dest_path, e + )) + })?; + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ios_builder_creation() { + let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile"); + assert!(!builder.verbose); + } + + #[test] + fn test_ios_builder_verbose() { + let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true); + assert!(builder.verbose); + } +} diff --git a/crates/mobench-sdk/src/builders/mod.rs b/crates/mobench-sdk/src/builders/mod.rs new file mode 100644 index 0000000..73acac9 --- /dev/null +++ b/crates/mobench-sdk/src/builders/mod.rs @@ -0,0 +1,11 @@ +//! Build automation for mobile platforms +//! +//! This module provides builders for Android and iOS that automate the process +//! of compiling Rust code to mobile libraries and packaging them into mobile apps. + +pub mod android; +pub mod ios; + +// Re-export builders +pub use android::AndroidBuilder; +pub use ios::{IosBuilder, SigningMethod}; diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs new file mode 100644 index 0000000..0de4071 --- /dev/null +++ b/crates/mobench-sdk/src/codegen.rs @@ -0,0 +1,501 @@ +//! Code generation and template management +//! +//! This module provides functionality for generating mobile app projects from +//! embedded templates. It handles template parameterization and file generation. + +use crate::types::{BenchError, InitConfig, Target}; +use std::fs; +use std::path::{Path, PathBuf}; + +use include_dir::{Dir, DirEntry, include_dir}; + +const ANDROID_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/android"); +const IOS_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/ios"); + +/// Template variable that can be replaced in template files +#[derive(Debug, Clone)] +pub struct TemplateVar { + pub name: &'static str, + pub value: String, +} + +/// Generates a new mobile benchmark project from templates +/// +/// Creates the necessary directory structure and files for benchmarking on +/// mobile platforms. This includes: +/// - A `bench-mobile/` crate for FFI bindings +/// - Platform-specific app projects (Android and/or iOS) +/// - Configuration files +/// +/// # Arguments +/// +/// * `config` - Configuration for project initialization +/// +/// # Returns +/// +/// * `Ok(PathBuf)` - Path to the generated project root +/// * `Err(BenchError)` - If generation fails +pub fn generate_project(config: &InitConfig) -> Result { + let output_dir = &config.output_dir; + let project_slug = sanitize_package_name(&config.project_name); + let project_pascal = to_pascal_case(&project_slug); + let bundle_prefix = format!("dev.world.{}", project_slug); + + // Create base directories + fs::create_dir_all(output_dir)?; + + // Generate bench-mobile FFI wrapper crate + generate_bench_mobile_crate(output_dir, &project_slug)?; + + // Generate platform-specific projects + match config.target { + Target::Android => { + generate_android_project(output_dir, &project_slug)?; + } + Target::Ios => { + generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix)?; + } + Target::Both => { + generate_android_project(output_dir, &project_slug)?; + generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix)?; + } + } + + // Generate config file + generate_config_file(output_dir, config)?; + + // Generate examples if requested + if config.generate_examples { + generate_example_benchmarks(output_dir)?; + } + + Ok(output_dir.clone()) +} + +/// Generates the bench-mobile FFI wrapper crate +fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result<(), BenchError> { + let crate_dir = output_dir.join("bench-mobile"); + fs::create_dir_all(crate_dir.join("src"))?; + + let crate_name = format!("{}-bench-mobile", project_name); + + // Generate Cargo.toml + let cargo_toml = format!( + r#"[package] +name = "{}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "staticlib", "rlib"] + +[dependencies] +bench-sdk = {{ path = ".." }} +uniffi = "0.28" +{} = {{ path = ".." }} + +[features] +default = [] + +[build-dependencies] +uniffi = {{ version = "0.28", features = ["build"] }} +"#, + crate_name, project_name + ); + + fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?; + + // Generate src/lib.rs + let lib_rs_template = r#"//! Mobile FFI bindings for benchmarks +//! +//! This crate provides the FFI boundary between Rust benchmarks and mobile +//! platforms (Android/iOS). It uses UniFFI to generate type-safe bindings. + +use uniffi; + +// Ensure the user crate is linked so benchmark registrations are pulled in. +extern crate {{USER_CRATE}} as _bench_user_crate; + +// Re-export bench-sdk types with UniFFI annotations +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchSample { + pub duration_ns: u64, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchReport { + pub spec: BenchSpec, + pub samples: Vec, +} + +#[derive(Debug, thiserror::Error, uniffi::Error)] +#[uniffi(flat_error)] +pub enum BenchError { + #[error("iterations must be greater than zero")] + InvalidIterations, + + #[error("unknown benchmark function: {name}")] + UnknownFunction { name: String }, + + #[error("benchmark execution failed: {reason}")] + ExecutionFailed { reason: String }, +} + +// Convert from bench-sdk types +impl From for BenchSpec { + fn from(spec: mobench_sdk::BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for mobench_sdk::BenchSpec { + fn from(spec: BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for BenchSample { + fn from(sample: mobench_sdk::BenchSample) -> Self { + Self { + duration_ns: sample.duration_ns, + } + } +} + +impl From for BenchReport { + fn from(report: mobench_sdk::RunnerReport) -> Self { + Self { + spec: report.spec.into(), + samples: report.samples.into_iter().map(Into::into).collect(), + } + } +} + +impl From for BenchError { + fn from(err: mobench_sdk::BenchError) -> Self { + match err { + mobench_sdk::BenchError::Runner(runner_err) => { + BenchError::ExecutionFailed { + reason: runner_err.to_string(), + } + } + mobench_sdk::BenchError::UnknownFunction(name) => { + BenchError::UnknownFunction { name } + } + _ => BenchError::ExecutionFailed { + reason: err.to_string(), + }, + } + } +} + +/// Runs a benchmark by name with the given specification +/// +/// This is the main FFI entry point called from mobile platforms. +#[uniffi::export] +pub fn run_benchmark(spec: BenchSpec) -> Result { + let sdk_spec: mobench_sdk::BenchSpec = spec.into(); + let report = mobench_sdk::run_benchmark(sdk_spec)?; + Ok(report.into()) +} + +// Generate UniFFI scaffolding +uniffi::setup_scaffolding!(); +"#; + + let lib_rs = render_template( + lib_rs_template, + &[TemplateVar { + name: "USER_CRATE", + value: project_name.replace('-', "_"), + }], + ); + fs::write(crate_dir.join("src/lib.rs"), lib_rs)?; + + // Generate build.rs + let build_rs = r#"fn main() { + uniffi::generate_scaffolding("src/lib.rs").unwrap(); +} +"#; + + fs::write(crate_dir.join("build.rs"), build_rs)?; + + Ok(()) +} + +/// Generates Android project structure +fn generate_android_project(output_dir: &Path, project_slug: &str) -> Result<(), BenchError> { + let target_dir = output_dir.join("android"); + let vars = vec![ + TemplateVar { + name: "PACKAGE_NAME", + value: format!("dev.world.{}", project_slug), + }, + TemplateVar { + name: "UNIFFI_NAMESPACE", + value: project_slug.replace('-', "_"), + }, + TemplateVar { + name: "LIBRARY_NAME", + value: project_slug.replace('-', "_"), + }, + TemplateVar { + name: "DEFAULT_FUNCTION", + value: "example_fibonacci".to_string(), + }, + ]; + render_dir(&ANDROID_TEMPLATES, Path::new(""), &target_dir, &vars)?; + Ok(()) +} + +/// Generates iOS project structure +fn generate_ios_project( + output_dir: &Path, + project_slug: &str, + project_pascal: &str, + bundle_prefix: &str, +) -> Result<(), BenchError> { + let target_dir = output_dir.join("ios"); + let vars = vec![ + TemplateVar { + name: "DEFAULT_FUNCTION", + value: "example_fibonacci".to_string(), + }, + TemplateVar { + name: "PROJECT_NAME_PASCAL", + value: project_pascal.to_string(), + }, + TemplateVar { + name: "BUNDLE_ID_PREFIX", + value: bundle_prefix.to_string(), + }, + TemplateVar { + name: "BUNDLE_ID", + value: format!("{}.{}", bundle_prefix, project_slug), + }, + TemplateVar { + name: "LIBRARY_NAME", + value: project_slug.replace('-', "_"), + }, + ]; + render_dir(&IOS_TEMPLATES, Path::new(""), &target_dir, &vars)?; + Ok(()) +} + +/// Generates bench-sdk.toml configuration file +fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> { + let config_content = format!( + r#"# Bench SDK Configuration +# This file controls how benchmarks are built and executed + +[project] +name = "{}" +target = "{}" + +[build] +profile = "debug" # or "release" + +# BrowserStack configuration (optional) +# Uncomment and fill in your credentials to use BrowserStack +# [browserstack] +# username = "${{BROWSERSTACK_USERNAME}}" +# access_key = "${{BROWSERSTACK_ACCESS_KEY}}" +# project = "{}-benchmarks" + +# Device matrix (optional) +# Uncomment to specify devices for BrowserStack runs +# [[devices]] +# name = "Pixel 7" +# os = "android" +# os_version = "13.0" +# tags = ["default"] + +# [[devices]] +# name = "iPhone 14" +# os = "ios" +# os_version = "16" +# tags = ["default"] +"#, + config.project_name, + config.target.as_str(), + config.project_name + ); + + fs::write(output_dir.join("bench-sdk.toml"), config_content)?; + + Ok(()) +} + +/// Generates example benchmark functions +fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> { + let examples_dir = output_dir.join("benches"); + fs::create_dir_all(&examples_dir)?; + + let example_content = r#"//! Example benchmarks +//! +//! This file demonstrates how to write benchmarks with bench-sdk. + +use mobench_sdk::benchmark; + +/// Simple benchmark example +#[benchmark] +fn example_fibonacci() { + let result = fibonacci(30); + std::hint::black_box(result); +} + +/// Another example with a loop +#[benchmark] +fn example_sum() { + let mut sum = 0u64; + for i in 0..10000 { + sum = sum.wrapping_add(i); + } + std::hint::black_box(sum); +} + +// Helper function (not benchmarked) +fn fibonacci(n: u32) -> u64 { + match n { + 0 => 0, + 1 => 1, + _ => { + let mut a = 0u64; + let mut b = 1u64; + for _ in 2..=n { + let next = a.wrapping_add(b); + a = b; + b = next; + } + b + } + } +} +"#; + + fs::write(examples_dir.join("example.rs"), example_content)?; + + Ok(()) +} + +fn render_dir( + dir: &Dir, + prefix: &Path, + out_root: &Path, + vars: &[TemplateVar], +) -> Result<(), BenchError> { + for entry in dir.entries() { + match entry { + DirEntry::Dir(sub) => { + // Skip cache directories + if sub.path().components().any(|c| c.as_os_str() == ".gradle") { + continue; + } + let next_prefix = prefix.join(sub.path()); + render_dir(sub, &next_prefix, out_root, vars)?; + } + DirEntry::File(file) => { + if file.path().components().any(|c| c.as_os_str() == ".gradle") { + continue; + } + let mut relative = prefix.join(file.path()); + let mut contents = file.contents().to_vec(); + if let Some(ext) = relative.extension() + && ext == "template" + { + relative.set_extension(""); + let rendered = render_template( + std::str::from_utf8(&contents).map_err(|e| { + BenchError::Build(format!( + "invalid UTF-8 in template {:?}: {}", + file.path(), + e + )) + })?, + vars, + ); + contents = rendered.into_bytes(); + } + let out_path = out_root.join(relative); + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&out_path, contents)?; + } + } + } + Ok(()) +} + +fn render_template(input: &str, vars: &[TemplateVar]) -> String { + let mut output = input.to_string(); + for var in vars { + output = output.replace(&format!("{{{{{}}}}}", var.name), &var.value); + } + output +} + +fn sanitize_package_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .replace("--", "-") +} + +fn to_pascal_case(input: &str) -> String { + input + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|s| !s.is_empty()) + .map(|s| { + let mut chars = s.chars(); + let first = chars.next().unwrap().to_ascii_uppercase(); + let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect(); + format!("{}{}", first, rest) + }) + .collect::() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn test_generate_bench_mobile_crate() { + let temp_dir = env::temp_dir().join("bench-sdk-test"); + fs::create_dir_all(&temp_dir).unwrap(); + + let result = generate_bench_mobile_crate(&temp_dir, "test_project"); + assert!(result.is_ok()); + + // Verify files were created + assert!(temp_dir.join("bench-mobile/Cargo.toml").exists()); + assert!(temp_dir.join("bench-mobile/src/lib.rs").exists()); + assert!(temp_dir.join("bench-mobile/build.rs").exists()); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } +} diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs new file mode 100644 index 0000000..5b0bd04 --- /dev/null +++ b/crates/mobench-sdk/src/lib.rs @@ -0,0 +1,111 @@ +//! Mobile Benchmark SDK for Rust +//! +//! `bench-sdk` is a library for benchmarking Rust functions on real mobile devices +//! (Android and iOS) via BrowserStack. It provides a simple API similar to criterion.rs +//! but targets mobile platforms. +//! +//! # Quick Start +//! +//! 1. Add bench-sdk to your project: +//! ```toml +//! [dependencies] +//! bench-sdk = "0.1" +//! ``` +//! +//! 2. Mark functions with `#[benchmark]`: +//! ```ignore +//! use mobench_sdk::benchmark; +//! +//! #[benchmark] +//! fn my_expensive_operation() { +//! // Your code here +//! let result = compute_something(); +//! std::hint::black_box(result); +//! } +//! ``` +//! +//! 3. Initialize mobile project: +//! ```bash +//! cargo bench-sdk init --target android +//! ``` +//! +//! 4. Build and run: +//! ```bash +//! cargo bench-sdk build --target android +//! cargo bench-sdk run my_expensive_operation --target android +//! ``` +//! +//! # Architecture +//! +//! The SDK consists of several components: +//! +//! - **Registry**: Discovers functions marked with `#[benchmark]` at runtime +//! - **Runner**: Executes benchmarks and collects timing data +//! - **Builders**: Automates building Android/iOS apps +//! - **Codegen**: Generates mobile app templates +//! +//! # Example: Programmatic Usage +//! +//! ```ignore +//! use mobench_sdk::{BenchmarkBuilder, BenchSpec}; +//! +//! fn main() -> Result<(), mobench_sdk::BenchError> { +//! // Using the builder pattern +//! let report = BenchmarkBuilder::new("my_benchmark") +//! .iterations(100) +//! .warmup(10) +//! .run()?; +//! +//! println!("Samples: {}", report.samples.len()); +//! +//! // Or using BenchSpec directly +//! let spec = BenchSpec { +//! name: "my_benchmark".to_string(), +//! iterations: 50, +//! warmup: 5, +//! }; +//! let report = mobench_sdk::run_benchmark(spec)?; +//! +//! Ok(()) +//! } +//! ``` + +// Public modules +pub mod builders; +pub mod codegen; +pub mod registry; +pub mod runner; +pub mod types; + +// Re-export the benchmark macro from bench-macros +pub use mobench_macros::benchmark; + +// Re-export key types for convenience +pub use registry::{BenchFunction, discover_benchmarks, find_benchmark, list_benchmark_names}; +pub use runner::{BenchmarkBuilder, run_benchmark}; +pub use types::{ + BenchError, BenchSample, BenchSpec, BuildConfig, BuildProfile, BuildResult, InitConfig, + RunnerReport, Target, +}; + +// Re-export mobench-runner types for backward compatibility +pub use mobench_runner; + +/// Library version +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_is_set() { + assert!(!VERSION.is_empty()); + } + + #[test] + fn test_discover_benchmarks_compiles() { + // This test just ensures the function is accessible + let _benchmarks = discover_benchmarks(); + } +} diff --git a/crates/mobench-sdk/src/registry.rs b/crates/mobench-sdk/src/registry.rs new file mode 100644 index 0000000..459b793 --- /dev/null +++ b/crates/mobench-sdk/src/registry.rs @@ -0,0 +1,125 @@ +//! Benchmark function registry +//! +//! This module provides runtime discovery of benchmark functions that have been +//! marked with the `#[benchmark]` attribute macro. + +use crate::types::BenchError; + +/// A registered benchmark function +/// +/// This struct is submitted to the global registry by the `#[benchmark]` macro. +/// It contains the function's name and a closure that invokes it. +pub struct BenchFunction { + /// Fully-qualified name of the benchmark function (e.g., "my_crate::my_module::my_bench") + pub name: &'static str, + + /// Function that invokes the benchmark + /// + /// Takes optional arguments and returns a Result. + /// Arguments are currently unused but reserved for future parameterization. + pub invoke: fn(&[String]) -> Result<(), BenchError>, +} + +// Register the BenchFunction type with inventory +inventory::collect!(BenchFunction); + +/// Discovers all registered benchmark functions +/// +/// Returns a vector of references to all functions that have been marked with +/// the `#[benchmark]` attribute in the current binary. +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::registry::discover_benchmarks; +/// +/// let benchmarks = discover_benchmarks(); +/// for bench in benchmarks { +/// println!("Found benchmark: {}", bench.name); +/// } +/// ``` +pub fn discover_benchmarks() -> Vec<&'static BenchFunction> { + inventory::iter::().collect() +} + +/// Finds a benchmark function by name +/// +/// Searches the registry for a function with the given name. Supports both +/// short names (e.g., "fibonacci") and fully-qualified names +/// (e.g., "my_crate::fibonacci"). +/// +/// # Arguments +/// +/// * `name` - The name of the benchmark to find +/// +/// # Returns +/// +/// * `Some(&BenchFunction)` if found +/// * `None` if no matching benchmark exists +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::registry::find_benchmark; +/// +/// if let Some(bench) = find_benchmark("fibonacci") { +/// println!("Found benchmark: {}", bench.name); +/// } else { +/// eprintln!("Benchmark not found"); +/// } +/// ``` +pub fn find_benchmark(name: &str) -> Option<&'static BenchFunction> { + inventory::iter::().find(|f| { + // Match either the full name or just the final component + f.name == name || f.name.ends_with(&format!("::{}", name)) + }) +} + +/// Lists all registered benchmark names +/// +/// Returns a sorted vector of all benchmark function names in the registry. +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::registry::list_benchmark_names; +/// +/// let names = list_benchmark_names(); +/// println!("Available benchmarks:"); +/// for name in names { +/// println!(" - {}", name); +/// } +/// ``` +pub fn list_benchmark_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = inventory::iter::().map(|f| f.name).collect(); + names.sort(); + names +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_discover_benchmarks() { + // Note: This test validates that the discovery function works + // The number of benchmarks depends on what's registered in the binary + let benchmarks = discover_benchmarks(); + // Just ensure the function returns successfully + let _ = benchmarks; + } + + #[test] + fn test_find_benchmark_none() { + // Should not find a non-existent benchmark + let result = find_benchmark("nonexistent_benchmark_function_12345"); + assert!(result.is_none()); + } + + #[test] + fn test_list_benchmark_names() { + // Validates that the function returns successfully + let names = list_benchmark_names(); + let _ = names; + } +} diff --git a/crates/mobench-sdk/src/runner.rs b/crates/mobench-sdk/src/runner.rs new file mode 100644 index 0000000..26b7dba --- /dev/null +++ b/crates/mobench-sdk/src/runner.rs @@ -0,0 +1,142 @@ +//! Benchmark execution runtime +//! +//! This module provides the execution engine that runs registered benchmarks +//! and collects timing data. + +use crate::registry::find_benchmark; +use crate::types::{BenchError, BenchSpec, RunnerReport}; +use mobench_runner::run_closure; + +/// Runs a benchmark by name +/// +/// Looks up the benchmark function in the registry and executes it with the +/// given specification. +/// +/// # Arguments +/// +/// * `spec` - Benchmark specification including function name, iterations, and warmup +/// +/// # Returns +/// +/// * `Ok(BenchReport)` - Report containing timing samples +/// * `Err(BenchError)` - If the function is not found or execution fails +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::{BenchSpec, run_benchmark}; +/// +/// let spec = BenchSpec { +/// name: "my_benchmark".to_string(), +/// iterations: 100, +/// warmup: 10, +/// }; +/// +/// let report = run_benchmark(spec)?; +/// println!("Mean: {} ns", report.mean()); +/// ``` +pub fn run_benchmark(spec: BenchSpec) -> Result { + // Find the benchmark function in the registry + let bench_fn = + find_benchmark(&spec.name).ok_or_else(|| BenchError::UnknownFunction(spec.name.clone()))?; + + // Create a closure that invokes the registered function + let closure = + || (bench_fn.invoke)(&[]).map_err(|e| mobench_runner::BenchError::Execution(e.to_string())); + + // Run the benchmark using bench-runner's timing infrastructure + let report = run_closure(spec, closure)?; + + Ok(report) +} + +/// Builder for constructing and running benchmarks +/// +/// Provides a fluent interface for configuring benchmark parameters. +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::BenchmarkBuilder; +/// +/// let report = BenchmarkBuilder::new("my_benchmark") +/// .iterations(100) +/// .warmup(10) +/// .run()?; +/// ``` +#[derive(Debug, Clone)] +pub struct BenchmarkBuilder { + function: String, + iterations: u32, + warmup: u32, +} + +impl BenchmarkBuilder { + /// Creates a new benchmark builder + /// + /// # Arguments + /// + /// * `function` - Name of the benchmark function to run + pub fn new(function: impl Into) -> Self { + Self { + function: function.into(), + iterations: 100, // Default + warmup: 10, // Default + } + } + + /// Sets the number of iterations + /// + /// # Arguments + /// + /// * `n` - Number of times to run the benchmark (after warmup) + pub fn iterations(mut self, n: u32) -> Self { + self.iterations = n; + self + } + + /// Sets the number of warmup iterations + /// + /// # Arguments + /// + /// * `n` - Number of warmup runs (not measured) + pub fn warmup(mut self, n: u32) -> Self { + self.warmup = n; + self + } + + /// Runs the benchmark and returns the report + /// + /// # Returns + /// + /// * `Ok(BenchReport)` - Report containing timing samples + /// * `Err(BenchError)` - If the function is not found or execution fails + pub fn run(self) -> Result { + let spec = BenchSpec { + name: self.function, + iterations: self.iterations, + warmup: self.warmup, + }; + + run_benchmark(spec) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_builder_defaults() { + let builder = BenchmarkBuilder::new("test_fn"); + assert_eq!(builder.iterations, 100); + assert_eq!(builder.warmup, 10); + } + + #[test] + fn test_builder_customization() { + let builder = BenchmarkBuilder::new("test_fn").iterations(50).warmup(5); + assert_eq!(builder.iterations, 50); + assert_eq!(builder.warmup, 5); + } +} diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs new file mode 100644 index 0000000..1325607 --- /dev/null +++ b/crates/mobench-sdk/src/types.rs @@ -0,0 +1,116 @@ +//! Core types for bench-sdk +//! +//! This module re-exports types from mobench-runner and adds SDK-specific types. + +// Re-export mobench-runner types for convenience +pub use mobench_runner::{ + BenchError as RunnerError, BenchReport as RunnerReport, BenchSample, BenchSpec, +}; + +use std::path::PathBuf; + +/// Error types for bench-sdk operations +#[derive(Debug, thiserror::Error)] +pub enum BenchError { + /// Error from the benchmark runner + #[error("runner error: {0}")] + Runner(#[from] mobench_runner::BenchError), + + /// Benchmark function not found in registry + #[error("unknown benchmark function: {0}")] + UnknownFunction(String), + + /// Error during benchmark execution + #[error("execution error: {0}")] + Execution(String), + + /// I/O error + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + /// Serialization error + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// Configuration error + #[error("configuration error: {0}")] + Config(String), + + /// Build error + #[error("build error: {0}")] + Build(String), +} + +/// Target platform for benchmarks +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Target { + /// Android platform + Android, + /// iOS platform + Ios, + /// Both platforms + Both, +} + +impl Target { + pub fn as_str(&self) -> &'static str { + match self { + Target::Android => "android", + Target::Ios => "ios", + Target::Both => "both", + } + } +} + +/// Configuration for initializing a benchmark project +#[derive(Debug, Clone)] +pub struct InitConfig { + /// Target platform(s) + pub target: Target, + /// Project name + pub project_name: String, + /// Output directory for generated files + pub output_dir: PathBuf, + /// Whether to generate example benchmarks + pub generate_examples: bool, +} + +/// Configuration for building mobile apps +#[derive(Debug, Clone)] +pub struct BuildConfig { + /// Target platform + pub target: Target, + /// Build profile (debug or release) + pub profile: BuildProfile, + /// Whether to skip build if artifacts exist + pub incremental: bool, +} + +/// Build profile +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BuildProfile { + /// Debug build + Debug, + /// Release build + Release, +} + +impl BuildProfile { + pub fn as_str(&self) -> &'static str { + match self { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + } + } +} + +/// Result of a build operation +#[derive(Debug, Clone)] +pub struct BuildResult { + /// Platform that was built + pub platform: Target, + /// Path to the app artifact (APK, IPA, etc.) + pub app_path: PathBuf, + /// Path to the test suite artifact (if applicable) + pub test_suite_path: Option, +} diff --git a/crates/mobench-sdk/templates/android/app/build.gradle b/crates/mobench-sdk/templates/android/app/build.gradle new file mode 100644 index 0000000..7180e74 --- /dev/null +++ b/crates/mobench-sdk/templates/android/app/build.gradle @@ -0,0 +1,69 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" + +android { + namespace = "{{PACKAGE_NAME}}" + compileSdk = 34 + ndkVersion "26.1.10909125" + def repoRoot = rootProject.projectDir.parentFile + def benchSpecDir = new File(repoRoot, "target/mobile-spec/android") + if (!benchSpecDir.exists()) { + benchSpecDir.mkdirs() + } + + defaultConfig { + applicationId "{{PACKAGE_NAME}}" + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "0.1" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + minifyEnabled false + } + release { + minifyEnabled false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + main { + jniLibs.srcDirs "src/main/jniLibs" + assets.srcDirs += [benchSpecDir.absolutePath] + } + androidTest { + assets.srcDirs += [benchSpecDir.absolutePath] + } + } + + packagingOptions { + jniLibs { + // Keep symbols in Rust shared objects; avoid strip warning. + keepDebugSymbols += ["**/lib{{LIBRARY_NAME}}.so"] + } + } +} + +dependencies { + implementation "androidx.core:core-ktx:1.12.0" + implementation "androidx.appcompat:appcompat:1.6.1" + implementation "com.google.android.material:material:1.11.0" + implementation "net.java.dev.jna:jna:5.14.0@aar" + androidTestImplementation "androidx.test.ext:junit:1.1.5" + androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" +} diff --git a/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template b/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template new file mode 100644 index 0000000..d041367 --- /dev/null +++ b/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template @@ -0,0 +1,25 @@ +package {{PACKAGE_NAME}} + +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import org.hamcrest.Matchers.containsString +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + + @Test + fun showsBenchOutput() { + onView(withId(R.id.result_text)) + .check(matches(withText(containsString("Samples")))) + } +} diff --git a/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml b/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..eda071a --- /dev/null +++ b/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template new file mode 100644 index 0000000..374da28 --- /dev/null +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -0,0 +1,167 @@ +package {{PACKAGE_NAME}} + +import android.os.Bundle +import android.os.Debug +import android.os.Process +import android.os.SystemClock +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import org.json.JSONArray +import org.json.JSONObject +import uniffi.{{UNIFFI_NAMESPACE}}.BenchException +import uniffi.{{UNIFFI_NAMESPACE}}.BenchReport +import uniffi.{{UNIFFI_NAMESPACE}}.BenchSpec +import uniffi.{{UNIFFI_NAMESPACE}}.runBenchmark + +class MainActivity : AppCompatActivity() { + + companion object { + private const val DEFAULT_FUNCTION = "{{DEFAULT_FUNCTION}}" + private const val DEFAULT_ITERATIONS = 20u + private const val DEFAULT_WARMUP = 3u + private const val FUNCTION_EXTRA = "bench_function" + private const val ITERATIONS_EXTRA = "bench_iterations" + private const val WARMUP_EXTRA = "bench_warmup" + private const val SPEC_ASSET = "bench_spec.json" + + init { + System.loadLibrary("{{LIBRARY_NAME}}") + } + } + + private data class BenchParams( + val function: String, + val iterations: UInt, + val warmup: UInt, + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + val params = resolveBenchParams() + val display = try { + val spec = BenchSpec( + name = params.function, + iterations = params.iterations, + warmup = params.warmup + ) + val report = runBenchmark(spec) + // Debug: Log first sample's raw nanoseconds + if (report.samples.isNotEmpty()) { + android.util.Log.d("MainActivity", "First sample duration_ns: ${report.samples[0].durationNs}") + } + logBenchReport(report) + formatBenchReport(report) + } catch (e: BenchException.InvalidIterations) { + "Error: ${e.message}" + } catch (e: BenchException.UnknownFunction) { + "Error: ${e.message}" + } catch (e: BenchException.ExecutionFailed) { + "Error: ${e.message}" + } catch (e: Exception) { + "Unexpected error: ${e.message}" + } + + findViewById(R.id.result_text)?.text = display + } + + private fun formatBenchReport(report: BenchReport): String = buildString { + appendLine("=== Benchmark Results ===") + appendLine() + appendLine("Function: ${report.spec.name}") + appendLine("Iterations: ${report.spec.iterations}") + appendLine("Warmup: ${report.spec.warmup}") + appendLine() + appendLine("Samples (${report.samples.size}):") + report.samples.forEachIndexed { index, sample -> + val durationUs = sample.durationNs.toDouble() / 1_000.0 + appendLine(" ${index + 1}. ${String.format("%.3f", durationUs)} μs (${sample.durationNs} ns)") + } + + if (report.samples.isNotEmpty()) { + val durations = report.samples.map { it.durationNs.toDouble() / 1_000.0 } + val min = durations.minOrNull() ?: 0.0 + val max = durations.maxOrNull() ?: 0.0 + val avg = durations.average() + appendLine() + appendLine("Statistics:") + appendLine(" Min: ${String.format("%.3f", min)} μs") + appendLine(" Max: ${String.format("%.3f", max)} μs") + appendLine(" Avg: ${String.format("%.3f", avg)} μs") + } + } + + private fun logBenchReport(report: BenchReport) { + val json = JSONObject() + val spec = JSONObject() + spec.put("name", report.spec.name) + spec.put("iterations", report.spec.iterations.toInt()) + spec.put("warmup", report.spec.warmup.toInt()) + json.put("spec", spec) + + val samples = report.samples.map { it.durationNs.toLong() } + val sampleArray = JSONArray() + samples.forEach { sampleArray.put(it) } + json.put("samples_ns", sampleArray) + + if (samples.isNotEmpty()) { + val min = samples.minOrNull() ?: 0L + val max = samples.maxOrNull() ?: 0L + val avg = samples.sum().toDouble() / samples.size.toDouble() + val stats = JSONObject() + stats.put("min_ns", min) + stats.put("max_ns", max) + stats.put("avg_ns", avg.toDouble()) + json.put("stats", stats) + } + + val memInfo = Debug.MemoryInfo() + Debug.getMemoryInfo(memInfo) + val resources = JSONObject() + resources.put("elapsed_cpu_ms", Process.getElapsedCpuTime()) + resources.put("uptime_ms", SystemClock.elapsedRealtime()) + resources.put("total_pss_kb", memInfo.totalPss) + resources.put("private_dirty_kb", memInfo.totalPrivateDirty) + resources.put("native_heap_kb", Debug.getNativeHeapAllocatedSize() / 1024) + val usedHeap = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() + resources.put("java_heap_kb", usedHeap / 1024) + json.put("resources", resources) + + android.util.Log.i("BenchRunner", "BENCH_JSON ${json}") + } + + private fun resolveBenchParams(): BenchParams { + val defaults = loadBenchParamsFromAssets() ?: BenchParams( + DEFAULT_FUNCTION, + DEFAULT_ITERATIONS, + DEFAULT_WARMUP + ) + val fn = intent?.getStringExtra(FUNCTION_EXTRA) + ?.takeUnless { it.isBlank() } + ?: defaults.function + val iterations = intent?.getIntExtra(ITERATIONS_EXTRA, defaults.iterations.toInt())?.toUInt() + ?: defaults.iterations + val warmup = intent?.getIntExtra(WARMUP_EXTRA, defaults.warmup.toInt())?.toUInt() + ?: defaults.warmup + return BenchParams(fn, iterations, warmup) + } + + private fun loadBenchParamsFromAssets(): BenchParams? { + return try { + val raw = assets.open(SPEC_ASSET).bufferedReader().use { it.readText() } + if (raw.isBlank()) { + null + } else { + val json = JSONObject(raw) + BenchParams( + json.optString("function", DEFAULT_FUNCTION), + json.optInt("iterations", DEFAULT_ITERATIONS.toInt()).toUInt(), + json.optInt("warmup", DEFAULT_WARMUP.toInt()).toUInt(), + ) + } + } catch (_: Exception) { + null + } + } +} diff --git a/crates/mobench-sdk/templates/android/app/src/main/res/layout/activity_main.xml b/crates/mobench-sdk/templates/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..ba5178a --- /dev/null +++ b/crates/mobench-sdk/templates/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/mobench-sdk/templates/android/app/src/main/res/values/strings.xml b/crates/mobench-sdk/templates/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3e81bad --- /dev/null +++ b/crates/mobench-sdk/templates/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + {{APP_NAME}} + Loading Rust benchmarks… + diff --git a/crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml b/crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..fb5b86a --- /dev/null +++ b/crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + diff --git a/crates/mobench-sdk/templates/android/build.gradle b/crates/mobench-sdk/templates/android/build.gradle new file mode 100644 index 0000000..c3e6c75 --- /dev/null +++ b/crates/mobench-sdk/templates/android/build.gradle @@ -0,0 +1,21 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean", Delete) { + delete(rootProject.buildDir) +} diff --git a/crates/mobench-sdk/templates/android/settings.gradle b/crates/mobench-sdk/templates/android/settings.gradle new file mode 100644 index 0000000..5598347 --- /dev/null +++ b/crates/mobench-sdk/templates/android/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "{{PROJECT_NAME}}-android" +include(":app") diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h.template new file mode 100644 index 0000000..86a61e5 --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h.template @@ -0,0 +1,8 @@ +// +// {{PROJECT_NAME_PASCAL}}-Bridging-Header.h +// {{PROJECT_NAME_PASCAL}} +// +// Bridge to import C FFI from Rust (UniFFI-generated) +// + +#import "{{LIBRARY_NAME}}FFI.h" diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerApp.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerApp.swift.template new file mode 100644 index 0000000..6842d76 --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerApp.swift.template @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct {{PROJECT_NAME_PASCAL}}App: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template new file mode 100644 index 0000000..fcebaa5 --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -0,0 +1,118 @@ +import Foundation + +private let defaultFunction = "{{DEFAULT_FUNCTION}}" +private let defaultIterations: UInt32 = 20 +private let defaultWarmup: UInt32 = 3 + +struct BenchParams { + let function: String + let iterations: UInt32 + let warmup: UInt32 + + private struct EncodedBenchSpec: Decodable { + let function: String + let iterations: UInt32 + let warmup: UInt32 + } + + static func fromBundle() -> BenchParams? { + guard let url = Bundle.main.url(forResource: "bench_spec", withExtension: "json") else { + return nil + } + do { + let data = try Data(contentsOf: url) + let decoded = try JSONDecoder().decode(EncodedBenchSpec.self, from: data) + return BenchParams(function: decoded.function, iterations: decoded.iterations, warmup: decoded.warmup) + } catch { + return nil + } + } + + static func fromProcessInfo() -> BenchParams { + let info = ProcessInfo.processInfo + var function = info.environment["BENCH_FUNCTION"] ?? defaultFunction + var iterations = UInt32(info.environment["BENCH_ITERATIONS"] ?? "") ?? defaultIterations + var warmup = UInt32(info.environment["BENCH_WARMUP"] ?? "") ?? defaultWarmup + + for arg in info.arguments { + if arg.hasPrefix("--bench-function=") { + function = String(arg.split(separator: "=", maxSplits: 1).last ?? Substring(function)) + } else if arg.hasPrefix("--bench-iterations=") { + iterations = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? iterations + } else if arg.hasPrefix("--bench-warmup=") { + warmup = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? warmup + } + } + + return BenchParams(function: function, iterations: iterations, warmup: warmup) + } + + static func resolved() -> BenchParams { + if let bundled = fromBundle() { + return bundled + } + return fromProcessInfo() + } +} + +enum {{PROJECT_NAME_PASCAL}}FFI { + static func runCurrentBenchmark() async -> String { + let params = BenchParams.resolved() + return run(params: params) + } + + static func run(params: BenchParams) -> String { + let spec = BenchSpec( + name: params.function, + iterations: params.iterations, + warmup: params.warmup + ) + + do { + let report = try runBenchmark(spec: spec) + return formatBenchReport(report) + } catch let error as BenchError { + return formatBenchError(error) + } catch { + return "Unexpected error: \(error.localizedDescription)" + } + } + + private static func formatBenchReport(_ report: BenchReport) -> String { + var output = "=== Benchmark Results ===\n\n" + output += "Function: \(report.spec.name)\n" + output += "Iterations: \(report.spec.iterations)\n" + output += "Warmup: \(report.spec.warmup)\n\n" + + output += "Samples (\(report.samples.count)):\n" + for (index, sample) in report.samples.enumerated() { + let durationUs = Double(sample.durationNs) / 1_000.0 + output += " \(index + 1). \(String(format: "%.3f", durationUs)) μs (\(sample.durationNs) ns)\n" + } + + if !report.samples.isEmpty { + let durations = report.samples.map { Double($0.durationNs) / 1_000.0 } + let min = durations.min() ?? 0.0 + let max = durations.max() ?? 0.0 + let avg = durations.reduce(0, +) / Double(durations.count) + + output += "\nStatistics:\n" + output += " Min: \(String(format: "%.3f", min)) μs\n" + output += " Max: \(String(format: "%.3f", max)) μs\n" + output += " Avg: \(String(format: "%.3f", avg)) μs\n" + } + + return output + } + + private static func formatBenchError(_ error: BenchError) -> String { + switch error { + case .InvalidIterations(let message): + return "Error (InvalidIterations): \(message)" + case .UnknownFunction(let message): + return "Error (UnknownFunction): \(message)" + case .ExecutionFailed(let message): + return "Error (ExecutionFailed): \(message)" + } + } +} diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template new file mode 100644 index 0000000..1264f44 --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template @@ -0,0 +1,25 @@ +import SwiftUI + +struct ContentView: View { + @State private var report: String = "Running benchmark..." + + var body: some View { + ScrollView { + Text(report) + .font(.system(.body, design: .monospaced)) + .accessibilityIdentifier("benchmarkReport") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .background(Color(UIColor.systemBackground)) + .onAppear { + Task { + report = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + } + } + } +} + +#Preview { + ContentView() +} diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template new file mode 100644 index 0000000..595b47a --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template @@ -0,0 +1,12 @@ +import XCTest + +final class {{PROJECT_NAME_PASCAL}}UITests: XCTestCase { + func testLaunchShowsBenchmarkReport() { + let app = XCUIApplication() + app.launch() + + let report = app.staticTexts["benchmarkReport"] + let exists = report.waitForExistence(timeout: 30.0) + XCTAssertTrue(exists, "Benchmark report text should appear after launch") + } +} diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml new file mode 100644 index 0000000..11f416b --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml @@ -0,0 +1,37 @@ +name: {{PROJECT_NAME_PASCAL}} +options: + bundleIdPrefix: {{BUNDLE_ID_PREFIX}} +settings: + base: + SWIFT_VERSION: 5.9 +targets: + {{PROJECT_NAME_PASCAL}}: + type: application + platform: iOS + deploymentTarget: "15.0" + sources: + - path: {{PROJECT_NAME_PASCAL}} + resources: + - path: {{PROJECT_NAME_PASCAL}}/Resources + optional: true + - path: ../../target/mobile-spec/ios + optional: true + info: + path: {{PROJECT_NAME_PASCAL}}/Info.plist + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID}} + SWIFT_OBJC_BRIDGING_HEADER: {{PROJECT_NAME_PASCAL}}/{{PROJECT_NAME_PASCAL}}-Bridging-Header.h + HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/{{PROJECT_NAME_PASCAL}}/Generated" + dependencies: + - framework: ../../target/ios/{{LIBRARY_NAME}}.xcframework + embed: true + link: true + {{PROJECT_NAME_PASCAL}}UITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "15.0" + sources: + - path: {{PROJECT_NAME_PASCAL}}UITests + dependencies: + - target: {{PROJECT_NAME_PASCAL}} diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml new file mode 100644 index 0000000..3f248db --- /dev/null +++ b/crates/mobench/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "mobench" +version.workspace = true +edition.workspace = true +license.workspace = true +authors = ["Dominik Clemente - dcbuilder.eth "] +description = "Mobile benchmarking CLI for Rust - Run benchmarks on real Android and iOS devices" +repository = "https://github.com/worldcoin/mobile-bench-rs" +readme = "README.md" +keywords = ["benchmark", "mobile", "android", "ios", "performance"] +categories = ["development-tools", "command-line-utilities"] + +default-run = "mobench" + +# Dual binary targets allow the tool to work as both: +# - `mobench run ...` (direct invocation) +# - `cargo mobench run ...` (cargo subcommand) +# This is a standard Rust pattern for cargo extensions. +[[bin]] +name = "mobench" +path = "src/main.rs" + +[[bin]] +name = "cargo-mobench" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +mobench-sdk = { version = "0.1", path = "../mobench-sdk" } +mobench-runner = { version = "0.1", path = "../mobench-runner" } +clap.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true +toml.workspace = true +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking", "json", "multipart"] } +dotenvy = "0.15" + +[dev-dependencies] +tempfile = "3" +inventory = "0.3" +sample-fns = { path = "../sample-fns" } diff --git a/crates/mobench/README.md b/crates/mobench/README.md new file mode 100644 index 0000000..4eee002 --- /dev/null +++ b/crates/mobench/README.md @@ -0,0 +1,493 @@ +# mobench + +Mobile benchmarking CLI for Rust - Run benchmarks on real Android and iOS devices. + +The `mobench` CLI is the easiest way to benchmark your Rust code on mobile devices. It handles everything from project setup to building mobile apps to running tests on real devices via BrowserStack. + +## Installation + +```bash +cargo install mobench +``` + +Or use as a Cargo subcommand: + +```bash +cargo install mobench +cargo mobench --help +``` + +## Quick Start + +### 1. Initialize Your Project + +```bash +# Create mobile benchmarking setup for Android +cargo mobench init --target android + +# Or for iOS +cargo mobench init --target ios + +# Or for both platforms +cargo mobench init --target both +``` + +This creates: +- `bench-mobile/` - FFI wrapper crate with UniFFI bindings +- `android/` or `ios/` - Platform-specific app projects +- `bench-sdk.toml` - Configuration file +- `benches/example.rs` - Example benchmarks (with `--generate-examples`) + +### 2. Write Benchmarks + +```rust +// benches/my_benchmarks.rs +use mobench_sdk::benchmark; + +#[benchmark] +fn fibonacci_30() { + let result = fibonacci(30); + std::hint::black_box(result); +} + +fn fibonacci(n: u32) -> u64 { + match n { + 0 => 0, + 1 => 1, + _ => fibonacci(n - 1) + fibonacci(n - 2), + } +} +``` + +### 3. Build for Mobile + +```bash +# Build Android APK +cargo mobench build --target android + +# Build iOS app +cargo mobench build --target ios +``` + +### 4. Run Benchmarks + +Local emulator/simulator: +```bash +cargo mobench run fibonacci_30 --local-only --iterations 50 +``` + +On real devices via BrowserStack: +```bash +export BROWSERSTACK_USERNAME=your_username +export BROWSERSTACK_ACCESS_KEY=your_access_key + +cargo mobench run fibonacci_30 \ + --devices "Pixel 7-13" \ + --iterations 100 \ + --warmup 10 +``` + +## Commands + +### `init` - Initialize Project + +Create mobile benchmarking infrastructure: + +```bash +cargo mobench init [OPTIONS] +``` + +**Options:** +- `--target ` - Target platform (default: android) +- `--output ` - Config file path (default: bench-sdk.toml) + +**Example:** +```bash +cargo mobench init --target both --output my-bench.toml +``` + +### `build` - Build Mobile Apps + +Cross-compile and package for mobile platforms: + +```bash +cargo mobench build --target [OPTIONS] +``` + +**Options:** +- `--target ` - Platform to build for (required) +- `--profile ` - Build profile (default: debug) + +**Examples:** +```bash +# Build Android APK in release mode +cargo mobench build --target android --profile release + +# Build iOS xcframework +cargo mobench build --target ios +``` + +**Outputs:** +- Android: `android/app/build/outputs/apk/debug/app-debug.apk` +- iOS: `target/ios/sample_fns.xcframework` + +### `run` - Run Benchmarks + +Execute benchmarks on devices: + +```bash +cargo mobench run [OPTIONS] +``` + +**Options:** +- `--target ` - Platform (required) +- `--function ` - Benchmark function name (required) +- `--iterations ` - Number of iterations (default: 100) +- `--warmup ` - Warmup iterations (default: 10) +- `--devices ` - Comma-separated device list for BrowserStack +- `--local-only` - Skip BrowserStack, run locally only +- `--output ` - Save results to JSON file +- `--fetch` - Fetch BrowserStack results after completion + +**Examples:** +```bash +# Run locally +cargo mobench run fibonacci_30 --target android --local-only + +# Run on BrowserStack devices +cargo mobench run sha256_hash \ + --target android \ + --devices "Pixel 7-13,Galaxy S23-13" \ + --iterations 50 \ + --output results.json + +# Run on iOS with auto-fetch +cargo mobench run json_parse \ + --target ios \ + --devices "iPhone 14-16,iPhone 15-17" \ + --fetch +``` + +### `package-ipa` - Package iOS IPA + +Create a signed IPA for BrowserStack: + +```bash +cargo mobench package-ipa [OPTIONS] +``` + +**Options:** +- `--scheme ` - Xcode scheme (default: BenchRunner) +- `--method ` - Signing method (default: adhoc) + +**Example:** +```bash +cargo mobench package-ipa --method adhoc +``` + +**Output:** `target/ios/BenchRunner.ipa` + +### `plan` - Generate Device Matrix + +Create a template device matrix file: + +```bash +cargo mobench plan [--output ] +``` + +**Example:** +```bash +cargo mobench plan --output devices.yaml +``` + +**Output:** `device-matrix.yaml` + +```yaml +devices: + - name: Pixel 7 + os: android + os_version: "13.0" + tags: [default, pixel] + - name: iPhone 14 + os: ios + os_version: "16" + tags: [default, iphone] +``` + +### `fetch` - Fetch Results + +Download BrowserStack build artifacts: + +```bash +cargo mobench fetch --target --build-id [OPTIONS] +``` + +**Options:** +- `--target ` - Platform (required) +- `--build-id ` - BrowserStack build ID (required) +- `--output-dir ` - Download directory (default: target/browserstack) + +**Example:** +```bash +cargo mobench fetch \ + --target android \ + --build-id abc123def456 \ + --output-dir ./results +``` + +## Configuration + +### Config File Format (`bench-sdk.toml`) + +```toml +[project] +name = "my-benchmarks" +target = "both" # android, ios, or both + +[build] +profile = "release" # or "debug" + +[browserstack] +username = "${BROWSERSTACK_USERNAME}" +access_key = "${BROWSERSTACK_ACCESS_KEY}" +project = "my-project-benchmarks" + +[[devices]] +name = "Pixel 7" +os = "android" +os_version = "13.0" +tags = ["default"] + +[[devices]] +name = "iPhone 14" +os = "ios" +os_version = "16" +tags = ["default"] +``` + +### Environment Variables + +BrowserStack credentials can be provided via: + +1. **Environment variables** (recommended): + ```bash + export BROWSERSTACK_USERNAME=your_username + export BROWSERSTACK_ACCESS_KEY=your_access_key + ``` + +2. **`.env.local` file**: + ``` + BROWSERSTACK_USERNAME=your_username + BROWSERSTACK_ACCESS_KEY=your_access_key + ``` + +3. **Config file** with variable expansion: + ```toml + [browserstack] + username = "${BROWSERSTACK_USERNAME}" + access_key = "${BROWSERSTACK_ACCESS_KEY}" + ``` + +## Requirements + +### For Android + +- **Android NDK** - Set `ANDROID_NDK_HOME` environment variable +- **cargo-ndk** - Install with `cargo install cargo-ndk` +- **Android SDK** - API level 24+ required +- **Gradle** - For building APKs (bundled with Android project) + +### For iOS + +- **macOS** with Xcode installed +- **Xcode Command Line Tools** - `xcode-select --install` +- **Rust iOS targets**: + ```bash + rustup target add aarch64-apple-ios + rustup target add aarch64-apple-ios-sim + ``` +- **XcodeGen** - Install with `brew install xcodegen` + +### For BrowserStack + +- **BrowserStack App Automate account** - [Sign up](https://www.browserstack.com/app-automate) +- **Credentials** - Username and access key from account settings + +## Examples + +### Benchmark Crypto Operations + +```bash +# Initialize +cargo mobench init --target android + +# Add benchmark +cat > benches/crypto.rs <<'EOF' +use mobench_sdk::benchmark; +use sha2::{Sha256, Digest}; + +#[benchmark] +fn sha256_1kb() { + let data = vec![0u8; 1024]; + let hash = Sha256::digest(&data); + std::hint::black_box(hash); +} +EOF + +# Build +cargo mobench build --target android --profile release + +# Run on multiple devices +cargo mobench run sha256_1kb \ + --target android \ + --devices "Pixel 7-13,Galaxy S23-13,OnePlus 11-13" \ + --iterations 200 \ + --output crypto-results.json +``` + +### Compare iOS Performance + +```bash +# Run same benchmark on different iOS versions +cargo mobench run json_parse \ + --target ios \ + --devices "iPhone 13-15,iPhone 14-16,iPhone 15-17" \ + --iterations 100 \ + --fetch \ + --output ios-comparison.json +``` + +### CI Integration + +```yaml +# .github/workflows/mobile-bench.yml +name: Mobile Benchmarks + +on: [push] + +jobs: + benchmark: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install mobench + run: cargo install mobench + + - name: Setup Android NDK + uses: nttld/setup-ndk@v1 + with: + ndk-version: r25c + + - name: Build + run: cargo mobench build --target android --profile release + + - name: Run benchmarks + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + run: | + cargo mobench run my_benchmark \ + --target android \ + --devices "Pixel 7-13" \ + --iterations 50 \ + --output results.json \ + --fetch + + - name: Upload results + uses: actions/upload-artifact@v3 + with: + name: benchmark-results + path: results.json +``` + +## Workflow + +``` +┌─────────────────────┐ +│ 1. cargo mobench │ +│ init │ +└──────────┬──────────┘ + │ + ↓ +┌─────────────────────┐ +│ 2. Write benchmarks │ +│ with #[benchmark]│ +└──────────┬──────────┘ + │ + ↓ +┌─────────────────────┐ +│ 3. cargo mobench │ +│ build │ +└──────────┬──────────┘ + │ + ↓ +┌─────────────────────┐ +│ 4. cargo mobench │ +│ run │ +└──────────┬──────────┘ + │ + ┌────┴────┐ + ↓ ↓ +┌──────────┐ ┌──────────────┐ +│ Local │ │ BrowserStack │ +│ Emulator │ │ Real Devices │ +└──────────┘ └──────────────┘ +``` + +## Troubleshooting + +### Android NDK not found + +```bash +export ANDROID_NDK_HOME=/path/to/ndk +``` + +Or install via Android Studio: Tools → SDK Manager → SDK Tools → NDK + +### iOS code signing issues + +For BrowserStack testing, use ad-hoc signing: + +```bash +cargo mobench package-ipa --method adhoc +``` + +### BrowserStack authentication failed + +Verify credentials: + +```bash +echo $BROWSERSTACK_USERNAME +echo $BROWSERSTACK_ACCESS_KEY +``` + +Or check `.env.local` file exists and contains valid credentials. + +### Benchmark function not found + +Ensure: +1. Function has `#[benchmark]` attribute +2. Function is compiled into the mobile binary +3. Function name matches exactly (case-sensitive) + +## Part of mobench + +This CLI is part of the mobench ecosystem: + +- **[mobench](https://crates.io/crates/mobench)** - This crate (CLI tool) +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - SDK library +- **[mobench-macros](https://crates.io/crates/mobench-macros)** - Proc macros +- **[mobench-runner](https://crates.io/crates/mobench-runner)** - Timing harness + +## See Also + +- [mobench-sdk Documentation](https://crates.io/crates/mobench-sdk) for programmatic API +- [BrowserStack App Automate](https://www.browserstack.com/app-automate) for device cloud +- [UniFFI Documentation](https://mozilla.github.io/uniffi-rs/) for FFI details + +## License + +Licensed under the MIT License. See [LICENSE.md](../../LICENSE.md) for details. + +Copyright (c) 2026 World Foundation diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs new file mode 100644 index 0000000..ad18abc --- /dev/null +++ b/crates/mobench/src/browserstack.rs @@ -0,0 +1,1343 @@ +use anyhow::{Context, Result, anyhow}; +use reqwest::blocking::multipart::Form; +use reqwest::blocking::{Client, Response}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::Value; +use std::path::Path; + +const DEFAULT_BASE_URL: &str = "https://api-cloud.browserstack.com"; +const USER_AGENT: &str = "mobile-bench-rs/0.1"; + +#[derive(Debug, Clone)] +pub struct BrowserStackAuth { + pub username: String, + pub access_key: String, +} + +/// BrowserStack App Automate (Espresso) client. +#[derive(Debug, Clone)] +pub struct BrowserStackClient { + http: Client, + auth: BrowserStackAuth, + base_url: String, + project: Option, +} + +impl BrowserStackClient { + pub fn new(auth: BrowserStackAuth, project: Option) -> Result { + let http = Client::builder() + .user_agent(USER_AGENT) + .build() + .context("building HTTP client")?; + + Ok(Self { + http, + auth, + base_url: DEFAULT_BASE_URL.to_string(), + project, + }) + } + + #[cfg(test)] + #[allow(dead_code)] // Used in tests to verify URL construction + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + /// Upload an Espresso app-under-test APK to BrowserStack. + pub fn upload_espresso_app(&self, artifact: &Path) -> Result { + if !artifact.exists() { + return Err(anyhow!("app artifact not found at {:?}", artifact)); + } + + let form = Form::new().file("file", artifact)?; + let resp = self + .http + .post(self.api("app-automate/espresso/v2/app")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .multipart(form) + .send() + .context("uploading app to BrowserStack")?; + + parse_response(resp, "app upload") + } + + /// Upload an Espresso test-suite APK to BrowserStack. + pub fn upload_espresso_test_suite(&self, artifact: &Path) -> Result { + if !artifact.exists() { + return Err(anyhow!("test suite artifact not found at {:?}", artifact)); + } + + let form = Form::new().file("file", artifact)?; + let resp = self + .http + .post(self.api("app-automate/espresso/v2/test-suite")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .multipart(form) + .send() + .context("uploading test suite to BrowserStack")?; + + parse_response(resp, "test suite upload") + } + + pub fn upload_xcuitest_app(&self, artifact: &Path) -> Result { + if !artifact.exists() { + return Err(anyhow!("iOS app artifact not found at {:?}", artifact)); + } + + let form = Form::new().file("file", artifact)?; + let resp = self + .http + .post(self.api("app-automate/xcuitest/v2/app")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .multipart(form) + .send() + .context("uploading iOS app to BrowserStack")?; + + parse_response(resp, "iOS app upload") + } + + pub fn upload_xcuitest_test_suite(&self, artifact: &Path) -> Result { + if !artifact.exists() { + return Err(anyhow!( + "iOS XCUITest suite artifact not found at {:?}", + artifact + )); + } + + let form = Form::new().file("file", artifact)?; + let resp = self + .http + .post(self.api("app-automate/xcuitest/v2/test-suite")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .multipart(form) + .send() + .context("uploading iOS XCUITest suite to BrowserStack")?; + + parse_response(resp, "iOS XCUITest suite upload") + } + + pub fn schedule_espresso_run( + &self, + devices: &[String], + app_url: &str, + test_suite_url: &str, + ) -> Result { + if devices.is_empty() { + return Err(anyhow!("device list is empty; provide at least one target")); + } + if app_url.is_empty() { + return Err(anyhow!("app_url is empty")); + } + if test_suite_url.is_empty() { + return Err(anyhow!("test_suite_url is empty")); + } + + let body = BuildRequest { + app: app_url.to_string(), + test_suite: test_suite_url.to_string(), + devices: devices.to_vec(), + device_logs: true, + disable_animations: true, + build_name: self.project.clone(), + }; + + let resp = self + .http + .post(self.api("app-automate/espresso/v2/build")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .json(&body) + .send() + .context("scheduling BrowserStack Espresso run")?; + + let build: BuildResponse = parse_response(resp, "schedule run")?; + Ok(ScheduledRun { + build_id: build.build_id, + }) + } + + pub fn schedule_xcuitest_run( + &self, + devices: &[String], + app_url: &str, + test_suite_url: &str, + ) -> Result { + if devices.is_empty() { + return Err(anyhow!("device list is empty; provide at least one target")); + } + if app_url.is_empty() { + return Err(anyhow!("app_url is empty")); + } + if test_suite_url.is_empty() { + return Err(anyhow!("test_suite_url is empty")); + } + + let body = XcuitestBuildRequest { + app: app_url.to_string(), + test_suite: test_suite_url.to_string(), + devices: devices.to_vec(), + device_logs: true, + build_name: self.project.clone(), + // Specify the test method to run (required by BrowserStack for XCUITest) + only_testing: Some(vec![ + "BenchRunnerUITests/BenchRunnerUITests/testLaunchShowsBenchmarkReport".to_string(), + ]), + }; + + let resp = self + .http + .post(self.api("app-automate/xcuitest/v2/build")) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .json(&body) + .send() + .context("scheduling BrowserStack XCUITest run")?; + + let build: BuildResponse = parse_response(resp, "schedule run")?; + Ok(ScheduledRun { + build_id: build.build_id, + }) + } + + fn api(&self, path: &str) -> String { + format!( + "{}/{}", + self.base_url.trim_end_matches('/'), + path.trim_start_matches('/') + ) + } + + pub fn get_json(&self, path: &str) -> Result { + let resp = self + .http + .get(self.api(path)) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .send() + .with_context(|| format!("requesting BrowserStack API {}", path))?; + + parse_response(resp, path) + } + + pub fn download_url(&self, url: &str, dest: &Path) -> Result<()> { + let resp = self + .http + .get(url) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .send() + .with_context(|| format!("downloading BrowserStack asset {}", url))?; + let status = resp.status(); + let bytes = resp + .bytes() + .with_context(|| format!("reading BrowserStack asset body {}", url))?; + if !status.is_success() { + return Err(anyhow!( + "BrowserStack asset download failed (status {}): {}", + status, + String::from_utf8_lossy(&bytes) + )); + } + std::fs::write(dest, bytes) + .with_context(|| format!("writing BrowserStack asset to {:?}", dest))?; + Ok(()) + } + + /// Get the status of an Espresso build + pub fn get_espresso_build_status(&self, build_id: &str) -> Result { + let path = format!("app-automate/espresso/v2/builds/{}", build_id); + let json = self.get_json(&path)?; + build_status_from_value(json).context("parsing build status response") + } + + /// Get the status of an XCUITest build + pub fn get_xcuitest_build_status(&self, build_id: &str) -> Result { + let path = format!("app-automate/xcuitest/v2/builds/{}", build_id); + let json = self.get_json(&path)?; + build_status_from_value(json).context("parsing build status response") + } + + /// Poll for build completion with timeout + /// + /// # Arguments + /// * `build_id` - The build ID to poll + /// * `platform` - "espresso" or "xcuitest" + /// * `timeout_secs` - Maximum time to wait in seconds (default: 600) + /// * `poll_interval_secs` - How often to check status in seconds (default: 10) + pub fn poll_build_completion( + &self, + build_id: &str, + platform: &str, + timeout_secs: u64, + poll_interval_secs: u64, + ) -> Result { + use std::time::{Duration, Instant}; + + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + let poll_interval = Duration::from_secs(poll_interval_secs); + + loop { + let status = match platform { + "espresso" => self.get_espresso_build_status(build_id)?, + "xcuitest" => self.get_xcuitest_build_status(build_id)?, + _ => return Err(anyhow!("unsupported platform: {}", platform)), + }; + + match status.status.as_str() { + "done" => return Ok(status), + "failed" | "error" | "timeout" => { + return Err(anyhow!( + "Build {} failed with status: {}", + build_id, + status.status + )); + } + _ => { + // Still running + if start.elapsed() >= timeout { + return Err(anyhow!( + "Timeout waiting for build {} to complete (waited {} seconds)", + build_id, + timeout_secs + )); + } + std::thread::sleep(poll_interval); + } + } + } + } + + /// Fetch device logs for a specific session + pub fn get_device_logs( + &self, + build_id: &str, + session_id: &str, + platform: &str, + ) -> Result { + let path = match platform { + "espresso" => format!( + "app-automate/espresso/v2/builds/{}/sessions/{}/devicelogs", + build_id, session_id + ), + "xcuitest" => format!( + "app-automate/xcuitest/v2/builds/{}/sessions/{}/devicelogs", + build_id, session_id + ), + _ => return Err(anyhow!("unsupported platform: {}", platform)), + }; + + let resp = self + .http + .get(self.api(&path)) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .send() + .with_context(|| format!("fetching device logs for session {}", session_id))?; + + let status = resp.status(); + let text = resp.text().context("reading device logs response")?; + + if !status.is_success() { + return Err(anyhow!( + "Failed to fetch device logs (status {}): {}", + status, + text + )); + } + + Ok(text) + } + + /// Extract benchmark results from device logs + /// Looks for JSON output matching BenchReport format + pub fn extract_benchmark_results(&self, logs: &str) -> Result> { + let mut results = Vec::new(); + + // Look for JSON objects that contain benchmark-related fields + for line in logs.lines() { + let trimmed = line.trim(); + if (trimmed.starts_with('{') && trimmed.ends_with('}')) + || (trimmed.contains("\"function\"") && trimmed.contains("\"samples\"")) + { + if let Ok(json) = serde_json::from_str::(trimmed) { + // Check if this looks like a benchmark report + if json.get("function").is_some() || json.get("samples").is_some() { + results.push(json); + } + } + } + } + + if results.is_empty() { + Err(anyhow!("No benchmark results found in device logs")) + } else { + Ok(results) + } + } + + /// Extract performance metrics from device logs + /// Looks for JSON objects with "type":"performance" or similar performance indicators + pub fn extract_performance_metrics(&self, logs: &str) -> Result { + let mut snapshots = Vec::new(); + + for line in logs.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('{') && trimmed.ends_with('}') { + if let Ok(json) = serde_json::from_str::(trimmed) { + // Check if this looks like a performance metric + if json.get("type").and_then(|t| t.as_str()) == Some("performance") + || json.get("memory").is_some() + || json.get("cpu").is_some() + { + if let Ok(snapshot) = serde_json::from_value::(json) { + snapshots.push(snapshot); + } + } + } + } + } + + Ok(PerformanceMetrics::from_snapshots(snapshots)) + } + + /// Wait for build completion and fetch all results including performance metrics + /// + /// Returns both benchmark results and performance metrics + pub fn wait_and_fetch_all_results( + &self, + build_id: &str, + platform: &str, + timeout_secs: Option, + ) -> Result<( + std::collections::HashMap>, + std::collections::HashMap, + )> { + let timeout = timeout_secs.unwrap_or(600); + + println!( + "Waiting for build {} to complete (timeout: {}s)...", + build_id, timeout + ); + let build_status = self.poll_build_completion(build_id, platform, timeout, 10)?; + + println!("Build completed with status: {}", build_status.status); + println!( + "Fetching results from {} device(s)...", + build_status.devices.len() + ); + + let mut benchmark_results = std::collections::HashMap::new(); + let mut performance_metrics = std::collections::HashMap::new(); + + for device in &build_status.devices { + println!( + " Fetching logs for {} (session: {})...", + device.device, device.session_id + ); + + match self.get_device_logs(build_id, &device.session_id, platform) { + Ok(logs) => { + // Extract benchmark results + match self.extract_benchmark_results(&logs) { + Ok(bench_results) => { + println!(" Found {} benchmark result(s)", bench_results.len()); + benchmark_results.insert(device.device.clone(), bench_results); + } + Err(e) => { + println!(" Warning: No benchmark results - {}", e); + } + } + + // Extract performance metrics + match self.extract_performance_metrics(&logs) { + Ok(perf_metrics) if perf_metrics.sample_count > 0 => { + println!( + " Found {} performance metric snapshot(s)", + perf_metrics.sample_count + ); + performance_metrics.insert(device.device.clone(), perf_metrics); + } + Ok(_) => { + println!(" No performance metrics found"); + } + Err(e) => { + println!(" Warning: Failed to extract performance metrics - {}", e); + } + } + } + Err(e) => { + println!(" Failed to fetch logs: {}", e); + } + } + } + + if benchmark_results.is_empty() { + Err(anyhow!("No benchmark results found from any device")) + } else { + Ok((benchmark_results, performance_metrics)) + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppUpload { + #[serde(alias = "appUrl")] + pub app_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TestSuiteUpload { + #[serde(alias = "test_suite_url", alias = "testSuiteUrl")] + pub test_suite_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScheduledRun { + pub build_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildStatus { + pub build_id: String, + pub status: String, + pub duration: Option, + pub devices: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PerformanceSnapshot { + #[serde(default)] + pub timestamp_ms: Option, + #[serde(flatten)] + pub metrics: PerformanceData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PerformanceData { + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryMetrics { + #[serde(alias = "used_mb", alias = "usedMb")] + pub used_mb: Option, + #[serde(alias = "max_mb", alias = "maxMb")] + pub max_mb: Option, + #[serde(alias = "available_mb", alias = "availableMb")] + pub available_mb: Option, + #[serde(alias = "total_mb", alias = "totalMb")] + pub total_mb: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CpuMetrics { + #[serde(alias = "usage_percent", alias = "usagePercent")] + pub usage_percent: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PerformanceMetrics { + pub sample_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub snapshots: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AggregateMemoryMetrics { + pub peak_mb: f64, + pub average_mb: f64, + pub min_mb: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AggregateCpuMetrics { + pub peak_percent: f64, + pub average_percent: f64, + pub min_percent: f64, +} + +impl PerformanceMetrics { + pub fn from_snapshots(snapshots: Vec) -> Self { + if snapshots.is_empty() { + return Self::default(); + } + + let sample_count = snapshots.len(); + + // Aggregate memory metrics + let memory_values: Vec = snapshots + .iter() + .filter_map(|s| s.metrics.memory.as_ref()?.used_mb) + .collect(); + + let memory = if !memory_values.is_empty() { + Some(AggregateMemoryMetrics { + peak_mb: memory_values + .iter() + .fold(f64::NEG_INFINITY, |a, &b| a.max(b)), + average_mb: memory_values.iter().sum::() / memory_values.len() as f64, + min_mb: memory_values.iter().fold(f64::INFINITY, |a, &b| a.min(b)), + }) + } else { + None + }; + + // Aggregate CPU metrics + let cpu_values: Vec = snapshots + .iter() + .filter_map(|s| s.metrics.cpu.as_ref()?.usage_percent) + .collect(); + + let cpu = if !cpu_values.is_empty() { + Some(AggregateCpuMetrics { + peak_percent: cpu_values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b)), + average_percent: cpu_values.iter().sum::() / cpu_values.len() as f64, + min_percent: cpu_values.iter().fold(f64::INFINITY, |a, &b| a.min(b)), + }) + } else { + None + }; + + Self { + sample_count, + memory, + cpu, + snapshots, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceSession { + pub device: String, + #[serde(alias = "sessionId", alias = "session_id")] + pub session_id: String, + pub status: String, + #[serde(alias = "deviceLogs", alias = "device_logs")] + pub device_logs: Option, +} + +// Internal response format from BrowserStack API +#[derive(Debug, Deserialize)] +struct BuildStatusResponse { + #[serde(alias = "buildId", alias = "build_id")] + build_id: String, + status: String, + duration: Option, + devices: Option>, +} + +#[derive(Debug, Deserialize)] +struct DeviceSessionResponse { + device: String, + #[serde(alias = "sessionId", alias = "session_id", alias = "hashed_id")] + session_id: String, + status: String, + #[serde(alias = "deviceLogs", alias = "device_logs")] + device_logs: Option, +} + +fn build_status_from_value(value: Value) -> Result { + if let Ok(response) = serde_json::from_value::(value.clone()) { + return Ok(response.into()); + } + + let build_id = value + .get("build_id") + .or_else(|| value.get("buildId")) + .or_else(|| value.get("id")) + .and_then(|val| val.as_str()) + .ok_or_else(|| anyhow!("build status response missing build id"))? + .to_string(); + let status = value + .get("status") + .and_then(|val| val.as_str()) + .unwrap_or("unknown") + .to_string(); + let duration = value.get("duration").and_then(|val| val.as_u64()); + + let mut devices = Vec::new(); + if let Some(entries) = value.get("devices").and_then(|val| val.as_array()) { + for entry in entries { + let device_name = entry + .get("device") + .and_then(|val| val.as_str()) + .unwrap_or("unknown") + .to_string(); + if let Some(sessions) = entry.get("sessions").and_then(|val| val.as_array()) { + for session in sessions { + let session_id = session + .get("id") + .or_else(|| session.get("session_id")) + .or_else(|| session.get("sessionId")) + .and_then(|val| val.as_str()); + if let Some(session_id) = session_id { + let session_status = session + .get("status") + .and_then(|val| val.as_str()) + .unwrap_or("unknown") + .to_string(); + devices.push(DeviceSession { + device: device_name.clone(), + session_id: session_id.to_string(), + status: session_status, + device_logs: None, + }); + } + } + } + } + } + + Ok(BuildStatus { + build_id, + status, + duration, + devices, + }) +} + +impl From for BuildStatus { + fn from(resp: BuildStatusResponse) -> Self { + BuildStatus { + build_id: resp.build_id, + status: resp.status, + duration: resp.duration, + devices: resp + .devices + .unwrap_or_default() + .into_iter() + .map(|d| DeviceSession { + device: d.device, + session_id: d.session_id, + status: d.status, + device_logs: d.device_logs, + }) + .collect(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct BuildRequest { + app: String, + test_suite: String, + devices: Vec, + device_logs: bool, + disable_animations: bool, + #[serde(skip_serializing_if = "Option::is_none")] + build_name: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct XcuitestBuildRequest { + app: String, + test_suite: String, + devices: Vec, + device_logs: bool, + #[serde(skip_serializing_if = "Option::is_none")] + build_name: Option, + #[serde(rename = "only-testing", skip_serializing_if = "Option::is_none")] + only_testing: Option>, +} + +#[derive(Debug, Deserialize)] +struct BuildResponse { + #[serde(alias = "build_id", alias = "buildId")] + build_id: String, +} + +fn parse_response(resp: Response, context: &str) -> Result { + let status = resp.status(); + let text = resp + .text() + .with_context(|| format!("reading BrowserStack API response body for {}", context))?; + + if !status.is_success() { + return Err(anyhow!( + "BrowserStack API {} failed (status {}): {}", + context, + status, + text + )); + } + + serde_json::from_str(&text) + .with_context(|| format!("parsing BrowserStack API response for {}", context)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_missing_artifact() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + let missing = Path::new("/tmp/definitely-missing-file"); + assert!(client.upload_espresso_app(missing).is_err()); + } + + #[test] + fn suppresses_dead_code_warning_for_test_helper() { + // This test uses with_base_url to verify it works and suppress the warning + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap() + .with_base_url("https://test.example.com"); + + assert_eq!(client.base_url, "https://test.example.com"); + } + + #[test] + fn new_client_uses_default_base_url() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "testuser".into(), + access_key: "testkey".into(), + }, + Some("test-project".into()), + ) + .unwrap(); + + assert_eq!(client.base_url, DEFAULT_BASE_URL); + assert_eq!(client.project, Some("test-project".to_string())); + } + + #[test] + fn api_constructs_url_correctly() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let url = client.api("app-automate/espresso/v2/app"); + assert_eq!( + url, + "https://api-cloud.browserstack.com/app-automate/espresso/v2/app" + ); + } + + #[test] + fn api_handles_leading_slash() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let url = client.api("/app-automate/builds"); + assert_eq!( + url, + "https://api-cloud.browserstack.com/app-automate/builds" + ); + } + + #[test] + fn api_handles_trailing_slash_in_base_url() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap() + .with_base_url("https://test.example.com/"); + + let url = client.api("endpoint"); + assert_eq!(url, "https://test.example.com/endpoint"); + } + + #[test] + fn schedule_espresso_run_rejects_empty_devices() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let result = client.schedule_espresso_run(&[], "bs://app123", "bs://test456"); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("empty")); + } + + #[test] + fn schedule_espresso_run_rejects_empty_app_url() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let result = client.schedule_espresso_run(&["Pixel 7-13".to_string()], "", "bs://test456"); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("app_url")); + } + + #[test] + fn schedule_espresso_run_rejects_empty_test_suite_url() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let result = client.schedule_espresso_run(&["Pixel 7-13".to_string()], "bs://app123", ""); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("test_suite_url")); + } + + #[test] + fn schedule_xcuitest_run_rejects_empty_devices() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let result = client.schedule_xcuitest_run(&[], "bs://app123", "bs://test456"); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("empty")); + } + + #[test] + fn upload_xcuitest_app_rejects_missing_artifact() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let missing = Path::new("/tmp/nonexistent-ios-app.ipa"); + assert!(client.upload_xcuitest_app(missing).is_err()); + } + + #[test] + fn upload_xcuitest_test_suite_rejects_missing_artifact() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let missing = Path::new("/tmp/nonexistent-test-suite.zip"); + assert!(client.upload_xcuitest_test_suite(missing).is_err()); + } + + #[test] + fn extract_benchmark_results_finds_json_in_logs() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +Some device output +2026-01-14 12:00:00 Starting test +{"function": "sample_fns::fibonacci", "samples": [{"duration_ns": 1000}, {"duration_ns": 1200}], "mean_ns": 1100} +More output here +Test completed + "#; + + let results = client.extract_benchmark_results(logs).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!( + results[0].get("function").unwrap().as_str().unwrap(), + "sample_fns::fibonacci" + ); + assert_eq!(results[0].get("mean_ns").unwrap().as_u64().unwrap(), 1100); + } + + #[test] + fn extract_benchmark_results_handles_multiple_results() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +{"function": "test1", "samples": [{"duration_ns": 1000}]} +Some other output +{"function": "test2", "samples": [{"duration_ns": 2000}]} + "#; + + let results = client.extract_benchmark_results(logs).unwrap(); + assert_eq!(results.len(), 2); + assert_eq!( + results[0].get("function").unwrap().as_str().unwrap(), + "test1" + ); + assert_eq!( + results[1].get("function").unwrap().as_str().unwrap(), + "test2" + ); + } + + #[test] + fn extract_benchmark_results_returns_error_when_no_results() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +Just some regular logs +No benchmark data here +Test completed + "#; + + let result = client.extract_benchmark_results(logs); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("No benchmark results") + ); + } + + #[test] + fn extract_benchmark_results_ignores_invalid_json() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +{"invalid": "json without function or samples"} +{"function": "test1", "samples": [{"duration_ns": 1000}]} +{broken json} + "#; + + let results = client.extract_benchmark_results(logs).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!( + results[0].get("function").unwrap().as_str().unwrap(), + "test1" + ); + } + + #[test] + fn build_status_conversion_from_response() { + let response = BuildStatusResponse { + build_id: "test123".to_string(), + status: "done".to_string(), + duration: Some(120), + devices: Some(vec![DeviceSessionResponse { + device: "Pixel 7-13".to_string(), + session_id: "session123".to_string(), + status: "passed".to_string(), + device_logs: Some("https://example.com/logs".to_string()), + }]), + }; + + let status: BuildStatus = response.into(); + assert_eq!(status.build_id, "test123"); + assert_eq!(status.status, "done"); + assert_eq!(status.duration, Some(120)); + assert_eq!(status.devices.len(), 1); + assert_eq!(status.devices[0].device, "Pixel 7-13"); + assert_eq!(status.devices[0].session_id, "session123"); + } + + #[test] + fn build_status_conversion_handles_missing_devices() { + let response = BuildStatusResponse { + build_id: "test456".to_string(), + status: "running".to_string(), + duration: None, + devices: None, + }; + + let status: BuildStatus = response.into(); + assert_eq!(status.build_id, "test456"); + assert_eq!(status.status, "running"); + assert_eq!(status.devices.len(), 0); + } + + #[test] + fn device_session_deserializes_from_json() { + let json = r#"{ + "device": "iPhone 14-16", + "sessionId": "abc123", + "status": "passed", + "deviceLogs": "https://example.com/logs" + }"#; + + let session: DeviceSessionResponse = serde_json::from_str(json).unwrap(); + assert_eq!(session.device, "iPhone 14-16"); + assert_eq!(session.session_id, "abc123"); + assert_eq!(session.status, "passed"); + } + + #[test] + fn device_session_handles_alternative_field_names() { + let json = r#"{ + "device": "Pixel 7", + "hashed_id": "xyz789", + "status": "running", + "device_logs": "https://example.com/logs" + }"#; + + let session: DeviceSessionResponse = serde_json::from_str(json).unwrap(); + assert_eq!(session.device, "Pixel 7"); + assert_eq!(session.session_id, "xyz789"); + } + + #[test] + fn extract_performance_metrics_finds_memory_and_cpu() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +Some device output +2026-01-14 12:00:00 Starting test +{"type": "performance", "timestamp_ms": 1705238400000, "memory": {"used_mb": 128.5, "max_mb": 512.0}, "cpu": {"usage_percent": 45.2}} +{"type": "performance", "timestamp_ms": 1705238401000, "memory": {"used_mb": 135.0, "max_mb": 512.0}, "cpu": {"usage_percent": 52.1}} +More output here + "#; + + let metrics = client.extract_performance_metrics(logs).unwrap(); + assert_eq!(metrics.sample_count, 2); + + assert!(metrics.memory.is_some()); + let mem = metrics.memory.as_ref().unwrap(); + assert_eq!(mem.peak_mb, 135.0); + assert_eq!(mem.average_mb, 131.75); // (128.5 + 135.0) / 2 + assert_eq!(mem.min_mb, 128.5); + + assert!(metrics.cpu.is_some()); + let cpu = metrics.cpu.as_ref().unwrap(); + assert_eq!(cpu.peak_percent, 52.1); + assert!((cpu.average_percent - 48.65).abs() < 0.001); // (45.2 + 52.1) / 2 + assert_eq!(cpu.min_percent, 45.2); + } + + #[test] + fn extract_performance_metrics_handles_memory_only() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +{"memory": {"used_mb": 100.0, "max_mb": 512.0}} +{"memory": {"used_mb": 120.0, "max_mb": 512.0}} + "#; + + let metrics = client.extract_performance_metrics(logs).unwrap(); + assert_eq!(metrics.sample_count, 2); + assert!(metrics.memory.is_some()); + assert!(metrics.cpu.is_none()); + + let mem = metrics.memory.as_ref().unwrap(); + assert_eq!(mem.peak_mb, 120.0); + assert_eq!(mem.average_mb, 110.0); + } + + #[test] + fn extract_performance_metrics_handles_cpu_only() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +{"cpu": {"usage_percent": 30.5}} +{"cpu": {"usage_percent": 40.5}} +{"cpu": {"usage_percent": 35.0}} + "#; + + let metrics = client.extract_performance_metrics(logs).unwrap(); + assert_eq!(metrics.sample_count, 3); + assert!(metrics.memory.is_none()); + assert!(metrics.cpu.is_some()); + + let cpu = metrics.cpu.as_ref().unwrap(); + assert_eq!(cpu.peak_percent, 40.5); + assert_eq!(cpu.min_percent, 30.5); + // Average: (30.5 + 40.5 + 35.0) / 3 = 35.333... + assert!((cpu.average_percent - 35.333333).abs() < 0.001); + } + + #[test] + fn extract_performance_metrics_returns_empty_when_no_metrics() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +Just some regular logs +No performance data here +Test completed + "#; + + let metrics = client.extract_performance_metrics(logs).unwrap(); + assert_eq!(metrics.sample_count, 0); + assert!(metrics.memory.is_none()); + assert!(metrics.cpu.is_none()); + } + + #[test] + fn extract_performance_metrics_ignores_invalid_json() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +{"invalid": "json without performance fields"} +{"memory": {"used_mb": 100.0}} +{broken json} +{"cpu": {"usage_percent": 50.0}} + "#; + + let metrics = client.extract_performance_metrics(logs).unwrap(); + assert_eq!(metrics.sample_count, 2); + assert!(metrics.memory.is_some()); + assert!(metrics.cpu.is_some()); + } + + #[test] + fn extract_performance_metrics_handles_alternative_field_names() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + // Test camelCase variants + let logs = r#" +{"memory": {"usedMb": 128.5, "maxMb": 512.0, "availableMb": 383.5}} + "#; + + let metrics = client.extract_performance_metrics(logs).unwrap(); + assert_eq!(metrics.sample_count, 1); + + let mem = metrics.memory.as_ref().unwrap(); + assert_eq!(mem.peak_mb, 128.5); + } + + #[test] + fn performance_metrics_aggregates_correctly_with_mixed_data() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +{"memory": {"used_mb": 100.0}} +{"cpu": {"usage_percent": 30.0}} +{"memory": {"used_mb": 150.0}, "cpu": {"usage_percent": 50.0}} + "#; + + let metrics = client.extract_performance_metrics(logs).unwrap(); + assert_eq!(metrics.sample_count, 3); + + // Memory should aggregate from snapshots 1 and 3 + let mem = metrics.memory.as_ref().unwrap(); + assert_eq!(mem.peak_mb, 150.0); + assert_eq!(mem.min_mb, 100.0); + assert_eq!(mem.average_mb, 125.0); // (100 + 150) / 2 + + // CPU should aggregate from snapshots 2 and 3 + let cpu = metrics.cpu.as_ref().unwrap(); + assert_eq!(cpu.peak_percent, 50.0); + assert_eq!(cpu.min_percent, 30.0); + assert_eq!(cpu.average_percent, 40.0); // (30 + 50) / 2 + } +} diff --git a/crates/mobench/src/main.rs b/crates/mobench/src/main.rs new file mode 100644 index 0000000..1559ea2 --- /dev/null +++ b/crates/mobench/src/main.rs @@ -0,0 +1,1385 @@ +use anyhow::{Context, Result, anyhow, bail}; +use clap::{Parser, Subcommand, ValueEnum}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use browserstack::{BrowserStackAuth, BrowserStackClient}; + +mod browserstack; + +/// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. +#[derive(Parser, Debug)] +#[command(name = "mobench", author, version, about = "Mobile Rust benchmarking orchestrator", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Run a benchmark against a target platform (mobile integration stub for now). + Run { + #[arg(long, value_enum)] + target: MobileTarget, + #[arg(long, help = "Fully-qualified Rust function to benchmark")] + function: String, + #[arg(long, default_value_t = 100)] + iterations: u32, + #[arg(long, default_value_t = 10)] + warmup: u32, + #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] + devices: Vec, + #[arg(long, help = "Optional path to config file")] + config: Option, + #[arg(long, help = "Optional output path for JSON report")] + output: Option, + #[arg(long, help = "Skip mobile builds and only run the host harness")] + local_only: bool, + #[arg( + long, + help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" + )] + ios_app: Option, + #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] + ios_test_suite: Option, + #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] + fetch: bool, + #[arg(long, default_value = "target/browserstack")] + fetch_output_dir: PathBuf, + #[arg(long, default_value_t = 10)] + fetch_poll_interval_secs: u64, + #[arg(long, default_value_t = 1800)] + fetch_timeout_secs: u64, + }, + /// Scaffold a base config file for the CLI. + Init { + #[arg(long, default_value = "bench-config.toml")] + output: PathBuf, + #[arg(long, value_enum, default_value_t = MobileTarget::Android)] + target: MobileTarget, + }, + /// Generate a sample device matrix file. + Plan { + #[arg(long, default_value = "device-matrix.yaml")] + output: PathBuf, + }, + /// Fetch BrowserStack build artifacts (logs, session JSON) for CI. + Fetch { + #[arg(long, value_enum)] + target: MobileTarget, + #[arg(long)] + build_id: String, + #[arg(long, default_value = "target/browserstack")] + output_dir: PathBuf, + #[arg(long, default_value_t = true)] + wait: bool, + #[arg(long, default_value_t = 10)] + poll_interval_secs: u64, + #[arg(long, default_value_t = 1800)] + timeout_secs: u64, + }, + /// Initialize a new benchmark project with SDK (Phase 1 MVP). + InitSdk { + #[arg(long, value_enum)] + target: SdkTarget, + #[arg(long, default_value = "bench-project")] + project_name: String, + #[arg(long, default_value = ".")] + output_dir: PathBuf, + #[arg(long, help = "Generate example benchmarks")] + examples: bool, + }, + /// Build mobile artifacts (Phase 1 MVP). + Build { + #[arg(long, value_enum)] + target: SdkTarget, + #[arg(long, help = "Build in release mode")] + release: bool, + }, + /// Package iOS app as IPA for distribution or testing. + PackageIpa { + #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] + scheme: String, + #[arg(long, value_enum, default_value = "adhoc", help = "Signing method")] + method: IosSigningMethodArg, + }, + /// List all discovered benchmark functions (Phase 1 MVP). + List, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum MobileTarget { + Android, + Ios, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +enum SdkTarget { + Android, + Ios, + Both, +} + +impl From for mobench_sdk::Target { + fn from(target: SdkTarget) -> Self { + match target { + SdkTarget::Android => mobench_sdk::Target::Android, + SdkTarget::Ios => mobench_sdk::Target::Ios, + SdkTarget::Both => mobench_sdk::Target::Both, + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +enum IosSigningMethodArg { + /// Ad-hoc signing (no Apple ID needed, works for BrowserStack) + Adhoc, + /// Development signing (requires Apple Developer account) + Development, +} + +impl From for mobench_sdk::builders::SigningMethod { + fn from(arg: IosSigningMethodArg) -> Self { + match arg { + IosSigningMethodArg::Adhoc => mobench_sdk::builders::SigningMethod::AdHoc, + IosSigningMethodArg::Development => mobench_sdk::builders::SigningMethod::Development, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct BrowserStackConfig { + app_automate_username: String, + app_automate_access_key: String, + project: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct IosXcuitestArtifacts { + app: PathBuf, + test_suite: PathBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +struct BenchConfig { + target: MobileTarget, + function: String, + iterations: u32, + warmup: u32, + device_matrix: PathBuf, + browserstack: BrowserStackConfig, + #[serde(skip_serializing_if = "Option::is_none", default)] + ios_xcuitest: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct DeviceEntry { + name: String, + os: String, + os_version: String, + tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DeviceMatrix { + devices: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RunSpec { + target: MobileTarget, + function: String, + iterations: u32, + warmup: u32, + devices: Vec, + #[serde(skip_serializing, skip_deserializing, default)] + browserstack: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + ios_xcuitest: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "platform", rename_all = "lowercase")] +enum MobileArtifacts { + Android { + apk: PathBuf, + }, + Ios { + xcframework: PathBuf, + header: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + app: Option, + #[serde(skip_serializing_if = "Option::is_none")] + test_suite: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct RunSummary { + spec: RunSpec, + artifacts: Option, + local_report: Value, + remote_run: Option, + #[serde(skip_serializing_if = "Option::is_none")] + benchmark_results: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + performance_metrics: + Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "platform", rename_all = "lowercase")] +enum RemoteRun { + Android { + app_url: String, + build_id: String, + }, + Ios { + app_url: String, + test_suite_url: String, + build_id: String, + }, +} + +fn main() -> Result<()> { + load_dotenv(); + let cli = Cli::parse(); + match cli.command { + Command::Run { + target, + function, + iterations, + warmup, + devices, + config, + output, + local_only, + ios_app, + ios_test_suite, + fetch, + fetch_output_dir, + fetch_poll_interval_secs, + fetch_timeout_secs, + } => { + let spec = resolve_run_spec( + target, + function, + iterations, + warmup, + devices, + config.as_deref(), + ios_app, + ios_test_suite, + )?; + println!( + "Preparing benchmark run for {:?}: {} (iterations={}, warmup={})", + spec.target, spec.function, spec.iterations, spec.warmup + ); + persist_mobile_spec(&spec)?; + if !spec.devices.is_empty() { + println!("Devices: {}", spec.devices.join(", ")); + } + if let Some(path) = &output { + println!("JSON summary will be written to {:?}", path); + } + + // Skip local smoke test - sample-fns uses direct dispatch, not inventory registry + // Benchmarks will run on the actual mobile device + println!("Skipping local smoke test - benchmarks will run on mobile device"); + let local_report = json!({ + "skipped": true, + "reason": "Local smoke test disabled - benchmarks run on mobile device only" + }); + let mut remote_run = None; + let artifacts = if local_only { + println!("Skipping mobile build: --local-only set"); + None + } else { + match spec.target { + MobileTarget::Android => { + let ndk = std::env::var("ANDROID_NDK_HOME") + .context("ANDROID_NDK_HOME must be set for Android builds")?; + let build = run_android_build(&ndk)?; + let apk = build.app_path; + println!("Built Android APK at {:?}", apk); + if spec.devices.is_empty() { + println!("Skipping BrowserStack upload/run: no devices provided"); + Some(MobileArtifacts::Android { apk }) + } else { + let test_apk = build.test_suite_path.as_ref().context( + "Android test suite APK missing; run ./gradlew assembleDebugAndroidTest", + )?; + let run = trigger_browserstack_espresso(&spec, &apk, test_apk)?; + remote_run = Some(run); + Some(MobileArtifacts::Android { apk }) + } + } + MobileTarget::Ios => { + let (xcframework, header) = run_ios_build()?; + println!("Built iOS xcframework at {:?}", xcframework); + let ios_xcuitest = spec.ios_xcuitest.clone(); + + if spec.devices.is_empty() { + println!("Skipping BrowserStack upload/run: no devices provided"); + } else { + let xcui = spec.ios_xcuitest.as_ref().context( + "iOS XCUITest artifacts required when targeting BrowserStack devices; provide --ios-app and --ios-test-suite or set ios_xcuitest in the config", + )?; + let run = trigger_browserstack_xcuitest(&spec, xcui)?; + remote_run = Some(run); + } + + Some(MobileArtifacts::Ios { + xcframework, + header, + app: ios_xcuitest.as_ref().map(|a| a.app.clone()), + test_suite: ios_xcuitest.map(|a| a.test_suite), + }) + } + } + }; + + let mut summary = RunSummary { + spec, + artifacts, + local_report, + remote_run, + benchmark_results: None, + performance_metrics: None, + }; + + if fetch { + if let Some(remote) = &summary.remote_run { + let build_id = match remote { + RemoteRun::Android { build_id, .. } => build_id, + RemoteRun::Ios { build_id, .. } => build_id, + }; + let creds = + resolve_browserstack_credentials(summary.spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + )?; + + let platform = match summary.spec.target { + MobileTarget::Android => "espresso", + MobileTarget::Ios => "xcuitest", + }; + + let dashboard_url = format!( + "https://app-automate.browserstack.com/dashboard/v2/builds/{}", + build_id + ); + + println!("Waiting for build {} to complete...", build_id); + println!("Dashboard: {}", dashboard_url); + + match client.wait_and_fetch_all_results( + build_id, + platform, + Some(fetch_timeout_secs), + ) { + Ok((bench_results, perf_metrics)) => { + println!( + "\n✓ Successfully fetched results from {} device(s)", + bench_results.len() + ); + + // Print summary of benchmark results + for (device, results) in &bench_results { + println!("\n Device: {}", device); + for (idx, result) in results.iter().enumerate() { + if let Some(function) = + result.get("function").and_then(|f| f.as_str()) + { + println!(" Benchmark {}: {}", idx + 1, function); + } + if let Some(mean) = + result.get("mean_ns").and_then(|m| m.as_u64()) + { + println!( + " Mean: {} ns ({:.2} ms)", + mean, + mean as f64 / 1_000_000.0 + ); + } + if let Some(samples) = + result.get("samples").and_then(|s| s.as_array()) + { + println!(" Samples: {}", samples.len()); + } + } + + // Print performance metrics if available + if let Some(metrics) = perf_metrics.get(device) { + if metrics.sample_count > 0 { + println!("\n Performance Metrics:"); + if let Some(mem) = &metrics.memory { + println!(" Memory:"); + println!(" Peak: {:.2} MB", mem.peak_mb); + println!(" Average: {:.2} MB", mem.average_mb); + } + if let Some(cpu) = &metrics.cpu { + println!(" CPU:"); + println!(" Peak: {:.1}%", cpu.peak_percent); + println!( + " Average: {:.1}%", + cpu.average_percent + ); + } + } + } + } + + println!("\n View full results: {}", dashboard_url); + summary.benchmark_results = Some(bench_results); + summary.performance_metrics = Some(perf_metrics); + } + Err(e) => { + println!("\nWarning: Failed to fetch results: {}", e); + println!("Build may still be accessible at: {}", dashboard_url); + } + } + + // Also save detailed artifacts to separate directory + let output_root = fetch_output_dir.join(build_id); + if let Err(e) = fetch_browserstack_artifacts( + &client, + summary.spec.target, + build_id, + &output_root, + false, // Don't wait again, we already did + fetch_poll_interval_secs, + fetch_timeout_secs, + ) { + println!("Warning: Failed to fetch detailed artifacts: {}", e); + } + } else { + println!("No BrowserStack run to fetch (devices not provided?)"); + } + } + + write_summary(&summary, output.as_deref())?; + } + Command::Init { output, target } => { + write_config_template(&output, target)?; + println!("Wrote starter config to {:?}", output); + } + Command::Plan { output } => { + write_device_matrix_template(&output)?; + println!("Wrote sample device matrix to {:?}", output); + } + Command::Fetch { + target, + build_id, + output_dir, + wait, + poll_interval_secs, + timeout_secs, + } => { + let creds = resolve_browserstack_credentials(None)?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + )?; + let output_root = output_dir.join(&build_id); + fetch_browserstack_artifacts( + &client, + target, + &build_id, + &output_root, + wait, + poll_interval_secs, + timeout_secs, + )?; + } + Command::InitSdk { + target, + project_name, + output_dir, + examples, + } => { + cmd_init_sdk(target, project_name, output_dir, examples)?; + } + Command::Build { target, release } => { + cmd_build(target, release)?; + } + Command::PackageIpa { scheme, method } => { + cmd_package_ipa(&scheme, method)?; + } + Command::List => { + cmd_list()?; + } + } + + Ok(()) +} + +fn write_config_template(path: &Path, target: MobileTarget) -> Result<()> { + ensure_can_write(path)?; + + let ios_xcuitest = if target == MobileTarget::Ios { + Some(IosXcuitestArtifacts { + app: PathBuf::from("target/ios/BenchRunner.ipa"), + test_suite: PathBuf::from("target/ios/BenchRunnerUITests.zip"), + }) + } else { + None + }; + + let cfg = BenchConfig { + target, + function: "sample_fns::fibonacci".into(), + iterations: 100, + warmup: 10, + device_matrix: PathBuf::from("device-matrix.yaml"), + browserstack: BrowserStackConfig { + app_automate_username: "${BROWSERSTACK_USERNAME}".into(), + app_automate_access_key: "${BROWSERSTACK_ACCESS_KEY}".into(), + project: Some("mobile-bench-rs".into()), + }, + ios_xcuitest, + }; + + let contents = toml::to_string_pretty(&cfg)?; + write_file(path, contents.as_bytes()) +} + +fn write_device_matrix_template(path: &Path) -> Result<()> { + ensure_can_write(path)?; + + let matrix = DeviceMatrix { + devices: vec![ + DeviceEntry { + name: "Pixel 7".into(), + os: "android".into(), + os_version: "13.0".into(), + tags: Some(vec!["default".into(), "pixel".into()]), + }, + DeviceEntry { + name: "iPhone 14".into(), + os: "ios".into(), + os_version: "16".into(), + tags: Some(vec!["default".into(), "iphone".into()]), + }, + ], + }; + + let contents = serde_yaml::to_string(&matrix)?; + write_file(path, contents.as_bytes()) +} + +fn fetch_browserstack_artifacts( + client: &BrowserStackClient, + target: MobileTarget, + build_id: &str, + output_root: &Path, + wait: bool, + poll_interval_secs: u64, + timeout_secs: u64, +) -> Result<()> { + fs::create_dir_all(output_root) + .with_context(|| format!("creating output dir {:?}", output_root))?; + + let base = browserstack_base_path(target); + let build_path = format!("{base}/builds/{build_id}"); + let sessions_path = format!("{base}/builds/{build_id}/sessions"); + + if wait { + wait_for_build(client, &build_path, poll_interval_secs, timeout_secs)?; + } + + let build_json = client.get_json(&build_path)?; + write_json(output_root.join("build.json"), &build_json)?; + + let mut session_ids = extract_session_ids(&build_json); + if session_ids.is_empty() { + match client.get_json(&sessions_path) { + Ok(value) => { + write_json(output_root.join("sessions.json"), &value)?; + session_ids = extract_session_ids(&value); + } + Err(err) => { + let msg = shorten_html_error(&err.to_string()); + println!("Sessions endpoint unavailable; falling back to build.json: {msg}"); + } + } + } + + if session_ids.is_empty() { + println!("No sessions found for build {}", build_id); + return Ok(()); + } + + for session_id in session_ids { + let session_path = format!("{base}/builds/{build_id}/sessions/{session_id}"); + let session_json = client.get_json(&session_path)?; + let session_dir = output_root.join(format!("session-{}", session_id)); + fs::create_dir_all(&session_dir) + .with_context(|| format!("creating session dir {:?}", session_dir))?; + write_json(session_dir.join("session.json"), &session_json)?; + + let mut bench_report: Option = None; + for (key, url) in extract_url_fields(&session_json) { + let file_name = filename_for_url(&key, &url); + let dest = session_dir.join(file_name); + if let Err(err) = client.download_url(&url, &dest) { + println!("Skipping download for {key}: {err}"); + continue; + } + if (key.contains("device_log") + || key.contains("instrumentation_log") + || key.contains("app_log")) + && let Ok(contents) = fs::read_to_string(&dest) + && let Some(parsed) = extract_bench_json(&contents) + { + bench_report = Some(parsed); + } + } + + if let Some(report) = bench_report { + write_json(session_dir.join("bench-report.json"), &report)?; + } + } + + println!("Fetched BrowserStack artifacts to {:?}", output_root); + Ok(()) +} + +fn browserstack_base_path(target: MobileTarget) -> &'static str { + match target { + MobileTarget::Android => "app-automate/espresso/v2", + MobileTarget::Ios => "app-automate/xcuitest/v2", + } +} + +fn wait_for_build( + client: &BrowserStackClient, + build_path: &str, + poll_interval_secs: u64, + timeout_secs: u64, +) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + loop { + let build_json = client.get_json(build_path)?; + if let Some(status) = build_json + .get("status") + .and_then(|val| val.as_str()) + .map(|val| val.to_lowercase()) + { + if status == "failed" || status == "error" { + println!("Build status: {status}"); + return Ok(()); + } + if status == "done" || status == "passed" || status == "completed" { + println!("Build status: {status}"); + return Ok(()); + } + println!("Build status: {status} (waiting)"); + } else { + println!("Build status missing; continuing without wait"); + return Ok(()); + } + + if Instant::now() >= deadline { + println!("Timed out waiting for build status"); + return Ok(()); + } + std::thread::sleep(Duration::from_secs(poll_interval_secs)); + } +} + +fn extract_session_ids(value: &Value) -> Vec { + let sessions = value + .get("sessions") + .and_then(|val| val.as_array()) + .or_else(|| value.as_array()); + let mut ids = Vec::new(); + if let Some(entries) = sessions { + for entry in entries { + let id = entry + .get("id") + .or_else(|| entry.get("session_id")) + .or_else(|| entry.get("sessionId")) + .and_then(|val| val.as_str()); + if let Some(id) = id { + ids.push(id.to_string()); + } + } + } + if ids.is_empty() + && let Some(devices) = value.get("devices").and_then(|val| val.as_array()) + { + for device in devices { + if let Some(sessions) = device.get("sessions").and_then(|val| val.as_array()) { + for entry in sessions { + if let Some(id) = entry.get("id").and_then(|val| val.as_str()) { + ids.push(id.to_string()); + } + } + } + } + } + ids +} + +fn extract_url_fields(value: &Value) -> Vec<(String, String)> { + let mut urls = Vec::new(); + extract_url_fields_recursive(value, "", &mut urls); + urls +} + +fn extract_url_fields_recursive(value: &Value, prefix: &str, out: &mut Vec<(String, String)>) { + match value { + Value::Object(map) => { + for (key, val) in map { + let next = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + if let Value::String(url) = val + && (url.starts_with("http") || url.starts_with("bs://")) + { + out.push((next.clone(), url.clone())); + } + extract_url_fields_recursive(val, &next, out); + } + } + Value::Array(items) => { + for (idx, val) in items.iter().enumerate() { + let next = format!("{}[{}]", prefix, idx); + extract_url_fields_recursive(val, &next, out); + } + } + _ => {} + } +} + +fn filename_for_url(key: &str, url: &str) -> String { + let stripped = url.split('?').next().unwrap_or(url); + let ext = Path::new(stripped) + .extension() + .and_then(|val| val.to_str()) + .unwrap_or("log"); + let mut safe = String::with_capacity(key.len()); + for ch in key.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + safe.push(ch); + } else { + safe.push('_'); + } + } + format!("{}.{}", safe, ext) +} + +fn extract_bench_json(contents: &str) -> Option { + let marker = "BENCH_JSON "; + for line in contents.lines().rev() { + if let Some(idx) = line.find(marker) { + let json_part = &line[idx + marker.len()..]; + if let Ok(value) = serde_json::from_str::(json_part) { + return Some(value); + } + } + } + None +} + +fn write_json(path: PathBuf, value: &Value) -> Result<()> { + let contents = serde_json::to_string_pretty(value)?; + write_file(&path, contents.as_bytes()) +} + +fn shorten_html_error(message: &str) -> String { + if message.contains("") || message.contains(", + config: Option<&Path>, + ios_app: Option, + ios_test_suite: Option, +) -> Result { + if let Some(cfg_path) = config { + let cfg = load_config(cfg_path)?; + let matrix = load_device_matrix(&cfg.device_matrix)?; + let device_names = matrix.devices.into_iter().map(|d| d.name).collect(); + return Ok(RunSpec { + target: cfg.target, + function: cfg.function, + iterations: cfg.iterations, + warmup: cfg.warmup, + devices: device_names, + browserstack: Some(cfg.browserstack), + ios_xcuitest: cfg.ios_xcuitest, + }); + } + + if function.trim().is_empty() { + bail!("function must not be empty"); + } + + let ios_xcuitest = match (ios_app, ios_test_suite) { + (Some(app), Some(test_suite)) => Some(IosXcuitestArtifacts { app, test_suite }), + (None, None) => None, + _ => bail!("both --ios-app and --ios-test-suite must be provided together"), + }; + + if target == MobileTarget::Ios && !devices.is_empty() && ios_xcuitest.is_none() { + bail!( + "iOS BrowserStack runs require --ios-app and --ios-test-suite or an ios_xcuitest config block" + ); + } + + Ok(RunSpec { + target, + function, + iterations, + warmup, + devices, + browserstack: None, + ios_xcuitest, + }) +} + +fn load_config(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("reading config {:?}", path))?; + toml::from_str(&contents).with_context(|| format!("parsing config {:?}", path)) +} + +fn load_device_matrix(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("reading device matrix {:?}", path))?; + serde_yaml::from_str(&contents).with_context(|| format!("parsing device matrix {:?}", path)) +} + +fn run_ios_build() -> Result<(PathBuf, PathBuf)> { + let root = repo_root()?; + let crate_name = + detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); + let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); + let cfg = mobench_sdk::BuildConfig { + target: mobench_sdk::Target::Ios, + profile: mobench_sdk::BuildProfile::Debug, + incremental: true, + }; + let result = builder.build(&cfg)?; + let header = root.join("target/ios/include").join(format!( + "{}.h", + result + .app_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("module") + )); + Ok((result.app_path, header)) +} + +#[derive(Debug, Clone)] +struct ResolvedBrowserStack { + username: String, + access_key: String, + project: Option, +} + +fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path, test_apk: &Path) -> Result { + let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username.clone(), + access_key: creds.access_key.clone(), + }, + creds.project.clone(), + )?; + + // Upload the app-under-test APK. + let upload = client.upload_espresso_app(apk)?; + + // Upload the Espresso test-suite APK produced by Gradle. + let test_upload = client.upload_espresso_test_suite(test_apk)?; + + // Schedule the Espresso build with both app and testSuite, as required by BrowserStack. + let run = client.schedule_espresso_run( + &spec.devices, + &upload.app_url, + &test_upload.test_suite_url, + )?; + println!( + "Queued BrowserStack Espresso build {} for devices: {}", + run.build_id, + spec.devices.join(", ") + ); + + Ok(RemoteRun::Android { + app_url: upload.app_url, + build_id: run.build_id, + }) +} + +fn trigger_browserstack_xcuitest( + spec: &RunSpec, + artifacts: &IosXcuitestArtifacts, +) -> Result { + let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username.clone(), + access_key: creds.access_key.clone(), + }, + creds.project.clone(), + )?; + + if !artifacts.app.exists() { + bail!( + "iOS app artifact not found at {:?}; provide a .ipa or zipped .app", + artifacts.app + ); + } + if !artifacts.test_suite.exists() { + bail!( + "iOS XCUITest test suite artifact not found at {:?}; provide the zipped test runner bundle", + artifacts.test_suite + ); + } + + let app_upload = client.upload_xcuitest_app(&artifacts.app)?; + let test_upload = client.upload_xcuitest_test_suite(&artifacts.test_suite)?; + let run = client.schedule_xcuitest_run( + &spec.devices, + &app_upload.app_url, + &test_upload.test_suite_url, + )?; + println!( + "Queued BrowserStack XCUITest build {} for devices: {}", + run.build_id, + spec.devices.join(", ") + ); + + Ok(RemoteRun::Ios { + app_url: app_upload.app_url, + test_suite_url: test_upload.test_suite_url, + build_id: run.build_id, + }) +} + +fn resolve_browserstack_credentials( + config: Option<&BrowserStackConfig>, +) -> Result { + let mut username = None; + let mut access_key = None; + let mut project = None; + + if let Some(cfg) = config { + username = Some(expand_env_var(&cfg.app_automate_username)?); + access_key = Some(expand_env_var(&cfg.app_automate_access_key)?); + project = cfg + .project + .as_ref() + .map(|p| expand_env_var(p)) + .transpose()?; + } + + if username.as_deref().map(str::is_empty).unwrap_or(true) + && let Ok(val) = env::var("BROWSERSTACK_USERNAME") + && !val.is_empty() + { + username = Some(val); + } + if access_key.as_deref().map(str::is_empty).unwrap_or(true) + && let Ok(val) = env::var("BROWSERSTACK_ACCESS_KEY") + && !val.is_empty() + { + access_key = Some(val); + } + if project.is_none() + && let Ok(val) = env::var("BROWSERSTACK_PROJECT") + && !val.is_empty() + { + project = Some(val); + } + + let username = username.filter(|s| !s.is_empty()).ok_or_else(|| { + anyhow!("BrowserStack username missing; set BROWSERSTACK_USERNAME or provide in config") + })?; + let access_key = access_key.filter(|s| !s.is_empty()).ok_or_else(|| { + anyhow!("BrowserStack access key missing; set BROWSERSTACK_ACCESS_KEY or provide in config") + })?; + + Ok(ResolvedBrowserStack { + username, + access_key, + project, + }) +} + +fn expand_env_var(raw: &str) -> Result { + if let Some(stripped) = raw.strip_prefix("${").and_then(|s| s.strip_suffix('}')) { + let val = env::var(stripped) + .with_context(|| format!("resolving env var {stripped} for BrowserStack config"))?; + return Ok(val); + } + Ok(raw.to_string()) +} + +#[cfg(test)] +fn run_local_smoke(spec: &RunSpec) -> Result { + println!("Running local smoke test for {}...", spec.function); + + let bench_spec = mobench_sdk::BenchSpec { + name: spec.function.clone(), + iterations: spec.iterations, + warmup: spec.warmup, + }; + + let report = + mobench_sdk::run_benchmark(bench_spec).map_err(|e| anyhow!("benchmark failed: {:?}", e))?; + + serde_json::to_value(&report).context("serializing benchmark report") +} + +fn persist_mobile_spec(spec: &RunSpec) -> Result<()> { + let root = repo_root()?; + let payload = json!({ + "function": spec.function, + "iterations": spec.iterations, + "warmup": spec.warmup, + }); + let contents = serde_json::to_string_pretty(&payload)?; + let targets = [ + root.join("target/mobile-spec/android/bench_spec.json"), + root.join("target/mobile-spec/ios/bench_spec.json"), + ]; + for path in targets { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating directory {:?}", parent))?; + } + write_file(&path, contents.as_bytes())?; + } + Ok(()) +} + +fn write_summary(summary: &RunSummary, output: Option<&Path>) -> Result<()> { + let json = serde_json::to_string_pretty(summary)?; + if let Some(path) = output { + write_file(path, json.as_bytes())?; + println!("Wrote run summary to {:?}", path); + } else { + println!("{json}"); + } + Ok(()) +} + +fn run_android_build(_ndk_home: &str) -> Result { + let root = repo_root()?; + let crate_name = + detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); + + let cfg = mobench_sdk::BuildConfig { + target: mobench_sdk::Target::Android, + profile: mobench_sdk::BuildProfile::Debug, + incremental: true, + }; + let builder = mobench_sdk::builders::AndroidBuilder::new(&root, crate_name).verbose(true); + let result = builder.build(&cfg)?; + Ok(result) +} + +fn load_dotenv() { + if let Ok(root) = repo_root() { + let path = root.join(".env.local"); + let _ = dotenvy::from_path(path); + } +} + +fn repo_root() -> Result { + // Prefer the build-time repo root but fall back to the current directory for installed binaries. + let compiled = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(".."); + if let Ok(path) = compiled.canonicalize() { + return Ok(path); + } + std::env::current_dir().context("resolving repo root from current directory") +} + +fn ensure_can_write(path: &Path) -> Result<()> { + if path.exists() { + bail!("refusing to overwrite existing file: {:?}", path); + } + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent) + .with_context(|| format!("creating parent directory {:?}", parent))?; + } + Ok(()) +} + +fn write_file(path: &Path, contents: &[u8]) -> Result<()> { + fs::write(path, contents).with_context(|| format!("writing file {:?}", path)) +} + +/// Initialize a new benchmark project using mobench-sdk (Phase 1 MVP) +fn cmd_init_sdk( + target: SdkTarget, + project_name: String, + output_dir: PathBuf, + generate_examples: bool, +) -> Result<()> { + println!("Initializing benchmark project with mobench-sdk..."); + println!(" Project name: {}", project_name); + println!(" Target: {:?}", target); + println!(" Output directory: {:?}", output_dir); + + let config = mobench_sdk::InitConfig { + target: target.into(), + project_name: project_name.clone(), + output_dir: output_dir.clone(), + generate_examples, + }; + + mobench_sdk::codegen::generate_project(&config).context("Failed to generate project")?; + + println!("\n✓ Project initialized successfully!"); + println!("\nNext steps:"); + println!(" 1. Add benchmark functions to your code with #[benchmark]"); + println!(" 2. Run 'cargo build --target ' to build"); + println!(" 3. Run benchmarks with 'cargo mobench build --target '"); + + Ok(()) +} + +/// Build mobile artifacts using mobench-sdk (Phase 1 MVP) +fn cmd_build(target: SdkTarget, release: bool) -> Result<()> { + println!("Building mobile artifacts..."); + println!(" Target: {:?}", target); + println!(" Profile: {}", if release { "release" } else { "debug" }); + + let project_root = std::env::current_dir().context("Failed to get current directory")?; + let crate_name = detect_bench_mobile_crate_name(&project_root) + .unwrap_or_else(|_| "bench-mobile".to_string()); // Fallback for legacy layouts + + let build_config = mobench_sdk::BuildConfig { + target: target.into(), + profile: if release { + mobench_sdk::BuildProfile::Release + } else { + mobench_sdk::BuildProfile::Debug + }, + incremental: true, + }; + + match target { + SdkTarget::Android => { + let builder = + mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) + .verbose(true); + let result = builder.build(&build_config)?; + println!("\n✓ Android build completed!"); + println!(" APK: {:?}", result.app_path); + } + SdkTarget::Ios => { + let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name.clone()) + .verbose(true); + let result = builder.build(&build_config)?; + println!("\n✓ iOS build completed!"); + println!(" Framework: {:?}", result.app_path); + } + SdkTarget::Both => { + // Build Android + let android_builder = + mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) + .verbose(true); + let android_result = android_builder.build(&build_config)?; + println!("\n✓ Android build completed!"); + println!(" APK: {:?}", android_result.app_path); + + // Build iOS + let ios_builder = + mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + let ios_result = ios_builder.build(&build_config)?; + println!("\n✓ iOS build completed!"); + println!(" Framework: {:?}", ios_result.app_path); + } + } + + Ok(()) +} + +fn detect_bench_mobile_crate_name(root: &Path) -> Result { + // Try bench-mobile/ first (SDK projects) + let bench_mobile_path = root.join("bench-mobile").join("Cargo.toml"); + if bench_mobile_path.exists() { + let contents = fs::read_to_string(&bench_mobile_path) + .with_context(|| format!("reading bench-mobile manifest at {:?}", bench_mobile_path))?; + let value: toml::Value = toml::from_str(&contents) + .with_context(|| format!("parsing bench-mobile manifest {:?}", bench_mobile_path))?; + let name = value + .get("package") + .and_then(|pkg| pkg.get("name")) + .and_then(|n| n.as_str()) + .ok_or_else(|| { + anyhow!( + "bench-mobile package.name missing in {:?}", + bench_mobile_path + ) + })?; + return Ok(name.to_string()); + } + + // Fallback: Try crates/sample-fns (repository testing) + let sample_fns_path = root.join("crates").join("sample-fns").join("Cargo.toml"); + if sample_fns_path.exists() { + let contents = fs::read_to_string(&sample_fns_path) + .with_context(|| format!("reading sample-fns manifest at {:?}", sample_fns_path))?; + let value: toml::Value = toml::from_str(&contents) + .with_context(|| format!("parsing sample-fns manifest {:?}", sample_fns_path))?; + let name = value + .get("package") + .and_then(|pkg| pkg.get("name")) + .and_then(|n| n.as_str()) + .ok_or_else(|| anyhow!("sample-fns package.name missing in {:?}", sample_fns_path))?; + return Ok(name.to_string()); + } + + bail!("No benchmark crate found. Expected 'bench-mobile/' or 'crates/sample-fns/'") +} + +/// List all discovered benchmark functions (Phase 1 MVP) +fn cmd_list() -> Result<()> { + println!("Discovering benchmark functions...\n"); + + let benchmarks = mobench_sdk::discover_benchmarks(); + + if benchmarks.is_empty() { + println!("No benchmarks found."); + println!("\nTo add benchmarks:"); + println!(" 1. Add #[benchmark] attribute to functions"); + println!(" 2. Make sure mobench-sdk is in your dependencies"); + println!(" 3. Rebuild your project"); + } else { + println!("Found {} benchmark(s):", benchmarks.len()); + for bench in benchmarks { + println!(" - {}", bench.name); + } + } + + Ok(()) +} + +/// Package iOS app as IPA for distribution or testing +fn cmd_package_ipa(scheme: &str, method: IosSigningMethodArg) -> Result<()> { + println!("Packaging iOS app as IPA..."); + println!(" Scheme: {}", scheme); + println!(" Method: {:?}", method); + + let project_root = repo_root()?; + let crate_name = detect_bench_mobile_crate_name(&project_root) + .unwrap_or_else(|_| "bench-mobile".to_string()); + + let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + + let signing_method: mobench_sdk::builders::SigningMethod = method.into(); + let ipa_path = builder + .package_ipa(scheme, signing_method) + .context("Failed to package IPA")?; + + println!("\n✓ IPA packaged successfully!"); + println!(" Path: {:?}", ipa_path); + println!("\nYou can now:"); + println!(" - Install on device: Use Xcode or ios-deploy"); + println!( + " - Test on BrowserStack: cargo mobench run --target ios --ios-app {:?}", + ipa_path + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Register a lightweight benchmark for tests so the inventory contains at least one entry. + #[mobench_sdk::benchmark] + fn noop_benchmark() { + std::hint::black_box(1u8); + } + + #[test] + fn resolves_cli_spec() { + let spec = resolve_run_spec( + MobileTarget::Android, + "sample_fns::fibonacci".into(), + 5, + 1, + vec!["pixel".into()], + None, + None, + None, + ) + .unwrap(); + assert_eq!(spec.function, "sample_fns::fibonacci"); + assert_eq!(spec.iterations, 5); + assert_eq!(spec.warmup, 1); + assert_eq!(spec.devices, vec!["pixel".to_string()]); + assert!(spec.browserstack.is_none()); + assert!(spec.ios_xcuitest.is_none()); + } + + #[test] + fn local_smoke_produces_samples() { + let spec = RunSpec { + target: MobileTarget::Android, + function: "noop_benchmark".into(), + iterations: 3, + warmup: 1, + devices: vec![], + browserstack: None, + ios_xcuitest: None, + }; + let report = run_local_smoke(&spec).expect("local harness"); + assert!(report["samples"].is_array()); + assert_eq!(report["spec"]["name"], "noop_benchmark"); + } + + #[test] + fn ios_requires_artifacts_for_browserstack() { + let err = resolve_run_spec( + MobileTarget::Ios, + "sample_fns::fibonacci".into(), + 1, + 0, + vec!["iphone".into()], + None, + None, + None, + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("iOS BrowserStack runs require --ios-app and --ios-test-suite") + ); + } +} diff --git a/crates/sample-fns/Cargo.toml b/crates/sample-fns/Cargo.toml index 1ef096b..8301837 100644 --- a/crates/sample-fns/Cargo.toml +++ b/crates/sample-fns/Cargo.toml @@ -11,8 +11,8 @@ crate-type = ["lib", "cdylib", "staticlib"] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -bench-runner = { path = "../bench-runner" } -uniffi.workspace = true +mobench-runner = { path = "../mobench-runner" } +uniffi = { workspace = true, features = ["cli"] } thiserror.workspace = true uniffi_bindgen = { version = "0.28", optional = true } camino = { version = "1.1", optional = true } diff --git a/crates/sample-fns/build.rs b/crates/sample-fns/build.rs index 9c3ed62..8ac5b0a 100644 --- a/crates/sample-fns/build.rs +++ b/crates/sample-fns/build.rs @@ -1,3 +1,4 @@ fn main() { - uniffi::generate_scaffolding("./src/sample_fns.udl").unwrap(); + // Scaffolding is now generated via proc macros (uniffi::setup_scaffolding! in lib.rs) + // No UDL file processing needed } diff --git a/crates/sample-fns/src/bin/generate-bindings.rs b/crates/sample-fns/src/bin/generate-bindings.rs index d262502..cb054b2 100644 --- a/crates/sample-fns/src/bin/generate-bindings.rs +++ b/crates/sample-fns/src/bin/generate-bindings.rs @@ -1,35 +1,57 @@ use camino::Utf8PathBuf; use std::env; use std::fs; -use std::process; use uniffi_bindgen::bindings::{KotlinBindingGenerator, SwiftBindingGenerator}; +use uniffi_bindgen::library_mode::generate_bindings; fn main() { let manifest_dir = Utf8PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let root_dir = manifest_dir.parent().unwrap().parent().unwrap(); - let udl_file = manifest_dir.join("src/sample_fns.udl"); - if !udl_file.exists() { - eprintln!("Error: UDL file not found at {:?}", udl_file); - process::exit(1); - } + let lib_file = if let Ok(path) = env::var("UNIFFI_LIBRARY_PATH") { + println!("Using UniFFI library from UNIFFI_LIBRARY_PATH"); + Utf8PathBuf::from(path) + } else { + let profile = env::var("UNIFFI_PROFILE").unwrap_or_else(|_| "release".to_string()); + println!( + "Building library to generate UniFFI metadata (profile={})...", + profile + ); + let target_dir = root_dir.join("target").join(&profile); + let lib_name = if cfg!(target_os = "macos") { + "libsample_fns.dylib" + } else if cfg!(target_os = "linux") { + "libsample_fns.so" + } else { + "sample_fns.dll" + }; + target_dir.join(lib_name) + }; - println!("Using UDL file: {:?}", udl_file); + println!("Using library: {:?}", lib_file); + if !lib_file.exists() { + eprintln!( + "UniFFI library not found at {:?}. Build it first or set UNIFFI_LIBRARY_PATH.", + lib_file + ); + std::process::exit(1); + } // Generate Kotlin bindings let kotlin_out = root_dir.join("android/app/src/main/java"); fs::create_dir_all(&kotlin_out).unwrap(); println!("Generating Kotlin bindings to {:?}", kotlin_out); - uniffi_bindgen::generate_bindings( - &udl_file, - None, // config file - KotlinBindingGenerator, - Some(kotlin_out.as_ref()), - None, // lib file - None, // crate name + generate_bindings( + &lib_file, + None, // crate name (auto-detect) + &KotlinBindingGenerator, + &uniffi_bindgen::cargo_metadata::CrateConfigSupplier::default(), + None, // config override path + &kotlin_out, false, // try_format_code - ).unwrap(); + ) + .unwrap(); println!("✓ Kotlin bindings generated"); @@ -38,15 +60,16 @@ fn main() { fs::create_dir_all(&swift_out).unwrap(); println!("Generating Swift bindings to {:?}", swift_out); - uniffi_bindgen::generate_bindings( - &udl_file, - None, // config file - SwiftBindingGenerator, - Some(swift_out.as_ref()), - None, // lib file - None, // crate name + generate_bindings( + &lib_file, + None, // crate name (auto-detect) + &SwiftBindingGenerator, + &uniffi_bindgen::cargo_metadata::CrateConfigSupplier::default(), + None, // config override path + &swift_out, false, // try_format_code - ).unwrap(); + ) + .unwrap(); println!("✓ Swift bindings generated"); diff --git a/crates/sample-fns/src/lib.rs b/crates/sample-fns/src/lib.rs index 2c6b72b..e1c128d 100644 --- a/crates/sample-fns/src/lib.rs +++ b/crates/sample-fns/src/lib.rs @@ -1,14 +1,11 @@ -//! Sample benchmark functions for mobile testing using UniFFI (UDL mode). +//! Sample benchmark functions for mobile testing using UniFFI (proc macro mode). -use bench_runner::{run_closure, BenchError as BenchRunnerError}; - -// Include UniFFI scaffolding (generated from UDL) -uniffi::include_scaffolding!("sample_fns"); +use mobench_runner::{run_closure, BenchError as BenchRunnerError}; const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; /// Specification for a benchmark run. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct BenchSpec { pub name: String, pub iterations: u32, @@ -16,20 +13,21 @@ pub struct BenchSpec { } /// A single benchmark sample with timing information. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct BenchSample { pub duration_ns: u64, } /// Complete benchmark report with spec and timing samples. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct BenchReport { pub spec: BenchSpec, pub samples: Vec, } /// Error types for benchmark operations. -#[derive(Debug, thiserror::Error)] +#[derive(Debug, thiserror::Error, uniffi::Error)] +#[uniffi(flat_error)] pub enum BenchError { #[error("iterations must be greater than zero")] InvalidIterations, @@ -41,9 +39,12 @@ pub enum BenchError { ExecutionFailed { reason: String }, } +// Generate UniFFI scaffolding from proc macros +uniffi::setup_scaffolding!(); + // Conversion from bench-runner types -impl From for BenchSpec { - fn from(spec: bench_runner::BenchSpec) -> Self { +impl From for BenchSpec { + fn from(spec: mobench_runner::BenchSpec) -> Self { Self { name: spec.name, iterations: spec.iterations, @@ -52,7 +53,7 @@ impl From for BenchSpec { } } -impl From for bench_runner::BenchSpec { +impl From for mobench_runner::BenchSpec { fn from(spec: BenchSpec) -> Self { Self { name: spec.name, @@ -62,16 +63,16 @@ impl From for bench_runner::BenchSpec { } } -impl From for BenchSample { - fn from(sample: bench_runner::BenchSample) -> Self { +impl From for BenchSample { + fn from(sample: mobench_runner::BenchSample) -> Self { Self { duration_ns: sample.duration_ns, } } } -impl From for BenchReport { - fn from(report: bench_runner::BenchReport) -> Self { +impl From for BenchReport { + fn from(report: mobench_runner::BenchReport) -> Self { Self { spec: report.spec.into(), samples: report.samples.into_iter().map(Into::into).collect(), @@ -89,8 +90,9 @@ impl From for BenchError { } /// Run a benchmark by name with the given specification. +#[uniffi::export] pub fn run_benchmark(spec: BenchSpec) -> Result { - let runner_spec: bench_runner::BenchSpec = spec.into(); + let runner_spec: mobench_runner::BenchSpec = spec.into(); let report = match runner_spec.name.as_str() { "fibonacci" | "fib" | "sample_fns::fibonacci" => { diff --git a/examples/basic-benchmark/Cargo.toml b/examples/basic-benchmark/Cargo.toml new file mode 100644 index 0000000..90532e8 --- /dev/null +++ b/examples/basic-benchmark/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "basic-benchmark" +version = "0.1.0" +edition = "2021" +license.workspace = true + +[lib] +name = "basic_benchmark" +crate-type = ["lib", "cdylib", "staticlib"] + +[dependencies] +# Use bench-sdk for the #[benchmark] macro and registry +mobench-sdk = { path = "../../crates/mobench-sdk" } +inventory.workspace = true +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uniffi = { workspace = true, features = ["cli"] } +thiserror.workspace = true + +[build-dependencies] +uniffi = { workspace = true, features = ["build"] } diff --git a/examples/basic-benchmark/build.rs b/examples/basic-benchmark/build.rs new file mode 100644 index 0000000..8ac5b0a --- /dev/null +++ b/examples/basic-benchmark/build.rs @@ -0,0 +1,4 @@ +fn main() { + // Scaffolding is now generated via proc macros (uniffi::setup_scaffolding! in lib.rs) + // No UDL file processing needed +} diff --git a/examples/basic-benchmark/src/lib.rs b/examples/basic-benchmark/src/lib.rs new file mode 100644 index 0000000..4b1e3f0 --- /dev/null +++ b/examples/basic-benchmark/src/lib.rs @@ -0,0 +1,231 @@ +//! Basic benchmark examples demonstrating bench-sdk usage +//! +//! This example crate shows how to write benchmarks using the bench-sdk +//! with the #[benchmark] attribute macro. + +use mobench_sdk::benchmark; + +const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; + +/// Specification for a benchmark run. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} + +/// A single benchmark sample with timing information. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchSample { + pub duration_ns: u64, +} + +/// Complete benchmark report with spec and timing samples. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchReport { + pub spec: BenchSpec, + pub samples: Vec, +} + +/// Error types for benchmark operations. +#[derive(Debug, thiserror::Error, uniffi::Error)] +#[uniffi(flat_error)] +pub enum BenchError { + #[error("iterations must be greater than zero")] + InvalidIterations, + + #[error("unknown benchmark function: {name}")] + UnknownFunction { name: String }, + + #[error("benchmark execution failed: {reason}")] + ExecutionFailed { reason: String }, +} + +// Generate UniFFI scaffolding from proc macros +uniffi::setup_scaffolding!(); + +// Conversion from bench-sdk types +impl From for BenchSpec { + fn from(spec: mobench_sdk::BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for mobench_sdk::BenchSpec { + fn from(spec: BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for BenchSample { + fn from(sample: mobench_sdk::BenchSample) -> Self { + Self { + duration_ns: sample.duration_ns, + } + } +} + +impl From for BenchReport { + fn from(report: mobench_sdk::RunnerReport) -> Self { + Self { + spec: report.spec.into(), + samples: report.samples.into_iter().map(Into::into).collect(), + } + } +} + +impl From for BenchError { + fn from(err: mobench_sdk::BenchError) -> Self { + match err { + mobench_sdk::BenchError::Runner(runner_err) => BenchError::ExecutionFailed { + reason: runner_err.to_string(), + }, + mobench_sdk::BenchError::UnknownFunction(name) => BenchError::UnknownFunction { name }, + _ => BenchError::ExecutionFailed { + reason: err.to_string(), + }, + } + } +} + +/// Run a benchmark by name with the given specification +/// +/// This is the main FFI entry point called from mobile platforms. +/// It uses bench-sdk's registry to discover and execute benchmarks. +#[uniffi::export] +pub fn run_benchmark(spec: BenchSpec) -> Result { + let sdk_spec: mobench_sdk::BenchSpec = spec.into(); + let report = mobench_sdk::run_benchmark(sdk_spec)?; + Ok(report.into()) +} + +/// Compute fibonacci number iteratively. +pub fn fibonacci(n: u32) -> u64 { + match n { + 0 => 0, + 1 => 1, + _ => { + let mut a = 0u64; + let mut b = 1u64; + for _ in 2..=n { + let next = a.wrapping_add(b); + a = b; + b = next; + } + b + } + } +} + +/// Compute fibonacci in a more measurable way by doing it multiple times. +pub fn fibonacci_batch(n: u32, iterations: u32) -> u64 { + let mut result = 0u64; + for _ in 0..iterations { + result = result.wrapping_add(fibonacci(n)); + } + result +} + +/// Compute checksum by summing all bytes. +pub fn checksum(bytes: &[u8]) -> u64 { + bytes.iter().map(|&b| b as u64).sum() +} + +// ============================================================================ +// Benchmark Functions +// ============================================================================ +// These functions are marked with #[benchmark] and automatically registered +// with bench-sdk's registry system. + +/// Benchmark: Fibonacci calculation (30th number, 1000 iterations) +#[benchmark] +pub fn bench_fibonacci() { + let result = fibonacci_batch(30, 1000); + std::hint::black_box(result); +} + +/// Benchmark: Checksum calculation on 1KB data (10000 iterations) +#[benchmark] +pub fn bench_checksum() { + let mut sum = 0u64; + for _ in 0..10000 { + sum = sum.wrapping_add(checksum(&CHECKSUM_INPUT)); + } + std::hint::black_box(sum); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fib_sequence() { + assert_eq!(fibonacci(0), 0); + assert_eq!(fibonacci(1), 1); + assert_eq!(fibonacci(10), 55); + assert_eq!(fibonacci(24), 46368); + } + + #[test] + fn checksum_matches() { + assert_eq!(checksum(&CHECKSUM_INPUT), 1024); + } + + #[test] + fn test_run_benchmark_via_registry() { + // Test that benchmarks can be discovered via the registry + let benchmarks = mobench_sdk::discover_benchmarks(); + assert!(benchmarks.len() >= 2, "Should find at least 2 benchmarks"); + + // Test execution via FFI using registry name + let spec = BenchSpec { + name: "basic_benchmark::bench_fibonacci".to_string(), + iterations: 3, + warmup: 1, + }; + let report = run_benchmark(spec).unwrap(); + assert_eq!(report.samples.len(), 3); + } + + #[test] + fn test_run_benchmark_checksum() { + let spec = BenchSpec { + name: "basic_benchmark::bench_checksum".to_string(), + iterations: 2, + warmup: 0, + }; + let report = run_benchmark(spec).unwrap(); + assert_eq!(report.samples.len(), 2); + } + + #[test] + fn test_unknown_function_error() { + let spec = BenchSpec { + name: "unknown".to_string(), + iterations: 1, + warmup: 0, + }; + let result = run_benchmark(spec); + assert!(matches!(result, Err(BenchError::UnknownFunction { .. }))); + } + + #[test] + fn test_invalid_iterations() { + let spec = BenchSpec { + name: "basic_benchmark::bench_fibonacci".to_string(), + iterations: 0, + warmup: 0, + }; + let result = run_benchmark(spec); + assert!(matches!(result, Err(BenchError::ExecutionFailed { .. }))); + } +} diff --git a/ios/BenchRunner/BenchRunner/Generated/sample_fns.swift b/ios/BenchRunner/BenchRunner/Generated/sample_fns.swift index 532a59b..24f8649 100644 --- a/ios/BenchRunner/BenchRunner/Generated/sample_fns.swift +++ b/ios/BenchRunner/BenchRunner/Generated/sample_fns.swift @@ -470,6 +470,9 @@ fileprivate struct FfiConverterString: FfiConverter { } +/** + * Complete benchmark report with spec and timing samples. + */ public struct BenchReport { public var spec: BenchSpec public var samples: [BenchSample] @@ -536,6 +539,9 @@ public func FfiConverterTypeBenchReport_lower(_ value: BenchReport) -> RustBuffe } +/** + * A single benchmark sample with timing information. + */ public struct BenchSample { public var durationNs: UInt64 @@ -594,6 +600,9 @@ public func FfiConverterTypeBenchSample_lower(_ value: BenchSample) -> RustBuffe } +/** + * Specification for a benchmark run. + */ public struct BenchSpec { public var name: String public var iterations: UInt32 @@ -668,6 +677,9 @@ public func FfiConverterTypeBenchSpec_lower(_ value: BenchSpec) -> RustBuffer { } +/** + * Error types for benchmark operations. + */ public enum BenchError { @@ -762,6 +774,9 @@ fileprivate struct FfiConverterSequenceTypeBenchSample: FfiConverterRustBuffer { return seq } } +/** + * Run a benchmark by name with the given specification. + */ public func runBenchmark(spec: BenchSpec)throws -> BenchReport { return try FfiConverterTypeBenchReport.lift(try rustCallWithError(FfiConverterTypeBenchError.lift) { uniffi_sample_fns_fn_func_run_benchmark( @@ -785,7 +800,7 @@ private var initializationResult: InitializationResult = { if bindings_contract_version != scaffolding_contract_version { return InitializationResult.contractVersionMismatch } - if (uniffi_sample_fns_checksum_func_run_benchmark() != 35019) { + if (uniffi_sample_fns_checksum_func_run_benchmark() != 38523) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/BenchRunner/project.yml b/ios/BenchRunner/project.yml index d17398f..5b5d269 100644 --- a/ios/BenchRunner/project.yml +++ b/ios/BenchRunner/project.yml @@ -4,6 +4,19 @@ options: settings: base: SWIFT_VERSION: 5.9 +schemes: + BenchRunner: + build: + targets: + BenchRunner: all + run: + config: Debug + test: + config: Debug + targets: + - BenchRunnerUITests + archive: + config: Release targets: BenchRunner: type: application diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index 4c52559..4b8100d 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -1,6 +1,15 @@ #!/usr/bin/env bash set -euo pipefail +# ⚠️ DEPRECATION WARNING ⚠️ +# This script is legacy tooling for developing this repository. +# +# For SDK integrators, use instead: +# cargo run -p bench-cli -- build --target android +# +# This command does everything this script does, but in pure Rust with no dependencies +# on having this repo's scripts/ directory locally. + # Convenience wrapper: build Rust libs for all Android ABIs, sync them into the app, # then assemble the Android APK. # @@ -24,6 +33,23 @@ fi pushd "${ROOT_DIR}" >/dev/null ./scripts/build-android.sh +ABI="${UNIFFI_ANDROID_ABI:-arm64-v8a}" +case "${ABI}" in + arm64-v8a) + LIB_PATH="${ROOT_DIR}/target/android/aarch64-linux-android/arm64-v8a/libsample_fns.so" + ;; + x86_64) + LIB_PATH="${ROOT_DIR}/target/android/x86_64-linux-android/x86_64/libsample_fns.so" + ;; + armeabi-v7a) + LIB_PATH="${ROOT_DIR}/target/android/armv7-linux-androideabi/armeabi-v7a/libsample_fns.so" + ;; + *) + echo "Unknown UNIFFI_ANDROID_ABI=${ABI}; expected arm64-v8a, x86_64, or armeabi-v7a" >&2 + exit 1 + ;; +esac +UNIFFI_LIBRARY_PATH="${LIB_PATH}" ./scripts/generate-bindings.sh ./scripts/sync-android-libs.sh popd >/dev/null diff --git a/scripts/build-android.sh b/scripts/build-android.sh index 73ca021..b00bbeb 100755 --- a/scripts/build-android.sh +++ b/scripts/build-android.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash set -euo pipefail +# ⚠️ DEPRECATION WARNING ⚠️ +# This script is legacy tooling for developing this repository. +# +# For SDK integrators, use instead: +# cargo run -p bench-cli -- build --target android +# +# The CLI command handles all build steps automatically. + # Build Rust shared libraries for Android targets using cargo-ndk. # # NOTE: If you modify the Rust API (sample_fns.udl), run: diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh index 6264e21..1fb3fcb 100755 --- a/scripts/build-ios.sh +++ b/scripts/build-ios.sh @@ -1,6 +1,15 @@ #!/usr/bin/env bash set -euo pipefail +# ⚠️ DEPRECATION WARNING ⚠️ +# This script is legacy tooling for developing this repository. +# +# For SDK integrators, use instead: +# cargo run -p bench-cli -- build --target ios +# +# The CLI command handles all build steps automatically including xcframework +# creation, binding generation, and code signing. + # Build the Rust library for iOS targets and package as xcframework. # UniFFI-generated headers (sample_fnsFFI.h) are used for the C ABI. # @@ -173,6 +182,16 @@ EOF echo "✓ iOS build complete. XCFramework created at: ${XCFRAMEWORK_PATH}" +# Copy public header for CLI consumers (matches bench-cli expectation) +INCLUDE_DIR="${OUTPUT_DIR}/include" +mkdir -p "${INCLUDE_DIR}" +if [[ -f "${UNIFFI_HEADER}" ]]; then + cp "${UNIFFI_HEADER}" "${INCLUDE_DIR}/sample_fns.h" +else + echo "Error: UniFFI header still missing at ${UNIFFI_HEADER}" >&2 + exit 1 +fi + # Code-sign the xcframework (required for Xcode) echo "Signing xcframework..." codesign --force --deep --sign - "${XCFRAMEWORK_PATH}" 2>/dev/null || { diff --git a/scripts/generate-bindings.sh b/scripts/generate-bindings.sh index 62ce14e..94ee5a3 100755 --- a/scripts/generate-bindings.sh +++ b/scripts/generate-bindings.sh @@ -1,42 +1,26 @@ #!/usr/bin/env bash set -euo pipefail +# ⚠️ DEPRECATION WARNING ⚠️ +# This script is legacy tooling for developing this repository. +# +# For SDK integrators, bindings are automatically generated during: +# cargo run -p bench-cli -- build --target +# +# You don't need to call this script separately. + # Generate Kotlin and Swift bindings using UniFFI ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CRATE_DIR="${ROOT_DIR}/crates/sample-fns" -# Build the library for host first -echo "Building sample-fns..." -cargo build -p sample-fns - -# Determine library extension based on platform -if [[ "$OSTYPE" == "darwin"* ]]; then - LIB_EXT="dylib" -elif [[ "$OSTYPE" == "linux-gnu"* ]]; then - LIB_EXT="so" +if [[ -n "${UNIFFI_LIBRARY_PATH:-}" ]]; then + echo "Using UNIFFI_LIBRARY_PATH=${UNIFFI_LIBRARY_PATH}" else - echo "Unsupported platform: $OSTYPE" - exit 1 + echo "Building sample-fns (release)..." + cargo build -p sample-fns --release + export UNIFFI_PROFILE=release fi -LIB_PATH="${ROOT_DIR}/target/debug/libsample_fns.${LIB_EXT}" - -# Generate Kotlin bindings -echo "Generating Kotlin bindings..." -cargo run --bin uniffi-bindgen generate \ - --library "${LIB_PATH}" \ - --language kotlin \ - --out-dir "${ROOT_DIR}/android/app/src/main/java" - -# Generate Swift bindings -echo "Generating Swift bindings..." -mkdir -p "${ROOT_DIR}/ios/BenchRunner/BenchRunner/Generated" -cargo run --bin uniffi-bindgen generate \ - --library "${LIB_PATH}" \ - --language swift \ - --out-dir "${ROOT_DIR}/ios/BenchRunner/BenchRunner/Generated" - -echo "✓ Bindings generated successfully" -echo " - Kotlin: android/app/src/main/java/uniffi/sample_fns/" -echo " - Swift: ios/BenchRunner/BenchRunner/Generated/" +echo "Generating Kotlin + Swift bindings via sample-fns helper..." +cargo run -p sample-fns --bin generate-bindings --features bindgen diff --git a/scripts/sync-android-libs.sh b/scripts/sync-android-libs.sh index 0ae99ea..4079b1f 100755 --- a/scripts/sync-android-libs.sh +++ b/scripts/sync-android-libs.sh @@ -1,14 +1,21 @@ #!/usr/bin/env bash set -euo pipefail +# ⚠️ DEPRECATION WARNING ⚠️ +# This script is legacy tooling for developing this repository. +# +# For SDK integrators, use instead: +# cargo run -p bench-cli -- build --target android +# +# The CLI command automatically handles library copying. + # Copy built Rust .so files into the Android app's jniLibs structure. # Run scripts/build-android.sh first. ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP_JNILIBS="${ROOT_DIR}/android/app/src/main/jniLibs" LIB_NAME="${LIB_NAME:-sample_fns}" -# JNA expects libuniffi_sample_fns.so, so we rename during copy -TARGET_LIB_NAME="${TARGET_LIB_NAME:-uniffi_sample_fns}" +TARGET_LIB_NAME="${TARGET_LIB_NAME:-sample_fns}" declare -A ABI_MAP=( ["aarch64-linux-android"]="arm64-v8a" @@ -33,6 +40,10 @@ for TRIPLE in "${!ABI_MAP[@]}"; do fi mkdir -p "${DEST_DIR}" cp "${SRC}" "${DEST}" + # Keep a compat copy for older loaders that expect uniffi_ prefix. + if [[ "${TARGET_LIB_NAME}" == "sample_fns" ]]; then + cp "${SRC}" "${DEST_DIR}/libuniffi_sample_fns.so" + fi echo "Copied ${SRC} -> ${DEST}" done diff --git a/templates/android/app/build.gradle b/templates/android/app/build.gradle new file mode 100644 index 0000000..7180e74 --- /dev/null +++ b/templates/android/app/build.gradle @@ -0,0 +1,69 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" + +android { + namespace = "{{PACKAGE_NAME}}" + compileSdk = 34 + ndkVersion "26.1.10909125" + def repoRoot = rootProject.projectDir.parentFile + def benchSpecDir = new File(repoRoot, "target/mobile-spec/android") + if (!benchSpecDir.exists()) { + benchSpecDir.mkdirs() + } + + defaultConfig { + applicationId "{{PACKAGE_NAME}}" + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "0.1" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + minifyEnabled false + } + release { + minifyEnabled false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + main { + jniLibs.srcDirs "src/main/jniLibs" + assets.srcDirs += [benchSpecDir.absolutePath] + } + androidTest { + assets.srcDirs += [benchSpecDir.absolutePath] + } + } + + packagingOptions { + jniLibs { + // Keep symbols in Rust shared objects; avoid strip warning. + keepDebugSymbols += ["**/lib{{LIBRARY_NAME}}.so"] + } + } +} + +dependencies { + implementation "androidx.core:core-ktx:1.12.0" + implementation "androidx.appcompat:appcompat:1.6.1" + implementation "com.google.android.material:material:1.11.0" + implementation "net.java.dev.jna:jna:5.14.0@aar" + androidTestImplementation "androidx.test.ext:junit:1.1.5" + androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" +} diff --git a/templates/android/app/src/androidTest/java/MainActivityTest.kt.template b/templates/android/app/src/androidTest/java/MainActivityTest.kt.template new file mode 100644 index 0000000..d041367 --- /dev/null +++ b/templates/android/app/src/androidTest/java/MainActivityTest.kt.template @@ -0,0 +1,25 @@ +package {{PACKAGE_NAME}} + +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import org.hamcrest.Matchers.containsString +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + + @Test + fun showsBenchOutput() { + onView(withId(R.id.result_text)) + .check(matches(withText(containsString("Samples")))) + } +} diff --git a/templates/android/app/src/main/AndroidManifest.xml b/templates/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..eda071a --- /dev/null +++ b/templates/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/templates/android/app/src/main/java/MainActivity.kt.template b/templates/android/app/src/main/java/MainActivity.kt.template new file mode 100644 index 0000000..374da28 --- /dev/null +++ b/templates/android/app/src/main/java/MainActivity.kt.template @@ -0,0 +1,167 @@ +package {{PACKAGE_NAME}} + +import android.os.Bundle +import android.os.Debug +import android.os.Process +import android.os.SystemClock +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import org.json.JSONArray +import org.json.JSONObject +import uniffi.{{UNIFFI_NAMESPACE}}.BenchException +import uniffi.{{UNIFFI_NAMESPACE}}.BenchReport +import uniffi.{{UNIFFI_NAMESPACE}}.BenchSpec +import uniffi.{{UNIFFI_NAMESPACE}}.runBenchmark + +class MainActivity : AppCompatActivity() { + + companion object { + private const val DEFAULT_FUNCTION = "{{DEFAULT_FUNCTION}}" + private const val DEFAULT_ITERATIONS = 20u + private const val DEFAULT_WARMUP = 3u + private const val FUNCTION_EXTRA = "bench_function" + private const val ITERATIONS_EXTRA = "bench_iterations" + private const val WARMUP_EXTRA = "bench_warmup" + private const val SPEC_ASSET = "bench_spec.json" + + init { + System.loadLibrary("{{LIBRARY_NAME}}") + } + } + + private data class BenchParams( + val function: String, + val iterations: UInt, + val warmup: UInt, + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + val params = resolveBenchParams() + val display = try { + val spec = BenchSpec( + name = params.function, + iterations = params.iterations, + warmup = params.warmup + ) + val report = runBenchmark(spec) + // Debug: Log first sample's raw nanoseconds + if (report.samples.isNotEmpty()) { + android.util.Log.d("MainActivity", "First sample duration_ns: ${report.samples[0].durationNs}") + } + logBenchReport(report) + formatBenchReport(report) + } catch (e: BenchException.InvalidIterations) { + "Error: ${e.message}" + } catch (e: BenchException.UnknownFunction) { + "Error: ${e.message}" + } catch (e: BenchException.ExecutionFailed) { + "Error: ${e.message}" + } catch (e: Exception) { + "Unexpected error: ${e.message}" + } + + findViewById(R.id.result_text)?.text = display + } + + private fun formatBenchReport(report: BenchReport): String = buildString { + appendLine("=== Benchmark Results ===") + appendLine() + appendLine("Function: ${report.spec.name}") + appendLine("Iterations: ${report.spec.iterations}") + appendLine("Warmup: ${report.spec.warmup}") + appendLine() + appendLine("Samples (${report.samples.size}):") + report.samples.forEachIndexed { index, sample -> + val durationUs = sample.durationNs.toDouble() / 1_000.0 + appendLine(" ${index + 1}. ${String.format("%.3f", durationUs)} μs (${sample.durationNs} ns)") + } + + if (report.samples.isNotEmpty()) { + val durations = report.samples.map { it.durationNs.toDouble() / 1_000.0 } + val min = durations.minOrNull() ?: 0.0 + val max = durations.maxOrNull() ?: 0.0 + val avg = durations.average() + appendLine() + appendLine("Statistics:") + appendLine(" Min: ${String.format("%.3f", min)} μs") + appendLine(" Max: ${String.format("%.3f", max)} μs") + appendLine(" Avg: ${String.format("%.3f", avg)} μs") + } + } + + private fun logBenchReport(report: BenchReport) { + val json = JSONObject() + val spec = JSONObject() + spec.put("name", report.spec.name) + spec.put("iterations", report.spec.iterations.toInt()) + spec.put("warmup", report.spec.warmup.toInt()) + json.put("spec", spec) + + val samples = report.samples.map { it.durationNs.toLong() } + val sampleArray = JSONArray() + samples.forEach { sampleArray.put(it) } + json.put("samples_ns", sampleArray) + + if (samples.isNotEmpty()) { + val min = samples.minOrNull() ?: 0L + val max = samples.maxOrNull() ?: 0L + val avg = samples.sum().toDouble() / samples.size.toDouble() + val stats = JSONObject() + stats.put("min_ns", min) + stats.put("max_ns", max) + stats.put("avg_ns", avg.toDouble()) + json.put("stats", stats) + } + + val memInfo = Debug.MemoryInfo() + Debug.getMemoryInfo(memInfo) + val resources = JSONObject() + resources.put("elapsed_cpu_ms", Process.getElapsedCpuTime()) + resources.put("uptime_ms", SystemClock.elapsedRealtime()) + resources.put("total_pss_kb", memInfo.totalPss) + resources.put("private_dirty_kb", memInfo.totalPrivateDirty) + resources.put("native_heap_kb", Debug.getNativeHeapAllocatedSize() / 1024) + val usedHeap = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() + resources.put("java_heap_kb", usedHeap / 1024) + json.put("resources", resources) + + android.util.Log.i("BenchRunner", "BENCH_JSON ${json}") + } + + private fun resolveBenchParams(): BenchParams { + val defaults = loadBenchParamsFromAssets() ?: BenchParams( + DEFAULT_FUNCTION, + DEFAULT_ITERATIONS, + DEFAULT_WARMUP + ) + val fn = intent?.getStringExtra(FUNCTION_EXTRA) + ?.takeUnless { it.isBlank() } + ?: defaults.function + val iterations = intent?.getIntExtra(ITERATIONS_EXTRA, defaults.iterations.toInt())?.toUInt() + ?: defaults.iterations + val warmup = intent?.getIntExtra(WARMUP_EXTRA, defaults.warmup.toInt())?.toUInt() + ?: defaults.warmup + return BenchParams(fn, iterations, warmup) + } + + private fun loadBenchParamsFromAssets(): BenchParams? { + return try { + val raw = assets.open(SPEC_ASSET).bufferedReader().use { it.readText() } + if (raw.isBlank()) { + null + } else { + val json = JSONObject(raw) + BenchParams( + json.optString("function", DEFAULT_FUNCTION), + json.optInt("iterations", DEFAULT_ITERATIONS.toInt()).toUInt(), + json.optInt("warmup", DEFAULT_WARMUP.toInt()).toUInt(), + ) + } + } catch (_: Exception) { + null + } + } +} diff --git a/templates/android/app/src/main/res/layout/activity_main.xml b/templates/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..ba5178a --- /dev/null +++ b/templates/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/templates/android/app/src/main/res/values/strings.xml b/templates/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3e81bad --- /dev/null +++ b/templates/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + {{APP_NAME}} + Loading Rust benchmarks… + diff --git a/templates/android/app/src/main/res/values/themes.xml b/templates/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..fb5b86a --- /dev/null +++ b/templates/android/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + diff --git a/templates/android/build.gradle b/templates/android/build.gradle new file mode 100644 index 0000000..c3e6c75 --- /dev/null +++ b/templates/android/build.gradle @@ -0,0 +1,21 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean", Delete) { + delete(rootProject.buildDir) +} diff --git a/templates/android/settings.gradle b/templates/android/settings.gradle new file mode 100644 index 0000000..5598347 --- /dev/null +++ b/templates/android/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "{{PROJECT_NAME}}-android" +include(":app") diff --git a/templates/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h.template b/templates/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h.template new file mode 100644 index 0000000..86a61e5 --- /dev/null +++ b/templates/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h.template @@ -0,0 +1,8 @@ +// +// {{PROJECT_NAME_PASCAL}}-Bridging-Header.h +// {{PROJECT_NAME_PASCAL}} +// +// Bridge to import C FFI from Rust (UniFFI-generated) +// + +#import "{{LIBRARY_NAME}}FFI.h" diff --git a/templates/ios/BenchRunner/BenchRunner/BenchRunnerApp.swift.template b/templates/ios/BenchRunner/BenchRunner/BenchRunnerApp.swift.template new file mode 100644 index 0000000..6842d76 --- /dev/null +++ b/templates/ios/BenchRunner/BenchRunner/BenchRunnerApp.swift.template @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct {{PROJECT_NAME_PASCAL}}App: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template new file mode 100644 index 0000000..fcebaa5 --- /dev/null +++ b/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -0,0 +1,118 @@ +import Foundation + +private let defaultFunction = "{{DEFAULT_FUNCTION}}" +private let defaultIterations: UInt32 = 20 +private let defaultWarmup: UInt32 = 3 + +struct BenchParams { + let function: String + let iterations: UInt32 + let warmup: UInt32 + + private struct EncodedBenchSpec: Decodable { + let function: String + let iterations: UInt32 + let warmup: UInt32 + } + + static func fromBundle() -> BenchParams? { + guard let url = Bundle.main.url(forResource: "bench_spec", withExtension: "json") else { + return nil + } + do { + let data = try Data(contentsOf: url) + let decoded = try JSONDecoder().decode(EncodedBenchSpec.self, from: data) + return BenchParams(function: decoded.function, iterations: decoded.iterations, warmup: decoded.warmup) + } catch { + return nil + } + } + + static func fromProcessInfo() -> BenchParams { + let info = ProcessInfo.processInfo + var function = info.environment["BENCH_FUNCTION"] ?? defaultFunction + var iterations = UInt32(info.environment["BENCH_ITERATIONS"] ?? "") ?? defaultIterations + var warmup = UInt32(info.environment["BENCH_WARMUP"] ?? "") ?? defaultWarmup + + for arg in info.arguments { + if arg.hasPrefix("--bench-function=") { + function = String(arg.split(separator: "=", maxSplits: 1).last ?? Substring(function)) + } else if arg.hasPrefix("--bench-iterations=") { + iterations = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? iterations + } else if arg.hasPrefix("--bench-warmup=") { + warmup = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? warmup + } + } + + return BenchParams(function: function, iterations: iterations, warmup: warmup) + } + + static func resolved() -> BenchParams { + if let bundled = fromBundle() { + return bundled + } + return fromProcessInfo() + } +} + +enum {{PROJECT_NAME_PASCAL}}FFI { + static func runCurrentBenchmark() async -> String { + let params = BenchParams.resolved() + return run(params: params) + } + + static func run(params: BenchParams) -> String { + let spec = BenchSpec( + name: params.function, + iterations: params.iterations, + warmup: params.warmup + ) + + do { + let report = try runBenchmark(spec: spec) + return formatBenchReport(report) + } catch let error as BenchError { + return formatBenchError(error) + } catch { + return "Unexpected error: \(error.localizedDescription)" + } + } + + private static func formatBenchReport(_ report: BenchReport) -> String { + var output = "=== Benchmark Results ===\n\n" + output += "Function: \(report.spec.name)\n" + output += "Iterations: \(report.spec.iterations)\n" + output += "Warmup: \(report.spec.warmup)\n\n" + + output += "Samples (\(report.samples.count)):\n" + for (index, sample) in report.samples.enumerated() { + let durationUs = Double(sample.durationNs) / 1_000.0 + output += " \(index + 1). \(String(format: "%.3f", durationUs)) μs (\(sample.durationNs) ns)\n" + } + + if !report.samples.isEmpty { + let durations = report.samples.map { Double($0.durationNs) / 1_000.0 } + let min = durations.min() ?? 0.0 + let max = durations.max() ?? 0.0 + let avg = durations.reduce(0, +) / Double(durations.count) + + output += "\nStatistics:\n" + output += " Min: \(String(format: "%.3f", min)) μs\n" + output += " Max: \(String(format: "%.3f", max)) μs\n" + output += " Avg: \(String(format: "%.3f", avg)) μs\n" + } + + return output + } + + private static func formatBenchError(_ error: BenchError) -> String { + switch error { + case .InvalidIterations(let message): + return "Error (InvalidIterations): \(message)" + case .UnknownFunction(let message): + return "Error (UnknownFunction): \(message)" + case .ExecutionFailed(let message): + return "Error (ExecutionFailed): \(message)" + } + } +} diff --git a/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template b/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template new file mode 100644 index 0000000..1264f44 --- /dev/null +++ b/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template @@ -0,0 +1,25 @@ +import SwiftUI + +struct ContentView: View { + @State private var report: String = "Running benchmark..." + + var body: some View { + ScrollView { + Text(report) + .font(.system(.body, design: .monospaced)) + .accessibilityIdentifier("benchmarkReport") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .background(Color(UIColor.systemBackground)) + .onAppear { + Task { + report = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + } + } + } +} + +#Preview { + ContentView() +} diff --git a/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template b/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template new file mode 100644 index 0000000..595b47a --- /dev/null +++ b/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template @@ -0,0 +1,12 @@ +import XCTest + +final class {{PROJECT_NAME_PASCAL}}UITests: XCTestCase { + func testLaunchShowsBenchmarkReport() { + let app = XCUIApplication() + app.launch() + + let report = app.staticTexts["benchmarkReport"] + let exists = report.waitForExistence(timeout: 30.0) + XCTAssertTrue(exists, "Benchmark report text should appear after launch") + } +} diff --git a/templates/ios/BenchRunner/project.yml b/templates/ios/BenchRunner/project.yml new file mode 100644 index 0000000..11f416b --- /dev/null +++ b/templates/ios/BenchRunner/project.yml @@ -0,0 +1,37 @@ +name: {{PROJECT_NAME_PASCAL}} +options: + bundleIdPrefix: {{BUNDLE_ID_PREFIX}} +settings: + base: + SWIFT_VERSION: 5.9 +targets: + {{PROJECT_NAME_PASCAL}}: + type: application + platform: iOS + deploymentTarget: "15.0" + sources: + - path: {{PROJECT_NAME_PASCAL}} + resources: + - path: {{PROJECT_NAME_PASCAL}}/Resources + optional: true + - path: ../../target/mobile-spec/ios + optional: true + info: + path: {{PROJECT_NAME_PASCAL}}/Info.plist + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID}} + SWIFT_OBJC_BRIDGING_HEADER: {{PROJECT_NAME_PASCAL}}/{{PROJECT_NAME_PASCAL}}-Bridging-Header.h + HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/{{PROJECT_NAME_PASCAL}}/Generated" + dependencies: + - framework: ../../target/ios/{{LIBRARY_NAME}}.xcframework + embed: true + link: true + {{PROJECT_NAME_PASCAL}}UITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "15.0" + sources: + - path: {{PROJECT_NAME_PASCAL}}UITests + dependencies: + - target: {{PROJECT_NAME_PASCAL}} diff --git a/test_browserstack.sh b/test_browserstack.sh new file mode 100755 index 0000000..58e73fc --- /dev/null +++ b/test_browserstack.sh @@ -0,0 +1,74 @@ +#!/bin/bash +set -e + +# Load credentials +export $(cat .env.local | xargs) + +echo "=== BrowserStack Manual Test Run ===" +echo "" + +# Upload Android APK +echo "1. Uploading Android APK..." +ANDROID_APP_URL=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/upload" \ + -F "file=@android/app/build/outputs/apk/debug/app-debug.apk" \ + | jq -r '.app_url') +echo "Android app uploaded: $ANDROID_APP_URL" +echo "" + +# Upload Android test APK +echo "2. Uploading Android test APK..." +ANDROID_TEST_URL=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite" \ + -F "file=@android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk" \ + | jq -r '.test_suite_url') +echo "Android test suite uploaded: $ANDROID_TEST_URL" +echo "" + +# Trigger Android Espresso run +echo "3. Triggering Android test on Pixel 7..." +ANDROID_BUILD=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/build" \ + -d "{\"app\": \"$ANDROID_APP_URL\", \"testSuite\": \"$ANDROID_TEST_URL\", \"devices\": [\"Google Pixel 7-13.0\"], \"project\": \"mobench-test\", \"deviceLogs\": true}" \ + -H "Content-Type: application/json") +echo "Android build started:" +echo "$ANDROID_BUILD" | jq '.' +ANDROID_BUILD_ID=$(echo "$ANDROID_BUILD" | jq -r '.build_id') +echo "" + +# Upload iOS app +echo "4. Uploading iOS app..." +IOS_APP_URL=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app" \ + -F "file=@target/ios/BenchRunner.zip" \ + | jq -r '.app_url') +echo "iOS app uploaded: $IOS_APP_URL" +echo "" + +# Upload iOS test suite +echo "5. Uploading iOS test suite..." +IOS_TEST_URL=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite" \ + -F "file=@target/ios/BenchRunnerUITests.zip" \ + | jq -r '.test_suite_url') +echo "iOS test suite uploaded: $IOS_TEST_URL" +echo "" + +# Trigger iOS XCUITest run +echo "6. Triggering iOS test on iPhone 14..." +IOS_BUILD=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/build" \ + -d "{\"app\": \"$IOS_APP_URL\", \"testSuite\": \"$IOS_TEST_URL\", \"devices\": [\"iPhone 14-16\"], \"project\": \"mobench-test\", \"deviceLogs\": true}" \ + -H "Content-Type: application/json") +echo "iOS build started:" +echo "$IOS_BUILD" | jq '.' +IOS_BUILD_ID=$(echo "$IOS_BUILD" | jq -r '.build_id') +echo "" + +echo "=== Test runs triggered ===" +echo "Android build ID: $ANDROID_BUILD_ID" +echo "iOS build ID: $IOS_BUILD_ID" +echo "" +echo "Monitor at:" +echo " Android: https://app-automate.browserstack.com/dashboard/v2/builds/$ANDROID_BUILD_ID" +echo " iOS: https://app-automate.browserstack.com/dashboard/v2/builds/$IOS_BUILD_ID" From 78ce38d35e5fd9d3e91240cad06b632fc82b348d Mon Sep 17 00:00:00 2001 From: wld-terraform Date: Fri, 16 Jan 2026 02:38:34 +0100 Subject: [PATCH 007/196] Update .github/workflows/relyance-sci.yml --- .github/workflows/relyance-sci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/relyance-sci.yml b/.github/workflows/relyance-sci.yml index fb33cf3..95feb3b 100644 --- a/.github/workflows/relyance-sci.yml +++ b/.github/workflows/relyance-sci.yml @@ -11,7 +11,8 @@ permissions: jobs: execute-relyance-sci: name: Relyance SCI Job - runs-on: ubuntu-latest + runs-on: + group: arc-public-large-amd64-runner permissions: contents: read From e8f12bb8477a26d76a64d985df78e43c52f884bb Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 16 Jan 2026 11:14:45 -0300 Subject: [PATCH 008/196] Add benchmark summaries, compare command, and CI polish (#5) * Improve mobench fetch and iOS/Android builders * Chore: align docs with markdownlint * Refine rust-analyzer diagnostics * Bump workspace version to 0.1.5 * Docs: refresh mobench testing and CLI guidance Update docs to reflect current CLI usage, remove host demo references, and clarify local device workflows. Ignore Cursor project files and sync lockfile version entries. * feat: add run summaries and CI artifacts Add JSON/Markdown (optional CSV) summaries for mobench runs and wire CI to publish host + BrowserStack results. * fix test * feat: add compare reports and device tag filters Add a mobench compare command plus device tag filtering from matrix configs, and publish benchmark summaries to CI job summaries. Refresh docs to cover compare usage and updated config examples. * chore: clean up formatting in tests and bindgen * address minor pr codex comments * fix: treat BrowserStack passed builds as complete Update polling to accept passed/completed build statuses and ignore run summary outputs in git. This prevents fetch timeouts after successful runs. * Docs: align configs, devices, and rustdoc naming Refresh markdown docs and rustdocs to use current mobench config names, BrowserStack device strings, and API references. Update codegen templates and examples to match mobench-sdk naming. --------- Co-authored-by: dcbuilder.eth --- .github/workflows/mobile-bench.yml | 80 +- .gitignore | 4 + .markdownlint.json | 10 + BENCH_SDK_INTEGRATION.md | 4 +- BROWSERSTACK_CI_INTEGRATION.md | 24 +- BROWSERSTACK_METRICS.md | 12 +- BUILD.md | 6 +- CLAUDE.md | 23 +- Cargo.lock | 63 +- Cargo.toml | 3 +- FETCH_RESULTS_GUIDE.md | 6 +- PROJECT_PLAN.md | 35 +- README.md | 28 +- TESTING.md | 45 +- crates/bench-cli/src/browserstack.rs | 37 +- crates/mobench-macros/Cargo.toml | 2 +- crates/mobench-macros/src/lib.rs | 4 +- crates/mobench-sdk/README.md | 50 +- crates/mobench-sdk/src/builders/android.rs | 4 +- crates/mobench-sdk/src/builders/ios.rs | 287 +++++- crates/mobench-sdk/src/codegen.rs | 66 +- crates/mobench-sdk/src/lib.rs | 12 +- crates/mobench-sdk/src/types.rs | 4 +- crates/mobench/Cargo.toml | 1 + crates/mobench/README.md | 129 ++- crates/mobench/src/browserstack.rs | 80 +- crates/mobench/src/main.rs | 832 +++++++++++++++--- .../sample-fns/src/bin/generate-bindings.rs | 158 ++-- examples/basic-benchmark/Cargo.toml | 2 +- examples/basic-benchmark/src/lib.rs | 10 +- 30 files changed, 1506 insertions(+), 515 deletions(-) create mode 100644 .markdownlint.json diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index 3facc4b..e6e27c2 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -25,6 +25,34 @@ jobs: - name: Cargo test run: cargo test --all + - name: Host benchmark summary + run: | + cargo run -p mobench -- run \ + --target android \ + --function sample_fns::fibonacci \ + --iterations 5 \ + --warmup 1 \ + --local-only \ + --output run-summary.json \ + --summary-csv + + - name: Publish host summary + if: ${{ always() }} + run: | + if [ -f run-summary.md ]; then + cat run-summary.md >> "$GITHUB_STEP_SUMMARY" + echo "::notice title=Host benchmark summary::Published to job summary" + fi + + - name: Upload host summaries + uses: actions/upload-artifact@v4 + with: + name: host-run-summary + path: | + run-summary.json + run-summary.md + run-summary.csv + android: if: ${{ github.event.inputs.platform == 'android' || github.event.inputs.platform == 'both' || github.event.inputs.platform == '' }} name: Android build (APK) @@ -100,37 +128,61 @@ jobs: target/ios/sample_fns.xcframework target/ios/include/sample_fns.h - browserstack-stub: - name: BrowserStack stub run + browserstack: + name: BrowserStack run needs: android if: ${{ secrets.BROWSERSTACK_USERNAME != '' && secrets.BROWSERSTACK_ACCESS_KEY != '' }} runs-on: ubuntu-latest + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} steps: - name: Checkout uses: actions/checkout@v4 - - name: Download APK from build - uses: actions/download-artifact@v4 - with: - name: mobile-bench-android-apk - path: artifacts - - name: Rust toolchain uses: dtolnay/rust-toolchain@stable - - name: Local bench summary (stub) + - name: Install cargo-ndk + run: cargo install cargo-ndk + + - name: Setup Android SDK/NDK + uses: android-actions/setup-android@v3 + with: + packages: | + platform-tools + platforms;android-34 + build-tools;34.0.0 + ndk;26.1.10909125 + + - name: BrowserStack benchmark run + env: + ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/26.1.10909125 run: | cargo run -p mobench -- run \ --target android \ --function sample_fns::fibonacci \ --iterations 5 \ --warmup 1 \ - --devices "browserstack-stub" \ - --local-only \ - --output run-summary.json + --devices "Pixel 7-13" \ + --fetch \ + --output run-summary.json \ + --summary-csv + + - name: Publish BrowserStack summary + if: ${{ always() }} + run: | + if [ -f run-summary.md ]; then + cat run-summary.md >> "$GITHUB_STEP_SUMMARY" + echo "::notice title=BrowserStack benchmark summary::Published to job summary" + fi - - name: Upload run summary + - name: Upload BrowserStack summaries uses: actions/upload-artifact@v4 with: name: browserstack-run-summary - path: run-summary.json + path: | + run-summary.json + run-summary.md + run-summary.csv + target/browserstack diff --git a/.gitignore b/.gitignore index 97ca88b..5340037 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ target/ **/*.DS_Store .vscode/ +.cursor/ android/app/src/main/jniLibs/*/libsample_fns.so android/app/src/main/jniLibs/*/libuniffi_sample_fns.so android/build/ @@ -17,6 +18,9 @@ target/ios/ # CLI-generated artifacts run-summary.json +run-summary-*.json +run-summary-*.md +run-summary-*.csv bench-config.toml device-matrix.yaml run-*.json diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..c844c3d --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,10 @@ +{ + "MD013": false, + "MD022": false, + "MD024": false, + "MD031": false, + "MD032": false, + "MD034": false, + "MD036": false, + "MD040": false +} diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index b2ed4b7..55ab67c 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -64,7 +64,7 @@ cargo mobench init-sdk --target both --project-name my-bench --output-dir . This generates: - `bench-mobile/` (FFI bridge that links your crate) - `android/` and `ios/` app templates -- `mobench-sdk.toml` configuration +- `bench-config.toml` configuration ## 5) Local Android testing @@ -138,7 +138,7 @@ cargo mobench run \ --function my_crate::checksum_bench \ --iterations 100 \ --warmup 10 \ - --devices "Pixel 7-13.0" + --devices "Google Pixel 7-13.0" ``` The CLI will automatically: diff --git a/BROWSERSTACK_CI_INTEGRATION.md b/BROWSERSTACK_CI_INTEGRATION.md index 279faae..4584884 100644 --- a/BROWSERSTACK_CI_INTEGRATION.md +++ b/BROWSERSTACK_CI_INTEGRATION.md @@ -38,7 +38,7 @@ println!("Build ID: {}", run.build_id); println!("Dashboard: https://app-automate.browserstack.com/dashboard/v2/builds/{}", run.build_id); // 4. Wait for completion and fetch results -let results = client.wait_and_fetch_results(&run.build_id, "espresso", Some(600))?; +let (results, _performance) = client.wait_and_fetch_all_results(&run.build_id, "espresso", Some(600))?; // 5. Process results for (device, bench_results) in results { @@ -53,18 +53,16 @@ for (device, bench_results) in results { ### Using `mobench run` with Result Fetching -The CLI can be extended to wait for results: - ```bash -# Run and wait for results (not yet implemented in CLI) +# Run and fetch results cargo mobench run \ --target android \ --function sample_fns::fibonacci \ --iterations 30 \ --warmup 5 \ --devices "Google Pixel 7-13.0" \ - --wait \ - --timeout 600 \ + --fetch \ + --fetch-timeout-secs 600 \ --output results.json ``` @@ -107,7 +105,7 @@ let status = client.get_xcuitest_build_status(build_id)?; **Build Status Values:** - `"running"` - Tests are executing -- `"done"` - Tests completed successfully +- `"done"` / `"passed"` / `"completed"` - Tests completed successfully - `"failed"` - Tests failed - `"error"` - Build error occurred - `"timeout"` - Exceeded time limit @@ -140,7 +138,7 @@ for result in results { ```rust use std::collections::HashMap; -let results: HashMap> = client.wait_and_fetch_results( +let (results, _performance) = client.wait_and_fetch_all_results( build_id, "espresso", Some(600), // 10 minute timeout @@ -185,15 +183,15 @@ jobs: BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} run: | - # Build and run (waits for completion) + # Build and run (fetches results) cargo mobench run \ --target android \ --function my_crate::my_benchmark \ --iterations 30 \ --warmup 5 \ --devices "Google Pixel 7-13.0" \ - --wait \ - --timeout 600 \ + --fetch \ + --fetch-timeout-secs 600 \ --output results.json # Extract metrics for comparison @@ -261,8 +259,8 @@ fn process_benchmark_results( ## Error Handling ```rust -match client.wait_and_fetch_results(build_id, "espresso", Some(600)) { - Ok(results) => { +match client.wait_and_fetch_all_results(build_id, "espresso", Some(600)) { + Ok((results, _performance)) => { println!("Successfully fetched results from {} devices", results.len()); } Err(e) if e.to_string().contains("Timeout") => { diff --git a/BROWSERSTACK_METRICS.md b/BROWSERSTACK_METRICS.md index 70e8a8d..023eb67 100644 --- a/BROWSERSTACK_METRICS.md +++ b/BROWSERSTACK_METRICS.md @@ -8,7 +8,7 @@ This document describes what device metrics BrowserStack provides and what we cu **Build-level:** - Build ID -- Build status (running, done, failed, error, timeout) +- Build status (running, done, passed, completed, failed, error, timeout) - Build duration (total time in seconds) **Session-level:** @@ -262,19 +262,15 @@ For CI/benchmarking on BrowserStack: 1. **Implement custom metric collection** in your app 2. **Log metrics as JSON** to stdout/logcat -3. **Extend mobench** to extract performance metrics (future enhancement) +3. **Use mobench `--fetch`** to extract performance metrics from logs 4. **Focus on metrics that matter** for your use case: - Memory: Peak usage, allocations during benchmark - CPU: Usage spikes during computation - Time: Already well-captured by benchmark harness -## Current Workaround +## Manual Inspection -Until performance metric extraction is built-in: - -1. Log performance metrics from your app as JSON -2. Use `--fetch` to download device logs -3. Manually parse performance data from logs: +If you need to inspect raw logs, you can still parse them directly: ```bash cargo mobench run --fetch --output results.json diff --git a/BUILD.md b/BUILD.md index 7ac4060..1ad9925 100644 --- a/BUILD.md +++ b/BUILD.md @@ -434,11 +434,11 @@ pub fn my_new_function(arg: MyNewType) -> Result { Then regenerate bindings as shown above. -## Performance Testing +## Host Testing -Run benchmarks locally without mobile builds: +Run host-side Rust tests: ```bash -cargo mobench demo --iterations 100 --warmup 10 +cargo test --all ``` ## Additional Documentation diff --git a/CLAUDE.md b/CLAUDE.md index 6338c56..1c08a2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.4):** +**Published on crates.io as the mobench ecosystem (v0.1.5):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with build automation - **[mobench-macros](https://crates.io/crates/mobench-macros)** - `#[benchmark]` attribute proc macro @@ -70,7 +70,7 @@ The CLI supports both Espresso (Android) and XCUITest (iOS) test automation fram For comprehensive testing instructions, see **`TESTING.md`** which includes: - Prerequisites and setup -- Host testing (cargo test, CLI demo) +- Host testing (cargo test) - Android testing (emulator, device, Android Studio; use `UNIFFI_ANDROID_ABI=x86_64` for default emulators) - iOS testing (simulator, device, Xcode) - Troubleshooting common issues @@ -82,7 +82,7 @@ Quick test commands: cargo test --all # Initialize SDK project -cargo mobench init --target android --output bench-sdk.toml +cargo mobench init --target android --output bench-config.toml # Build mobile artifacts (recommended approach) cargo mobench build --target android @@ -107,7 +107,7 @@ The `cargo mobench` CLI provides a unified build experience: cargo install mobench # Initialize project (generates config and scaffolding) -cargo mobench init --target android --output bench-sdk.toml +cargo mobench init --target android --output bench-config.toml # Build for Android cargo mobench build --target android @@ -216,13 +216,12 @@ Note: UniFFI C headers are generated automatically during the build process and #### Local Testing (No BrowserStack) ```bash -# Run benchmark locally on emulator/simulator +# Build artifacts and write bench_spec.json (launch the app manually) cargo mobench run \ --target android \ --function sample_fns::fibonacci \ --iterations 100 \ --warmup 10 \ - --local-only \ --output run-summary.json ``` @@ -238,7 +237,7 @@ cargo mobench run \ --function sample_fns::checksum \ --iterations 30 \ --warmup 5 \ - --devices "Pixel 7-13" \ + --devices "Google Pixel 7-13.0" \ --output run-summary.json ``` @@ -377,7 +376,7 @@ mobench-sdk = "0.1" inventory = "0.3" ``` -2. Mark functions with `#[benchmark]`: +1. Mark functions with `#[benchmark]`: ```rust use mobench_sdk::benchmark; @@ -388,13 +387,13 @@ fn my_function() { } ``` -3. Build for mobile: +1. Build for mobile: ```bash cargo mobench build --target android cargo mobench build --target ios ``` -4. Run benchmarks: +1. Run benchmarks: ```bash cargo mobench run --target android --function my_function ``` @@ -466,11 +465,11 @@ test_suite = "target/ios/BenchRunnerUITests.zip" ### `device-matrix.yaml` (generated by `plan` command) ```yaml devices: - - name: Pixel 7 + - name: Google Pixel 7-13.0 os: android os_version: "13.0" tags: [default, pixel] - - name: iPhone 14 + - name: iPhone 14-16 os: ios os_version: "16" tags: [default, iphone] diff --git a/Cargo.lock b/Cargo.lock index 4f77f69..07fa5e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,6 +265,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -784,7 +793,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "clap", @@ -798,12 +807,13 @@ dependencies = [ "serde_json", "serde_yaml", "tempfile", + "time", "toml 0.8.23", ] [[package]] name = "mobench-macros" -version = "0.1.4" +version = "0.1.5" dependencies = [ "proc-macro2", "quote", @@ -812,7 +822,7 @@ dependencies = [ [[package]] name = "mobench-runner" -version = "0.1.4" +version = "0.1.5" dependencies = [ "serde", "thiserror 1.0.69", @@ -820,7 +830,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "include_dir", @@ -843,6 +853,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "once_cell" version = "1.21.3" @@ -894,6 +910,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1134,7 +1156,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.4" +version = "0.1.5" dependencies = [ "camino", "mobench-runner", @@ -1409,6 +1431,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 93d72f8..66d36c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.4" +version = "0.1.5" [workspace.dependencies] anyhow = "1" @@ -23,6 +23,7 @@ thiserror = "1" serde_yaml = "0.9" toml = "0.8" uniffi = "0.28" +time = { version = "0.3", features = ["formatting"] } # Phase 1: Registry and proc macros inventory = "0.3" diff --git a/FETCH_RESULTS_GUIDE.md b/FETCH_RESULTS_GUIDE.md index 0490fc4..8d25d8a 100644 --- a/FETCH_RESULTS_GUIDE.md +++ b/FETCH_RESULTS_GUIDE.md @@ -23,7 +23,7 @@ When `--fetch` is enabled: 1. **Builds and uploads** artifacts to BrowserStack 2. **Schedules** test run on specified devices -3. **Polls** for build completion (checks every 10 seconds) +3. **Polls** for build completion (checks every 5 seconds) 4. **Fetches** device logs from all sessions 5. **Extracts** benchmark results as JSON 6. **Merges** results into output file @@ -71,7 +71,7 @@ With `--fetch`, the output JSON includes a `benchmark_results` field: ### Timeout -Control how long to wait for build completion (default: 1800 seconds / 30 minutes): +Control how long to wait for build completion (default: 300 seconds / 5 minutes): ```bash cargo mobench run \ @@ -84,7 +84,7 @@ cargo mobench run \ ### Poll Interval -Control how often to check build status (default: 10 seconds): +Control how often to check build status (default: 5 seconds): ```bash cargo mobench run \ diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 777b246..0f56510 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -27,22 +27,31 @@ - Benchmark a single exported Rust function with configurable iterations. - Build Android APK + iOS app/xcframework locally and in CI. - Trigger one Android device run on BrowserStack and capture timing JSON. -- CLI command: `mobench run --target android --function path::to::fn --devices "pixel_7"` producing a report. - -## Task Backlog (initial) - -- [ ] Repo bootstrap: Cargo workspace, `mobench` binary crate, `bench-runner` library crate, example `sample-fns` crate. -- [ ] Define FFI boundary: macro/attribute to mark benchmarkable Rust functions; export through C ABI; basic timing harness. -- [ ] Android packaging: cargo-ndk config, Kotlin wrapper module, minimal test/activity to trigger Rust bench entrypoint. -- [ ] iOS packaging: xcframework build script (cargo lipo or cargo-apple), C header generation (cbindgen), Swift wrapper, test host. -- [ ] CLI scaffolding: parse config (function path, iterations, warmups, device matrix), invoke build scripts, prepare artifacts. -- [ ] BrowserStack integration: AppAutomate REST client (upload builds, start sessions, poll status, download logs/artifacts). -- [ ] Result handling: normalize timing output to JSON, aggregate across iterations/devices, emit markdown/CSV summary. -- [ ] CI: GitHub Actions workflow covering build, artifact upload, BrowserStack-triggered run (behind secrets), and report upload. -- [ ] Developer UX: local smoke test runners, sample bench functions, docs with step-by-step usage. +- CLI command: `mobench run --target android --function path::to::fn --devices "Google Pixel 7-13.0"` producing a report. + +## Task Backlog + +- [x] Repo bootstrap: Cargo workspace, `mobench` binary crate, `bench-runner` library crate, example `sample-fns` crate. +- [x] Define FFI boundary: macro/attribute to mark benchmarkable Rust functions; export through C ABI; basic timing harness. +- [x] Android packaging: cargo-ndk config, Kotlin wrapper module, minimal test/activity to trigger Rust bench entrypoint. +- [x] iOS packaging: xcframework build script (cargo lipo or cargo-apple), C header generation (cbindgen), Swift wrapper, test host. +- [x] CLI scaffolding: parse config (function path, iterations, warmups, device matrix), invoke build scripts, prepare artifacts. +- [x] BrowserStack integration: AppAutomate REST client (upload builds, start sessions, poll status, download logs/artifacts). +- [x] Result handling: normalize timing output to JSON, aggregate across iterations/devices, emit markdown/CSV summary. +- [x] CI: GitHub Actions workflow covering build, artifact upload, BrowserStack-triggered run (behind secrets), and report upload. +- [x] Developer UX: local smoke test runners, sample bench functions, docs with step-by-step usage. +- [x] Add markdown + CSV summary output for `mobench run` results. +- [x] Wire device matrix config into `mobench run` (load devices by tag). +- [x] Replace BrowserStack stub run in CI with real AppAutomate run and fetch. +- [x] Add GH Actions summary/annotations for benchmark results. +- [x] Add regression comparison command (compare two JSON summaries). + +## Suggested Next Tasks + - [ ] Stretch: parallel device runs, retries, percentile stats, optional energy/thermal readings where available. ## In-Repo Placeholders (current) + - Scripts: `scripts/build-android.sh`, `scripts/build-ios.sh` for manual/CI builds (require Android NDK / cargo-apple). - Android demo app: `android/` Gradle project that loads the Rust demo cdylib (`sample-fns`) and displays results. - Workflow: `.github/workflows/mobile-bench.yml` manual build for Android; extend with BrowserStack upload/run and iOS job. diff --git a/README.md b/README.md index ec6c3a7..5f6abb4 100644 --- a/README.md +++ b/README.md @@ -80,17 +80,17 @@ cargo mobench list ## Quick Start -### Host Demo (No Mobile Build Required) +### Host Testing (No Mobile Build Required) -Test the benchmarking harness locally: +Run the host-side Rust tests: ```bash -cargo mobench demo --iterations 10 --warmup 2 +cargo test --all ``` ### Mobile Testing -For complete end-to-end testing on Android/iOS, see the **[End-to-End Testing](#end-to-end-testing)** section below. +For complete end-to-end testing on Android/iOS, see the **[BrowserStack Workflow](#browserstack-workflow)** section below. **Quick commands:** @@ -104,6 +104,12 @@ cargo mobench init --output bench-config.toml cargo mobench plan --output device-matrix.yaml ``` +### Run Outputs + +`cargo mobench run` writes a JSON summary to `run-summary.json` by default and +produces a Markdown summary alongside it (`run-summary.md`). Use `--output` to +change the base filename and `--summary-csv` to emit a CSV summary. + ## UniFFI Bindings (Proc Macro Mode) This project uses [UniFFI](https://mozilla.github.io/uniffi-rs/) with **proc macros** to generate type-safe Kotlin and Swift bindings from Rust code. @@ -161,6 +167,14 @@ mobile-bench-rs supports two testing workflows: --- +## Deferred QoL (Post-Feedback) + +These improvements are intentionally deferred until we have real usage feedback: + +- Parallel device runs and retry policies +- Additional percentile/statistics enhancements +- Energy or thermal readings where supported + ## Local Development Workflow Test your benchmarks locally using Android Studio or Xcode. This is the fastest way to iterate during development. @@ -333,7 +347,7 @@ cargo mobench run \ --function sample_fns::fibonacci \ --iterations 100 \ --warmup 10 \ - --devices "Pixel 7-13" \ + --devices "Google Pixel 7-13.0" \ --output run-summary.json ``` @@ -451,11 +465,11 @@ project = "mobile-bench-rs" **device-matrix.yaml:** ```yaml devices: - - name: Pixel 7 + - name: Google Pixel 7-13.0 os: android os_version: "13.0" tags: [default, pixel] - - name: Samsung Galaxy S23 + - name: Samsung Galaxy S23-13.0 os: android os_version: "13.0" tags: [samsung] diff --git a/TESTING.md b/TESTING.md index 0f18d5b..6f823f2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,6 @@ This document provides comprehensive testing instructions for mobile-bench-rs. > - `cargo mobench build --target ` for builds > - Scripts shown below are legacy tooling for this repository > - See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide - > **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see **[BUILD.md](BUILD.md)**. This document focuses on testing scenarios and troubleshooting. ## Table of Contents @@ -71,23 +70,14 @@ cargo test --all Expected output: All tests pass (11 tests total as of UniFFI migration). -### CLI Demo -Test the benchmarking harness without mobile builds: -```bash -cargo mobench demo --iterations 10 --warmup 2 -``` - -Expected output: JSON report with timing samples for `fibonacci` function. +### CLI Note +The CLI does not currently expose a host-only demo command. Use `cargo test --all` for host +validation and use `cargo mobench run` to execute benchmarks on devices. -### Testing Different Functions -```bash -# Test fibonacci (default) -cargo mobench demo --iterations 5 --warmup 1 - -# Currently supports: -# - fibonacci / fib / sample_fns::fibonacci -# - checksum / checksum_1k / sample_fns::checksum -``` +### CI Artifacts +The `Mobile Bench (manual)` workflow uploads summary artifacts: +- `host-run-summary` (JSON + Markdown + optional CSV from host-only run) +- `browserstack-run-summary` (JSON + Markdown + optional CSV + fetched logs when secrets are set) ## Android Testing @@ -495,20 +485,27 @@ Compare benchmark results across builds: cargo mobench run \ --target android \ --function sample_fns::fibonacci \ + --devices "Google Pixel 7-13.0" \ --iterations 100 \ - --local-only \ - --output results-v1.json + --fetch \ + --output results-v1.json \ + --summary-csv # After changes, run again cargo mobench run \ --target android \ --function sample_fns::fibonacci \ + --devices "Google Pixel 7-13.0" \ --iterations 100 \ - --local-only \ - --output results-v2.json - -# Compare results (requires jq) -jq -s '.[0].local_report.samples, .[1].local_report.samples' results-v1.json results-v2.json + --fetch \ + --output results-v2.json \ + --summary-csv + +# Compare summaries +cargo mobench compare \ + --baseline results-v1.json \ + --candidate results-v2.json \ + --output comparison.md ``` ### Adding New Test Functions diff --git a/crates/bench-cli/src/browserstack.rs b/crates/bench-cli/src/browserstack.rs index 2463413..7e78c1a 100644 --- a/crates/bench-cli/src/browserstack.rs +++ b/crates/bench-cli/src/browserstack.rs @@ -1,7 +1,7 @@ -use anyhow::{Context, Result, anyhow}; +use anyhow::{anyhow, Context, Result}; use reqwest::blocking::multipart::Form; use reqwest::blocking::{Client, Response}; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; use std::path::Path; @@ -39,7 +39,7 @@ impl BrowserStackClient { } #[cfg(test)] - #[allow(dead_code)] // Used in tests to verify URL construction + #[allow(dead_code)] // Used in tests to verify URL construction pub fn with_base_url(mut self, base_url: impl Into) -> Self { self.base_url = base_url.into(); self @@ -364,7 +364,10 @@ mod tests { .unwrap(); let url = client.api("app-automate/espresso/v2/app"); - assert_eq!(url, "https://api-cloud.browserstack.com/app-automate/espresso/v2/app"); + assert_eq!( + url, + "https://api-cloud.browserstack.com/app-automate/espresso/v2/app" + ); } #[test] @@ -379,7 +382,10 @@ mod tests { .unwrap(); let url = client.api("/app-automate/builds"); - assert_eq!(url, "https://api-cloud.browserstack.com/app-automate/builds"); + assert_eq!( + url, + "https://api-cloud.browserstack.com/app-automate/builds" + ); } #[test] @@ -409,11 +415,7 @@ mod tests { ) .unwrap(); - let result = client.schedule_espresso_run( - &[], - "bs://app123", - "bs://test456", - ); + let result = client.schedule_espresso_run(&[], "bs://app123", "bs://test456"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("empty")); @@ -430,11 +432,8 @@ mod tests { ) .unwrap(); - let result = client.schedule_espresso_run( - &["Pixel 7-13".to_string()], - "", - "bs://test456", - ); + let result = + client.schedule_espresso_run(&["Google Pixel 7-13.0".to_string()], "", "bs://test456"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("app_url")); @@ -452,7 +451,7 @@ mod tests { .unwrap(); let result = client.schedule_espresso_run( - &["Pixel 7-13".to_string()], + &["Google Pixel 7-13.0".to_string()], "bs://app123", "", ); @@ -472,11 +471,7 @@ mod tests { ) .unwrap(); - let result = client.schedule_xcuitest_run( - &[], - "bs://app123", - "bs://test456", - ); + let result = client.schedule_xcuitest_run(&[], "bs://app123", "bs://test456"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("empty")); diff --git a/crates/mobench-macros/Cargo.toml b/crates/mobench-macros/Cargo.toml index f857b09..a63ce0e 100644 --- a/crates/mobench-macros/Cargo.toml +++ b/crates/mobench-macros/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true authors = ["Dominik Clemente - dcbuilder.eth "] -description = "Proc macros for bench-sdk - #[benchmark] attribute" +description = "Proc macros for mobench-sdk - #[benchmark] attribute" repository = "https://github.com/worldcoin/mobile-bench-rs" readme = "README.md" keywords = ["benchmark", "mobile", "macro", "procedural"] diff --git a/crates/mobench-macros/src/lib.rs b/crates/mobench-macros/src/lib.rs index 8096414..88e40dc 100644 --- a/crates/mobench-macros/src/lib.rs +++ b/crates/mobench-macros/src/lib.rs @@ -1,4 +1,4 @@ -//! Procedural macros for bench-sdk +//! Procedural macros for mobench-sdk //! //! This crate provides the `#[benchmark]` attribute macro for marking functions //! as benchmarkable. Functions marked with this attribute are automatically @@ -11,7 +11,7 @@ use syn::{ItemFn, parse_macro_input}; /// Marks a function as a benchmark. /// /// This macro registers the function in the global benchmark registry and -/// makes it available for execution via the bench-sdk runtime. +/// makes it available for execution via the mobench-sdk runtime. /// /// # Example /// diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index 67efba4..8cc07a1 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -80,7 +80,7 @@ cargo mobench init --target android # or ios, or both This creates: - `bench-mobile/` - FFI wrapper crate - `android/` or `ios/` - Mobile app projects -- `bench-sdk.toml` - Configuration file +- `bench-config.toml` - Configuration file ### 2. Add Benchmarks @@ -101,9 +101,9 @@ cargo mobench build --target android ### 4. Run on Devices -Local emulator: +Local device workflow (builds artifacts and writes the run spec; launch the app manually): ```bash -cargo mobench run my_benchmark --local-only +cargo mobench run --target android --function my_benchmark ``` BrowserStack: @@ -111,7 +111,7 @@ BrowserStack: export BROWSERSTACK_USERNAME=your_username export BROWSERSTACK_ACCESS_KEY=your_key -cargo mobench run my_benchmark --devices "Pixel 7-13,iPhone 14-16" +cargo mobench run --target android --function my_benchmark --devices "Google Pixel 7-13.0" ``` ## API Documentation @@ -340,30 +340,38 @@ fn btreemap_insert_1000() { ## Configuration -### `bench-sdk.toml` +### `bench-config.toml` ```toml -[project] -name = "my-benchmarks" -target = "both" # android, ios, or both - -[build] -profile = "release" # or "debug" +target = "android" +function = "sample_fns::fibonacci" +iterations = 100 +warmup = 10 +device_matrix = "device-matrix.yaml" +device_tags = ["default"] # optional; filter devices by tag [browserstack] -username = "${BROWSERSTACK_USERNAME}" -access_key = "${BROWSERSTACK_ACCESS_KEY}" +app_automate_username = "${BROWSERSTACK_USERNAME}" +app_automate_access_key = "${BROWSERSTACK_ACCESS_KEY}" project = "my-project-benchmarks" -[[devices]] -name = "Pixel 7" -os = "android" -os_version = "13.0" +[ios_xcuitest] +app = "target/ios/BenchRunner.ipa" +test_suite = "target/ios/BenchRunnerUITests.zip" +``` -[[devices]] -name = "iPhone 14" -os = "ios" -os_version = "16" +### `device-matrix.yaml` + +```yaml +devices: + - name: "Google Pixel 7-13.0" + os: "android" + os_version: "13.0" + tags: ["default", "pixel"] + - name: "iPhone 14-16" + os: "ios" + os_version: "16" + tags: ["default", "iphone"] ``` ## Requirements diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index ed6dd10..ec3e0e7 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -5,7 +5,7 @@ use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; use std::env; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; /// Android builder that handles the complete build pipeline @@ -395,7 +395,7 @@ impl AndroidBuilder { } // Shared helpers -fn host_lib_path(project_dir: &PathBuf, crate_name: &str) -> Result { +fn host_lib_path(project_dir: &Path, crate_name: &str) -> Result { let lib_prefix = if cfg!(target_os = "windows") { "" } else { diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 368921c..a757ae8 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -372,6 +372,7 @@ impl IosBuilder { framework_name: &str, platform: &str, ) -> Result<(), BenchError> { + let bundle_id = framework_name.replace('_', "-"); let plist_content = format!( r#" @@ -398,7 +399,7 @@ impl IosBuilder { "#, framework_name, - framework_name, + bundle_id, framework_name, if platform == "ios" { "iPhoneOS" @@ -582,7 +583,7 @@ impl IosBuilder { } // Shared helpers (duplicated with android builder) -fn host_lib_path(project_dir: &PathBuf, crate_name: &str) -> Result { +fn host_lib_path(project_dir: &Path, crate_name: &str) -> Result { let lib_prefix = if cfg!(target_os = "windows") { "" } else { @@ -627,6 +628,91 @@ fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> { Ok(()) } +#[allow(clippy::collapsible_if)] +fn find_codesign_identity() -> Option { + let output = Command::new("security") + .args(["find-identity", "-v", "-p", "codesigning"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + let mut identities = Vec::new(); + for line in stdout.lines() { + if let Some(start) = line.find('"') { + if let Some(end) = line[start + 1..].find('"') { + identities.push(line[start + 1..start + 1 + end].to_string()); + } + } + } + let preferred = [ + "Apple Distribution", + "iPhone Distribution", + "Apple Development", + "iPhone Developer", + ]; + for label in preferred { + if let Some(identity) = identities.iter().find(|i| i.contains(label)) { + return Some(identity.clone()); + } + } + identities.first().cloned() +} + +#[allow(clippy::collapsible_if)] +fn find_provisioning_profile() -> Option { + if let Ok(path) = env::var("MOBENCH_IOS_PROFILE") { + let profile = PathBuf::from(path); + if profile.exists() { + return Some(profile); + } + } + let home = env::var("HOME").ok()?; + let profiles_dir = PathBuf::from(home).join("Library/MobileDevice/Provisioning Profiles"); + let entries = fs::read_dir(&profiles_dir).ok()?; + let mut newest: Option<(std::time::SystemTime, PathBuf)> = None; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("mobileprovision") { + continue; + } + if let Ok(metadata) = entry.metadata() + && let Ok(modified) = metadata.modified() + { + match &newest { + Some((current, _)) if *current >= modified => {} + _ => newest = Some((modified, path)), + } + } + } + newest.map(|(_, path)| path) +} + +fn embed_provisioning_profile(app_path: &Path, profile: &Path) -> Result<(), BenchError> { + let dest = app_path.join("embedded.mobileprovision"); + fs::copy(profile, &dest).map_err(|e| { + BenchError::Build(format!( + "Failed to embed provisioning profile {:?}: {}", + dest, e + )) + })?; + Ok(()) +} + +fn codesign_bundle(app_path: &Path, identity: &str) -> Result<(), BenchError> { + let output = Command::new("codesign") + .args(["--force", "--deep", "--sign", identity]) + .arg(app_path) + .output() + .map_err(|e| BenchError::Build(format!("Failed to run codesign: {}", e)))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!("codesign failed: {}", stderr))); + } + Ok(()) +} + /// iOS code signing methods for IPA packaging #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SigningMethod { @@ -689,8 +775,9 @@ impl IosBuilder { // Step 1: Build the app for device (simpler than archiving) let build_dir = self.project_root.join("target/ios/build"); + let build_configuration = "Debug"; let mut cmd = Command::new("xcodebuild"); - cmd.args(&[ + cmd.args([ "-project", project_path.to_str().unwrap(), "-scheme", @@ -698,7 +785,7 @@ impl IosBuilder { "-destination", "generic/platform=iOS", "-configuration", - "Release", + build_configuration, "-derivedDataPath", build_dir.to_str().unwrap(), "build", @@ -709,11 +796,11 @@ impl IosBuilder { SigningMethod::AdHoc => { // Ad-hoc signing (works for BrowserStack, no Apple ID needed) // For ad-hoc, we disable signing during build and sign manually after - cmd.args(&["CODE_SIGNING_REQUIRED=NO", "CODE_SIGNING_ALLOWED=NO"]); + cmd.args(["CODE_SIGNING_REQUIRED=NO", "CODE_SIGNING_ALLOWED=NO"]); } SigningMethod::Development => { // Development signing (requires Apple Developer account) - cmd.args(&[ + cmd.args([ "CODE_SIGN_STYLE=Automatic", "CODE_SIGN_IDENTITY=iPhone Developer", ]); @@ -729,7 +816,7 @@ impl IosBuilder { // Step 2: Check if the .app bundle was created (even if validation failed) let app_path = build_dir - .join("Build/Products/Release-iphoneos") + .join(format!("Build/Products/{}-iphoneos", build_configuration)) .join(format!("{}.app", scheme)); if !app_path.exists() { @@ -740,12 +827,11 @@ impl IosBuilder { "xcodebuild build failed and app bundle not found: {}", stderr ))); - } else { - return Err(BenchError::Build(format!( - "App bundle not found at {:?}. Build may have failed.", - app_path - ))); } + return Err(BenchError::Build(format!( + "App bundle not found at {:?}. Build may have failed.", + app_path + ))); } if self.verbose { @@ -753,26 +839,38 @@ impl IosBuilder { } if matches!(method, SigningMethod::AdHoc) { - let output = Command::new("codesign") - .arg("--force") - .arg("--deep") - .arg("--sign") - .arg("-") - .arg(&app_path) - .output(); - - match output { - Ok(output) if output.status.success() => { + let profile = find_provisioning_profile(); + let identity = find_codesign_identity(); + match (profile.as_ref(), identity.as_ref()) { + (Some(profile), Some(identity)) => { + embed_provisioning_profile(&app_path, profile)?; + codesign_bundle(&app_path, identity)?; if self.verbose { - println!(" Signed app bundle with ad-hoc identity"); + println!(" Signed app bundle with identity {}", identity); } } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - println!("Warning: Ad-hoc signing failed: {}", stderr); - } - Err(err) => { - println!("Warning: Could not run codesign: {}", err); + _ => { + let output = Command::new("codesign") + .arg("--force") + .arg("--deep") + .arg("--sign") + .arg("-") + .arg(&app_path) + .output(); + match output { + Ok(output) if output.status.success() => { + println!( + "Warning: Signed app bundle without provisioning profile; BrowserStack install may fail." + ); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("Warning: Ad-hoc signing failed: {}", stderr); + } + Err(err) => { + println!("Warning: Could not run codesign: {}", err); + } + } } } } @@ -800,7 +898,7 @@ impl IosBuilder { } let mut cmd = Command::new("zip"); - cmd.args(&["-qr", ipa_path.to_str().unwrap(), "Payload"]) + cmd.args(["-qr", ipa_path.to_str().unwrap(), "Payload"]) .current_dir(&export_path); if self.verbose { @@ -817,6 +915,135 @@ impl IosBuilder { Ok(ipa_path) } + /// Packages the XCUITest runner app into a zip for BrowserStack. + /// + /// This requires the app project to be generated first with `build()`. + /// The resulting zip can be supplied to BrowserStack as the test suite. + pub fn package_xcuitest(&self, scheme: &str) -> Result { + let ios_dir = self.project_root.join("ios").join(scheme); + let project_path = ios_dir.join(format!("{}.xcodeproj", scheme)); + + if !project_path.exists() { + return Err(BenchError::Build(format!( + "Xcode project not found at {:?}. Run `cargo mobench build --target ios` first.", + project_path + ))); + } + + let export_path = self.project_root.join("target/ios"); + fs::create_dir_all(&export_path) + .map_err(|e| BenchError::Build(format!("Failed to create export directory: {}", e)))?; + + let build_dir = self.project_root.join("target/ios/build"); + println!("Building XCUITest runner for {}...", scheme); + + let mut cmd = Command::new("xcodebuild"); + cmd.args([ + "build-for-testing", + "-project", + project_path.to_str().unwrap(), + "-scheme", + scheme, + "-destination", + "generic/platform=iOS", + "-sdk", + "iphoneos", + "-configuration", + "Release", + "-derivedDataPath", + build_dir.to_str().unwrap(), + "VALIDATE_PRODUCT=NO", + "CODE_SIGN_STYLE=Manual", + "CODE_SIGN_IDENTITY=", + "CODE_SIGNING_ALLOWED=NO", + "CODE_SIGNING_REQUIRED=NO", + "DEVELOPMENT_TEAM=", + "PROVISIONING_PROFILE_SPECIFIER=", + "ENABLE_BITCODE=NO", + "BITCODE_GENERATION_MODE=none", + "STRIP_BITCODE_FROM_COPIED_FILES=NO", + ]); + + if self.verbose { + println!(" Running: {:?}", cmd); + } + + let runner_name = format!("{}UITests-Runner.app", scheme); + let runner_path = build_dir + .join("Build/Products/Release-iphoneos") + .join(&runner_name); + + let build_result = cmd.output(); + let log_path = export_path.join("xcuitest-build.log"); + if let Ok(output) = &build_result + && !output.status.success() + { + let mut log = String::new(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + log.push_str("STDOUT:\n"); + log.push_str(&stdout); + log.push_str("\n\nSTDERR:\n"); + log.push_str(&stderr); + let _ = fs::write(&log_path, log); + println!("xcodebuild log written to {:?}", log_path); + if runner_path.exists() { + println!( + "Warning: xcodebuild build-for-testing failed, but runner exists: {}", + stderr + ); + } + } + + if !runner_path.exists() { + if let Ok(output) = build_result { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "xcodebuild build-for-testing failed and runner not found: {}", + stderr + ))); + } + return Err(BenchError::Build(format!( + "XCUITest runner not found at {:?}. Build may have failed.", + runner_path + ))); + } + + let profile = find_provisioning_profile(); + let identity = find_codesign_identity(); + if let (Some(profile), Some(identity)) = (profile.as_ref(), identity.as_ref()) { + embed_provisioning_profile(&runner_path, profile)?; + codesign_bundle(&runner_path, identity)?; + if self.verbose { + println!(" Signed XCUITest runner with identity {}", identity); + } + } else { + println!( + "Warning: No provisioning profile/identity found; XCUITest runner may not install." + ); + } + + let zip_path = export_path.join(format!("{}UITests.zip", scheme)); + if zip_path.exists() { + fs::remove_file(&zip_path) + .map_err(|e| BenchError::Build(format!("Failed to remove old zip: {}", e)))?; + } + + let mut zip_cmd = Command::new("zip"); + zip_cmd + .args(["-qr", zip_path.to_str().unwrap(), runner_name.as_str()]) + .current_dir(runner_path.parent().unwrap()); + + if self.verbose { + println!(" Running: {:?}", zip_cmd); + } + + run_command(zip_cmd, "zip XCUITest runner")?; + println!("✓ XCUITest runner packaged: {:?}", zip_path); + + Ok(zip_path) + } + /// Recursively copies a directory fn copy_dir_recursive(&self, src: &Path, dest: &Path) -> Result<(), BenchError> { fs::create_dir_all(dest).map_err(|e| { diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 0de4071..688637a 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -90,7 +90,7 @@ edition = "2021" crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] -bench-sdk = {{ path = ".." }} +mobench-sdk = {{ path = ".." }} uniffi = "0.28" {} = {{ path = ".." }} @@ -116,7 +116,7 @@ use uniffi; // Ensure the user crate is linked so benchmark registrations are pulled in. extern crate {{USER_CRATE}} as _bench_user_crate; -// Re-export bench-sdk types with UniFFI annotations +// Re-export mobench-sdk types with UniFFI annotations #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct BenchSpec { pub name: String, @@ -148,7 +148,7 @@ pub enum BenchError { ExecutionFailed { reason: String }, } -// Convert from bench-sdk types +// Convert from mobench-sdk types impl From for BenchSpec { fn from(spec: mobench_sdk::BenchSpec) -> Self { Self { @@ -297,46 +297,36 @@ fn generate_ios_project( Ok(()) } -/// Generates bench-sdk.toml configuration file +/// Generates bench-config.toml configuration file fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> { + let config_target = match config.target { + Target::Ios => "ios", + Target::Android | Target::Both => "android", + }; let config_content = format!( - r#"# Bench SDK Configuration -# This file controls how benchmarks are built and executed + r#"# mobench configuration +# This file controls how benchmarks are executed on devices. -[project] -name = "{}" target = "{}" - -[build] -profile = "debug" # or "release" - -# BrowserStack configuration (optional) -# Uncomment and fill in your credentials to use BrowserStack -# [browserstack] -# username = "${{BROWSERSTACK_USERNAME}}" -# access_key = "${{BROWSERSTACK_ACCESS_KEY}}" -# project = "{}-benchmarks" - -# Device matrix (optional) -# Uncomment to specify devices for BrowserStack runs -# [[devices]] -# name = "Pixel 7" -# os = "android" -# os_version = "13.0" -# tags = ["default"] - -# [[devices]] -# name = "iPhone 14" -# os = "ios" -# os_version = "16" -# tags = ["default"] +function = "example_fibonacci" +iterations = 100 +warmup = 10 +device_matrix = "device-matrix.yaml" +device_tags = ["default"] + +[browserstack] +app_automate_username = "${{BROWSERSTACK_USERNAME}}" +app_automate_access_key = "${{BROWSERSTACK_ACCESS_KEY}}" +project = "{}-benchmarks" + +[ios_xcuitest] +app = "target/ios/BenchRunner.ipa" +test_suite = "target/ios/BenchRunnerUITests.zip" "#, - config.project_name, - config.target.as_str(), - config.project_name + config_target, config.project_name ); - fs::write(output_dir.join("bench-sdk.toml"), config_content)?; + fs::write(output_dir.join("bench-config.toml"), config_content)?; Ok(()) } @@ -348,7 +338,7 @@ fn generate_example_benchmarks(output_dir: &Path) -> Result<(), BenchError> { let example_content = r#"//! Example benchmarks //! -//! This file demonstrates how to write benchmarks with bench-sdk. +//! This file demonstrates how to write benchmarks with mobench-sdk. use mobench_sdk::benchmark; @@ -484,7 +474,7 @@ mod tests { #[test] fn test_generate_bench_mobile_crate() { - let temp_dir = env::temp_dir().join("bench-sdk-test"); + let temp_dir = env::temp_dir().join("mobench-sdk-test"); fs::create_dir_all(&temp_dir).unwrap(); let result = generate_bench_mobile_crate(&temp_dir, "test_project"); diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 5b0bd04..0803365 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -1,15 +1,15 @@ //! Mobile Benchmark SDK for Rust //! -//! `bench-sdk` is a library for benchmarking Rust functions on real mobile devices +//! `mobench-sdk` is a library for benchmarking Rust functions on real mobile devices //! (Android and iOS) via BrowserStack. It provides a simple API similar to criterion.rs //! but targets mobile platforms. //! //! # Quick Start //! -//! 1. Add bench-sdk to your project: +//! 1. Add mobench-sdk to your project: //! ```toml //! [dependencies] -//! bench-sdk = "0.1" +//! mobench-sdk = "0.1" //! ``` //! //! 2. Mark functions with `#[benchmark]`: @@ -26,13 +26,13 @@ //! //! 3. Initialize mobile project: //! ```bash -//! cargo bench-sdk init --target android +//! cargo mobench init --target android //! ``` //! //! 4. Build and run: //! ```bash -//! cargo bench-sdk build --target android -//! cargo bench-sdk run my_expensive_operation --target android +//! cargo mobench build --target android +//! cargo mobench run --target android --function my_expensive_operation //! ``` //! //! # Architecture diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs index 1325607..803b640 100644 --- a/crates/mobench-sdk/src/types.rs +++ b/crates/mobench-sdk/src/types.rs @@ -1,4 +1,4 @@ -//! Core types for bench-sdk +//! Core types for mobench-sdk //! //! This module re-exports types from mobench-runner and adds SDK-specific types. @@ -9,7 +9,7 @@ pub use mobench_runner::{ use std::path::PathBuf; -/// Error types for bench-sdk operations +/// Error types for mobench-sdk operations #[derive(Debug, thiserror::Error)] pub enum BenchError { /// Error from the benchmark runner diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 3f248db..87ab466 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -35,6 +35,7 @@ serde_yaml.workspace = true toml.workspace = true reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking", "json", "multipart"] } dotenvy = "0.15" +time.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 4eee002..8d07451 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -35,7 +35,7 @@ cargo mobench init --target both This creates: - `bench-mobile/` - FFI wrapper crate with UniFFI bindings - `android/` or `ios/` - Platform-specific app projects -- `bench-sdk.toml` - Configuration file +- `bench-config.toml` - Configuration file - `benches/example.rs` - Example benchmarks (with `--generate-examples`) ### 2. Write Benchmarks @@ -71,9 +71,9 @@ cargo mobench build --target ios ### 4. Run Benchmarks -Local emulator/simulator: +Local device workflow (builds artifacts and writes the run spec; launch the app manually): ```bash -cargo mobench run fibonacci_30 --local-only --iterations 50 +cargo mobench run --target android --function fibonacci_30 --iterations 50 ``` On real devices via BrowserStack: @@ -81,8 +81,10 @@ On real devices via BrowserStack: export BROWSERSTACK_USERNAME=your_username export BROWSERSTACK_ACCESS_KEY=your_access_key -cargo mobench run fibonacci_30 \ - --devices "Pixel 7-13" \ +cargo mobench run \ + --target android \ + --function fibonacci_30 \ + --devices "Google Pixel 7-13.0" \ --iterations 100 \ --warmup 10 ``` @@ -99,7 +101,7 @@ cargo mobench init [OPTIONS] **Options:** - `--target ` - Target platform (default: android) -- `--output ` - Config file path (default: bench-sdk.toml) +- `--output ` - Config file path (default: bench-config.toml) **Example:** ```bash @@ -116,12 +118,12 @@ cargo mobench build --target [OPTIONS] **Options:** - `--target ` - Platform to build for (required) -- `--profile ` - Build profile (default: debug) +- `--release` - Build in release mode (default: debug) **Examples:** ```bash # Build Android APK in release mode -cargo mobench build --target android --profile release +cargo mobench build --target android --release # Build iOS xcframework cargo mobench build --target ios @@ -136,7 +138,7 @@ cargo mobench build --target ios Execute benchmarks on devices: ```bash -cargo mobench run [OPTIONS] +cargo mobench run --target --function [OPTIONS] ``` **Options:** @@ -145,25 +147,36 @@ cargo mobench run [OPTIONS] - `--iterations ` - Number of iterations (default: 100) - `--warmup ` - Warmup iterations (default: 10) - `--devices ` - Comma-separated device list for BrowserStack -- `--local-only` - Skip BrowserStack, run locally only -- `--output ` - Save results to JSON file +- `--local-only` - Skip mobile builds (no device run) +- `--config ` - Load run spec from config file +- `--ios-app ` - iOS .ipa or zipped .app for BrowserStack +- `--ios-test-suite ` - iOS XCUITest runner (.zip or .ipa) +- `--output ` - Save results to JSON file (default: run-summary.json) +- `--summary-csv` - Write CSV summary alongside JSON/Markdown - `--fetch` - Fetch BrowserStack results after completion +**Outputs:** +- JSON summary (default: `run-summary.json`) +- Markdown summary (same base name, `.md`) +- CSV summary (same base name, `.csv`, when `--summary-csv` is set) + **Examples:** ```bash -# Run locally -cargo mobench run fibonacci_30 --target android --local-only +# Run locally (no BrowserStack devices specified) +cargo mobench run --target android --function fibonacci_30 # Run on BrowserStack devices -cargo mobench run sha256_hash \ +cargo mobench run \ --target android \ - --devices "Pixel 7-13,Galaxy S23-13" \ + --function sha256_hash \ + --devices "Google Pixel 7-13.0,Samsung Galaxy S23-13.0" \ --iterations 50 \ --output results.json # Run on iOS with auto-fetch -cargo mobench run json_parse \ +cargo mobench run \ --target ios \ + --function json_parse \ --devices "iPhone 14-16,iPhone 15-17" \ --fetch ``` @@ -204,16 +217,24 @@ cargo mobench plan --output devices.yaml ```yaml devices: - - name: Pixel 7 + - name: Google Pixel 7-13.0 os: android os_version: "13.0" tags: [default, pixel] - - name: iPhone 14 + - name: iPhone 14-16 os: ios os_version: "16" tags: [default, iphone] ``` +### `list` - List Benchmarks + +Show benchmarks discovered via `#[benchmark]`: + +```bash +cargo mobench list +``` + ### `fetch` - Fetch Results Download BrowserStack build artifacts: @@ -235,34 +256,51 @@ cargo mobench fetch \ --output-dir ./results ``` +### `compare` - Compare Summaries + +Compare two JSON run summaries and emit a Markdown report: + +```bash +cargo mobench compare \ + --baseline results-v1.json \ + --candidate results-v2.json \ + --output comparison.md +``` + ## Configuration -### Config File Format (`bench-sdk.toml`) +### Config File Format (`bench-config.toml`) ```toml -[project] -name = "my-benchmarks" -target = "both" # android, ios, or both - -[build] -profile = "release" # or "debug" +target = "android" +function = "sample_fns::fibonacci" +iterations = 100 +warmup = 10 +device_matrix = "device-matrix.yaml" +device_tags = ["default"] # optional; filter devices by tag [browserstack] -username = "${BROWSERSTACK_USERNAME}" -access_key = "${BROWSERSTACK_ACCESS_KEY}" +app_automate_username = "${BROWSERSTACK_USERNAME}" +app_automate_access_key = "${BROWSERSTACK_ACCESS_KEY}" project = "my-project-benchmarks" -[[devices]] -name = "Pixel 7" -os = "android" -os_version = "13.0" -tags = ["default"] +[ios_xcuitest] +app = "target/ios/BenchRunner.ipa" +test_suite = "target/ios/BenchRunnerUITests.zip" +``` + +### Device Matrix Format (`device-matrix.yaml`) -[[devices]] -name = "iPhone 14" -os = "ios" -os_version = "16" -tags = ["default"] +```yaml +devices: + - name: "Google Pixel 7-13.0" + os: "android" + os_version: "13.0" + tags: ["default", "pixel"] + - name: "iPhone 14-16" + os: "ios" + os_version: "16" + tags: ["default", "iphone"] ``` ### Environment Variables @@ -335,12 +373,13 @@ fn sha256_1kb() { EOF # Build -cargo mobench build --target android --profile release +cargo mobench build --target android --release # Run on multiple devices -cargo mobench run sha256_1kb \ +cargo mobench run \ --target android \ - --devices "Pixel 7-13,Galaxy S23-13,OnePlus 11-13" \ + --function sha256_1kb \ + --devices "Google Pixel 7-13.0,Samsung Galaxy S23-13.0,OnePlus 11-13.0" \ --iterations 200 \ --output crypto-results.json ``` @@ -349,8 +388,9 @@ cargo mobench run sha256_1kb \ ```bash # Run same benchmark on different iOS versions -cargo mobench run json_parse \ +cargo mobench run \ --target ios \ + --function json_parse \ --devices "iPhone 13-15,iPhone 14-16,iPhone 15-17" \ --iterations 100 \ --fetch \ @@ -380,16 +420,17 @@ jobs: ndk-version: r25c - name: Build - run: cargo mobench build --target android --profile release + run: cargo mobench build --target android --release - name: Run benchmarks env: BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} run: | - cargo mobench run my_benchmark \ + cargo mobench run \ --target android \ - --devices "Pixel 7-13" \ + --function my_benchmark \ + --devices "Google Pixel 7-13.0" \ --iterations 50 \ --output results.json \ --fetch diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index ad18abc..14931e0 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -3,6 +3,10 @@ use reqwest::blocking::multipart::Form; use reqwest::blocking::{Client, Response}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; +type BrowserStackResults = ( + std::collections::HashMap>, + std::collections::HashMap, +); use std::path::Path; const DEFAULT_BASE_URL: &str = "https://api-cloud.browserstack.com"; @@ -282,8 +286,8 @@ impl BrowserStackClient { _ => return Err(anyhow!("unsupported platform: {}", platform)), }; - match status.status.as_str() { - "done" => return Ok(status), + match status.status.to_lowercase().as_str() { + "done" | "passed" | "completed" => return Ok(status), "failed" | "error" | "timeout" => { return Err(anyhow!( "Build {} failed with status: {}", @@ -354,15 +358,14 @@ impl BrowserStackClient { // Look for JSON objects that contain benchmark-related fields for line in logs.lines() { let trimmed = line.trim(); - if (trimmed.starts_with('{') && trimmed.ends_with('}')) - || (trimmed.contains("\"function\"") && trimmed.contains("\"samples\"")) + let looks_like_json = trimmed.starts_with('{') && trimmed.ends_with('}'); + let looks_like_bench = + trimmed.contains("\"function\"") && trimmed.contains("\"samples\""); + if (looks_like_json || looks_like_bench) + && let Ok(json) = serde_json::from_str::(trimmed) + && (json.get("function").is_some() || json.get("samples").is_some()) { - if let Ok(json) = serde_json::from_str::(trimmed) { - // Check if this looks like a benchmark report - if json.get("function").is_some() || json.get("samples").is_some() { - results.push(json); - } - } + results.push(json); } } @@ -380,18 +383,15 @@ impl BrowserStackClient { for line in logs.lines() { let trimmed = line.trim(); - if trimmed.starts_with('{') && trimmed.ends_with('}') { - if let Ok(json) = serde_json::from_str::(trimmed) { - // Check if this looks like a performance metric - if json.get("type").and_then(|t| t.as_str()) == Some("performance") - || json.get("memory").is_some() - || json.get("cpu").is_some() - { - if let Ok(snapshot) = serde_json::from_value::(json) { - snapshots.push(snapshot); - } - } - } + let looks_like_json = trimmed.starts_with('{') && trimmed.ends_with('}'); + if looks_like_json + && let Ok(json) = serde_json::from_str::(trimmed) + && (json.get("type").and_then(|t| t.as_str()) == Some("performance") + || json.get("memory").is_some() + || json.get("cpu").is_some()) + && let Ok(snapshot) = serde_json::from_value::(json) + { + snapshots.push(snapshot); } } @@ -401,22 +401,32 @@ impl BrowserStackClient { /// Wait for build completion and fetch all results including performance metrics /// /// Returns both benchmark results and performance metrics + #[allow(dead_code)] pub fn wait_and_fetch_all_results( &self, build_id: &str, platform: &str, timeout_secs: Option, - ) -> Result<( - std::collections::HashMap>, - std::collections::HashMap, - )> { - let timeout = timeout_secs.unwrap_or(600); + ) -> Result { + self.wait_and_fetch_all_results_with_poll(build_id, platform, timeout_secs, None) + } + + pub fn wait_and_fetch_all_results_with_poll( + &self, + build_id: &str, + platform: &str, + timeout_secs: Option, + poll_interval_secs: Option, + ) -> Result { + let timeout = timeout_secs.unwrap_or(300); + let poll_interval = poll_interval_secs.unwrap_or(5); println!( - "Waiting for build {} to complete (timeout: {}s)...", - build_id, timeout + "Waiting for build {} to complete (timeout: {}s, poll: {}s)...", + build_id, timeout, poll_interval ); - let build_status = self.poll_build_completion(build_id, platform, timeout, 10)?; + let build_status = + self.poll_build_completion(build_id, platform, timeout, poll_interval)?; println!("Build completed with status: {}", build_status.status); println!( @@ -902,7 +912,8 @@ mod tests { ) .unwrap(); - let result = client.schedule_espresso_run(&["Pixel 7-13".to_string()], "", "bs://test456"); + let result = + client.schedule_espresso_run(&["Google Pixel 7-13.0".to_string()], "", "bs://test456"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("app_url")); @@ -919,7 +930,8 @@ mod tests { ) .unwrap(); - let result = client.schedule_espresso_run(&["Pixel 7-13".to_string()], "bs://app123", ""); + let result = + client.schedule_espresso_run(&["Google Pixel 7-13.0".to_string()], "bs://app123", ""); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("test_suite_url")); @@ -1088,7 +1100,7 @@ Test completed status: "done".to_string(), duration: Some(120), devices: Some(vec![DeviceSessionResponse { - device: "Pixel 7-13".to_string(), + device: "Google Pixel 7-13.0".to_string(), session_id: "session123".to_string(), status: "passed".to_string(), device_logs: Some("https://example.com/logs".to_string()), @@ -1100,7 +1112,7 @@ Test completed assert_eq!(status.status, "done"); assert_eq!(status.duration, Some(120)); assert_eq!(status.devices.len(), 1); - assert_eq!(status.devices[0].device, "Pixel 7-13"); + assert_eq!(status.devices[0].device, "Google Pixel 7-13.0"); assert_eq!(status.devices[0].session_id, "session123"); } diff --git a/crates/mobench/src/main.rs b/crates/mobench/src/main.rs index 1559ea2..f1d536e 100644 --- a/crates/mobench/src/main.rs +++ b/crates/mobench/src/main.rs @@ -2,10 +2,14 @@ use anyhow::{Context, Result, anyhow, bail}; use clap::{Parser, Subcommand, ValueEnum}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use std::collections::BTreeMap; use std::env; +use std::fmt::Write; use std::fs; use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; use browserstack::{BrowserStackAuth, BrowserStackClient}; @@ -37,6 +41,8 @@ enum Command { config: Option, #[arg(long, help = "Optional output path for JSON report")] output: Option, + #[arg(long, help = "Write CSV summary alongside JSON")] + summary_csv: bool, #[arg(long, help = "Skip mobile builds and only run the host harness")] local_only: bool, #[arg( @@ -50,9 +56,9 @@ enum Command { fetch: bool, #[arg(long, default_value = "target/browserstack")] fetch_output_dir: PathBuf, - #[arg(long, default_value_t = 10)] + #[arg(long, default_value_t = 5)] fetch_poll_interval_secs: u64, - #[arg(long, default_value_t = 1800)] + #[arg(long, default_value_t = 300)] fetch_timeout_secs: u64, }, /// Scaffold a base config file for the CLI. @@ -82,6 +88,15 @@ enum Command { #[arg(long, default_value_t = 1800)] timeout_secs: u64, }, + /// Compare two run summaries for regressions. + Compare { + #[arg(long, help = "Baseline JSON summary to compare against")] + baseline: PathBuf, + #[arg(long, help = "Candidate JSON summary to compare")] + candidate: PathBuf, + #[arg(long, help = "Optional output path for markdown report")] + output: Option, + }, /// Initialize a new benchmark project with SDK (Phase 1 MVP). InitSdk { #[arg(long, value_enum)] @@ -174,6 +189,8 @@ struct BenchConfig { iterations: u32, warmup: u32, device_matrix: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + device_tags: Option>, browserstack: BrowserStackConfig, #[serde(skip_serializing_if = "Option::is_none", default)] ios_xcuitest: Option, @@ -227,11 +244,40 @@ struct RunSummary { artifacts: Option, local_report: Value, remote_run: Option, + summary: SummaryReport, #[serde(skip_serializing_if = "Option::is_none")] - benchmark_results: Option>>, + benchmark_results: Option>>, #[serde(skip_serializing_if = "Option::is_none")] - performance_metrics: - Option>, + performance_metrics: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct SummaryReport { + generated_at: String, + generated_at_unix: u64, + target: MobileTarget, + function: String, + iterations: u32, + warmup: u32, + devices: Vec, + device_summaries: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct DeviceSummary { + device: String, + benchmarks: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct BenchmarkStats { + function: String, + samples: usize, + mean_ns: Option, + median_ns: Option, + p95_ns: Option, + min_ns: Option, + max_ns: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -260,6 +306,7 @@ fn main() -> Result<()> { devices, config, output, + summary_csv, local_only, ios_app, ios_test_suite, @@ -277,7 +324,9 @@ fn main() -> Result<()> { config.as_deref(), ios_app, ios_test_suite, + local_only, )?; + let summary_paths = resolve_summary_paths(output.as_deref())?; println!( "Preparing benchmark run for {:?}: {} (iterations={}, warmup={})", spec.target, spec.function, spec.iterations, spec.warmup @@ -286,8 +335,13 @@ fn main() -> Result<()> { if !spec.devices.is_empty() { println!("Devices: {}", spec.devices.join(", ")); } - if let Some(path) = &output { - println!("JSON summary will be written to {:?}", path); + println!("JSON summary will be written to {:?}", summary_paths.json); + println!( + "Markdown summary will be written to {:?}", + summary_paths.markdown + ); + if summary_csv { + println!("CSV summary will be written to {:?}", summary_paths.csv); } // Skip local smoke test - sample-fns uses direct dispatch, not inventory registry @@ -346,130 +400,127 @@ fn main() -> Result<()> { } }; - let mut summary = RunSummary { + let summary_placeholder = empty_summary(&spec); + let mut run_summary = RunSummary { spec, artifacts, local_report, remote_run, + summary: summary_placeholder, benchmark_results: None, performance_metrics: None, }; - if fetch { - if let Some(remote) = &summary.remote_run { - let build_id = match remote { - RemoteRun::Android { build_id, .. } => build_id, - RemoteRun::Ios { build_id, .. } => build_id, - }; - let creds = - resolve_browserstack_credentials(summary.spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username, - access_key: creds.access_key, - }, - creds.project, - )?; - - let platform = match summary.spec.target { - MobileTarget::Android => "espresso", - MobileTarget::Ios => "xcuitest", - }; - - let dashboard_url = format!( - "https://app-automate.browserstack.com/dashboard/v2/builds/{}", - build_id - ); - - println!("Waiting for build {} to complete...", build_id); - println!("Dashboard: {}", dashboard_url); - - match client.wait_and_fetch_all_results( - build_id, - platform, - Some(fetch_timeout_secs), - ) { - Ok((bench_results, perf_metrics)) => { - println!( - "\n✓ Successfully fetched results from {} device(s)", - bench_results.len() - ); - - // Print summary of benchmark results - for (device, results) in &bench_results { - println!("\n Device: {}", device); - for (idx, result) in results.iter().enumerate() { - if let Some(function) = - result.get("function").and_then(|f| f.as_str()) - { - println!(" Benchmark {}: {}", idx + 1, function); - } - if let Some(mean) = - result.get("mean_ns").and_then(|m| m.as_u64()) - { - println!( - " Mean: {} ns ({:.2} ms)", - mean, - mean as f64 / 1_000_000.0 - ); - } - if let Some(samples) = - result.get("samples").and_then(|s| s.as_array()) - { - println!(" Samples: {}", samples.len()); - } - } + if fetch && let Some(remote) = &run_summary.remote_run { + let build_id = match remote { + RemoteRun::Android { build_id, .. } => build_id, + RemoteRun::Ios { build_id, .. } => build_id, + }; + let creds = + resolve_browserstack_credentials(run_summary.spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + )?; + + let platform = match run_summary.spec.target { + MobileTarget::Android => "espresso", + MobileTarget::Ios => "xcuitest", + }; - // Print performance metrics if available - if let Some(metrics) = perf_metrics.get(device) { - if metrics.sample_count > 0 { - println!("\n Performance Metrics:"); - if let Some(mem) = &metrics.memory { - println!(" Memory:"); - println!(" Peak: {:.2} MB", mem.peak_mb); - println!(" Average: {:.2} MB", mem.average_mb); - } - if let Some(cpu) = &metrics.cpu { - println!(" CPU:"); - println!(" Peak: {:.1}%", cpu.peak_percent); - println!( - " Average: {:.1}%", - cpu.average_percent - ); - } - } + let dashboard_url = format!( + "https://app-automate.browserstack.com/dashboard/v2/builds/{}", + build_id + ); + + println!("Waiting for build {} to complete...", build_id); + println!("Dashboard: {}", dashboard_url); + + match client.wait_and_fetch_all_results_with_poll( + build_id, + platform, + Some(fetch_timeout_secs), + Some(fetch_poll_interval_secs), + ) { + Ok((bench_results, perf_metrics)) => { + println!( + "\n✓ Successfully fetched results from {} device(s)", + bench_results.len() + ); + + // Print summary of benchmark results + for (device, results) in &bench_results { + println!("\n Device: {}", device); + for (idx, result) in results.iter().enumerate() { + if let Some(function) = + result.get("function").and_then(|f| f.as_str()) + { + println!(" Benchmark {}: {}", idx + 1, function); + } + if let Some(mean) = result.get("mean_ns").and_then(|m| m.as_u64()) { + println!( + " Mean: {} ns ({:.2} ms)", + mean, + mean as f64 / 1_000_000.0 + ); + } + if let Some(samples) = + result.get("samples").and_then(|s| s.as_array()) + { + println!(" Samples: {}", samples.len()); } } - println!("\n View full results: {}", dashboard_url); - summary.benchmark_results = Some(bench_results); - summary.performance_metrics = Some(perf_metrics); - } - Err(e) => { - println!("\nWarning: Failed to fetch results: {}", e); - println!("Build may still be accessible at: {}", dashboard_url); + // Print performance metrics if available + if let Some(metrics) = + perf_metrics.get(device).filter(|m| m.sample_count > 0) + { + println!("\n Performance Metrics:"); + if let Some(mem) = &metrics.memory { + println!(" Memory:"); + println!(" Peak: {:.2} MB", mem.peak_mb); + println!(" Average: {:.2} MB", mem.average_mb); + } + if let Some(cpu) = &metrics.cpu { + println!(" CPU:"); + println!(" Peak: {:.1}%", cpu.peak_percent); + println!(" Average: {:.1}%", cpu.average_percent); + } + } } - } - // Also save detailed artifacts to separate directory - let output_root = fetch_output_dir.join(build_id); - if let Err(e) = fetch_browserstack_artifacts( - &client, - summary.spec.target, - build_id, - &output_root, - false, // Don't wait again, we already did - fetch_poll_interval_secs, - fetch_timeout_secs, - ) { - println!("Warning: Failed to fetch detailed artifacts: {}", e); + println!("\n View full results: {}", dashboard_url); + run_summary.benchmark_results = Some(bench_results.into_iter().collect()); + run_summary.performance_metrics = Some(perf_metrics.into_iter().collect()); + } + Err(e) => { + println!("\nWarning: Failed to fetch results: {}", e); + println!("Build may still be accessible at: {}", dashboard_url); } - } else { - println!("No BrowserStack run to fetch (devices not provided?)"); } + + // Also save detailed artifacts to separate directory + let output_root = fetch_output_dir.join(build_id); + if let Err(e) = fetch_browserstack_artifacts( + &client, + run_summary.spec.target, + build_id, + &output_root, + false, // Don't wait again, we already did + fetch_poll_interval_secs, + fetch_timeout_secs, + ) { + println!("Warning: Failed to fetch detailed artifacts: {}", e); + } + } else if fetch { + println!("No BrowserStack run to fetch (devices not provided?)"); } - write_summary(&summary, output.as_deref())?; + run_summary.summary = build_summary(&run_summary)?; + write_summary(&run_summary, &summary_paths, summary_csv)?; } Command::Init { output, target } => { write_config_template(&output, target)?; @@ -506,6 +557,14 @@ fn main() -> Result<()> { timeout_secs, )?; } + Command::Compare { + baseline, + candidate, + output, + } => { + let report = compare_summaries(&baseline, &candidate)?; + write_compare_report(&report, output.as_deref())?; + } Command::InitSdk { target, project_name, @@ -546,6 +605,7 @@ fn write_config_template(path: &Path, target: MobileTarget) -> Result<()> { iterations: 100, warmup: 10, device_matrix: PathBuf::from("device-matrix.yaml"), + device_tags: Some(vec!["default".into()]), browserstack: BrowserStackConfig { app_automate_username: "${BROWSERSTACK_USERNAME}".into(), app_automate_access_key: "${BROWSERSTACK_ACCESS_KEY}".into(), @@ -811,6 +871,7 @@ fn shorten_html_error(message: &str) -> String { message.to_string() } +#[allow(clippy::too_many_arguments)] fn resolve_run_spec( target: MobileTarget, function: String, @@ -820,11 +881,15 @@ fn resolve_run_spec( config: Option<&Path>, ios_app: Option, ios_test_suite: Option, + local_only: bool, ) -> Result { if let Some(cfg_path) = config { let cfg = load_config(cfg_path)?; let matrix = load_device_matrix(&cfg.device_matrix)?; - let device_names = matrix.devices.into_iter().map(|d| d.name).collect(); + let device_names = match &cfg.device_tags { + Some(tags) if !tags.is_empty() => filter_devices_by_tags(matrix.devices, tags)?, + _ => matrix.devices.into_iter().map(|d| d.name).collect(), + }; return Ok(RunSpec { target: cfg.target, function: cfg.function, @@ -846,11 +911,15 @@ fn resolve_run_spec( _ => bail!("both --ios-app and --ios-test-suite must be provided together"), }; - if target == MobileTarget::Ios && !devices.is_empty() && ios_xcuitest.is_none() { - bail!( - "iOS BrowserStack runs require --ios-app and --ios-test-suite or an ios_xcuitest config block" - ); - } + let ios_xcuitest = if target == MobileTarget::Ios + && !local_only + && !devices.is_empty() + && ios_xcuitest.is_none() + { + Some(package_ios_xcuitest_artifacts()?) + } else { + ios_xcuitest + }; Ok(RunSpec { target, @@ -875,6 +944,39 @@ fn load_device_matrix(path: &Path) -> Result { serde_yaml::from_str(&contents).with_context(|| format!("parsing device matrix {:?}", path)) } +fn filter_devices_by_tags(devices: Vec, tags: &[String]) -> Result> { + let wanted: Vec = tags + .iter() + .map(|tag| tag.trim().to_lowercase()) + .filter(|tag| !tag.is_empty()) + .collect(); + if wanted.is_empty() { + return Ok(devices.into_iter().map(|d| d.name).collect()); + } + + let mut matched = Vec::new(); + for device in devices { + let Some(device_tags) = device.tags.as_ref() else { + continue; + }; + let has_match = device_tags.iter().any(|tag| { + let candidate = tag.trim().to_lowercase(); + wanted.iter().any(|wanted_tag| wanted_tag == &candidate) + }); + if has_match { + matched.push(device.name); + } + } + + if matched.is_empty() { + bail!( + "no devices matched tags [{}] in device matrix", + wanted.join(", ") + ); + } + Ok(matched) +} + fn run_ios_build() -> Result<(PathBuf, PathBuf)> { let root = repo_root()?; let crate_name = @@ -897,6 +999,28 @@ fn run_ios_build() -> Result<(PathBuf, PathBuf)> { Ok((result.app_path, header)) } +fn package_ios_xcuitest_artifacts() -> Result { + let root = repo_root()?; + let crate_name = + detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); + let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); + let cfg = mobench_sdk::BuildConfig { + target: mobench_sdk::Target::Ios, + profile: mobench_sdk::BuildProfile::Debug, + incremental: true, + }; + builder + .build(&cfg) + .context("Failed to build iOS xcframework before packaging")?; + let app = builder + .package_ipa("BenchRunner", mobench_sdk::builders::SigningMethod::AdHoc) + .context("Failed to package iOS IPA for BrowserStack")?; + let test_suite = builder + .package_xcuitest("BenchRunner") + .context("Failed to package iOS XCUITest runner for BrowserStack")?; + Ok(IosXcuitestArtifacts { app, test_suite }) +} + #[derive(Debug, Clone)] struct ResolvedBrowserStack { username: String, @@ -1081,17 +1205,463 @@ fn persist_mobile_spec(spec: &RunSpec) -> Result<()> { Ok(()) } -fn write_summary(summary: &RunSummary, output: Option<&Path>) -> Result<()> { +#[derive(Debug)] +struct SummaryPaths { + json: PathBuf, + markdown: PathBuf, + csv: PathBuf, +} + +fn resolve_summary_paths(output: Option<&Path>) -> Result { + let json = output + .map(ToOwned::to_owned) + .unwrap_or_else(|| PathBuf::from("run-summary.json")); + let markdown = json.with_extension("md"); + let csv = json.with_extension("csv"); + Ok(SummaryPaths { + json, + markdown, + csv, + }) +} + +fn empty_summary(spec: &RunSpec) -> SummaryReport { + SummaryReport { + generated_at: "pending".to_string(), + generated_at_unix: 0, + target: spec.target, + function: spec.function.clone(), + iterations: spec.iterations, + warmup: spec.warmup, + devices: spec.devices.clone(), + device_summaries: Vec::new(), + } +} + +fn build_summary(run_summary: &RunSummary) -> Result { + let generated_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("generating timestamp")? + .as_secs(); + let generated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| generated_at_unix.to_string()); + + let mut device_summaries = Vec::new(); + + if let Some(results) = &run_summary.benchmark_results { + for (device, entries) in results { + let mut benchmarks = Vec::new(); + for entry in entries { + let function = entry + .get("function") + .and_then(|f| f.as_str()) + .unwrap_or("unknown") + .to_string(); + let samples = extract_samples(entry); + let stats = compute_sample_stats(&samples); + let mean_ns = stats + .as_ref() + .map(|s| s.mean_ns) + .or_else(|| entry.get("mean_ns").and_then(|m| m.as_u64())); + + benchmarks.push(BenchmarkStats { + function, + samples: samples.len(), + mean_ns, + median_ns: stats.as_ref().map(|s| s.median_ns), + p95_ns: stats.as_ref().map(|s| s.p95_ns), + min_ns: stats.as_ref().map(|s| s.min_ns), + max_ns: stats.as_ref().map(|s| s.max_ns), + }); + } + + benchmarks.sort_by(|a, b| a.function.cmp(&b.function)); + device_summaries.push(DeviceSummary { + device: device.clone(), + benchmarks, + }); + } + } + + if device_summaries.is_empty() + && let Some(local_summary) = summarize_local_report(run_summary) + { + device_summaries.push(local_summary); + } + + Ok(SummaryReport { + generated_at, + generated_at_unix, + target: run_summary.spec.target, + function: run_summary.spec.function.clone(), + iterations: run_summary.spec.iterations, + warmup: run_summary.spec.warmup, + devices: run_summary.spec.devices.clone(), + device_summaries, + }) +} + +fn write_summary(summary: &RunSummary, paths: &SummaryPaths, summary_csv: bool) -> Result<()> { let json = serde_json::to_string_pretty(summary)?; + ensure_parent_dir(&paths.json)?; + write_file(&paths.json, json.as_bytes())?; + println!("Wrote run summary to {:?}", paths.json); + + let markdown = render_markdown_summary(&summary.summary); + ensure_parent_dir(&paths.markdown)?; + write_file(&paths.markdown, markdown.as_bytes())?; + println!("Wrote markdown summary to {:?}", paths.markdown); + + if summary_csv { + let csv = render_csv_summary(&summary.summary); + ensure_parent_dir(&paths.csv)?; + write_file(&paths.csv, csv.as_bytes())?; + println!("Wrote CSV summary to {:?}", paths.csv); + } + Ok(()) +} + +fn ensure_parent_dir(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent).with_context(|| format!("creating directory {:?}", parent))?; + } + Ok(()) +} + +#[derive(Debug)] +struct CompareReport { + baseline: PathBuf, + candidate: PathBuf, + rows: Vec, +} + +#[derive(Debug)] +struct CompareRow { + device: String, + function: String, + baseline_median_ns: Option, + candidate_median_ns: Option, + median_delta_pct: Option, + baseline_p95_ns: Option, + candidate_p95_ns: Option, + p95_delta_pct: Option, +} + +fn compare_summaries(baseline: &Path, candidate: &Path) -> Result { + let baseline_summary = load_run_summary(baseline)?; + let candidate_summary = load_run_summary(candidate)?; + + let baseline_map = summary_lookup(&baseline_summary.summary); + let candidate_map = summary_lookup(&candidate_summary.summary); + + let mut rows = Vec::new(); + let mut devices: BTreeMap = BTreeMap::new(); + devices.extend(baseline_map.keys().map(|k| (k.clone(), ()))); + devices.extend(candidate_map.keys().map(|k| (k.clone(), ()))); + + for device in devices.keys() { + let mut functions: BTreeMap = BTreeMap::new(); + if let Some(entry) = baseline_map.get(device) { + functions.extend(entry.keys().map(|k| (k.clone(), ()))); + } + if let Some(entry) = candidate_map.get(device) { + functions.extend(entry.keys().map(|k| (k.clone(), ()))); + } + + for function in functions.keys() { + let baseline_stats = baseline_map + .get(device) + .and_then(|entry| entry.get(function)); + let candidate_stats = candidate_map + .get(device) + .and_then(|entry| entry.get(function)); + + let baseline_median = baseline_stats.and_then(|s| s.median_ns); + let candidate_median = candidate_stats.and_then(|s| s.median_ns); + let median_delta = percent_delta(baseline_median, candidate_median); + + let baseline_p95 = baseline_stats.and_then(|s| s.p95_ns); + let candidate_p95 = candidate_stats.and_then(|s| s.p95_ns); + let p95_delta = percent_delta(baseline_p95, candidate_p95); + + rows.push(CompareRow { + device: device.clone(), + function: function.clone(), + baseline_median_ns: baseline_median, + candidate_median_ns: candidate_median, + median_delta_pct: median_delta, + baseline_p95_ns: baseline_p95, + candidate_p95_ns: candidate_p95, + p95_delta_pct: p95_delta, + }); + } + } + + Ok(CompareReport { + baseline: baseline.to_path_buf(), + candidate: candidate.to_path_buf(), + rows, + }) +} + +fn load_run_summary(path: &Path) -> Result { + let contents = fs::read_to_string(path).with_context(|| format!("reading {:?}", path))?; + serde_json::from_str(&contents).with_context(|| format!("parsing summary {:?}", path)) +} + +fn summary_lookup(summary: &SummaryReport) -> BTreeMap> { + let mut map = BTreeMap::new(); + for device in &summary.device_summaries { + let mut functions = BTreeMap::new(); + for bench in &device.benchmarks { + functions.insert(bench.function.clone(), bench.clone()); + } + map.insert(device.device.clone(), functions); + } + map +} + +fn percent_delta(baseline: Option, candidate: Option) -> Option { + let baseline = baseline? as f64; + let candidate = candidate? as f64; + if baseline == 0.0 { + return None; + } + Some(((candidate - baseline) / baseline) * 100.0) +} + +fn write_compare_report(report: &CompareReport, output: Option<&Path>) -> Result<()> { + let markdown = render_compare_markdown(report); if let Some(path) = output { - write_file(path, json.as_bytes())?; - println!("Wrote run summary to {:?}", path); + ensure_parent_dir(path)?; + write_file(path, markdown.as_bytes())?; + println!("Wrote compare report to {:?}", path); } else { - println!("{json}"); + println!("{markdown}"); } Ok(()) } +fn render_compare_markdown(report: &CompareReport) -> String { + let mut output = String::new(); + let _ = writeln!(output, "# Benchmark Comparison"); + let _ = writeln!(output); + let _ = writeln!(output, "- Baseline: {}", report.baseline.display()); + let _ = writeln!(output, "- Candidate: {}", report.candidate.display()); + let _ = writeln!(output); + let _ = writeln!( + output, + "| Device | Function | Median (base ms) | Median (cand ms) | Median Δ% | P95 (base ms) | P95 (cand ms) | P95 Δ% |" + ); + let _ = writeln!( + output, + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |" + ); + for row in &report.rows { + let _ = writeln!( + output, + "| {} | {} | {} | {} | {} | {} | {} | {} |", + row.device, + row.function, + format_ms(row.baseline_median_ns), + format_ms(row.candidate_median_ns), + format_delta(row.median_delta_pct), + format_ms(row.baseline_p95_ns), + format_ms(row.candidate_p95_ns), + format_delta(row.p95_delta_pct) + ); + } + output +} + +fn format_delta(value: Option) -> String { + value + .map(|delta| format!("{:+.2}%", delta)) + .unwrap_or_else(|| "-".to_string()) +} + +fn summarize_local_report(run_summary: &RunSummary) -> Option { + let samples = extract_samples(&run_summary.local_report); + if samples.is_empty() { + return None; + } + let stats = compute_sample_stats(&samples)?; + let function = run_summary + .local_report + .get("spec") + .and_then(|spec| spec.get("name")) + .and_then(|name| name.as_str()) + .unwrap_or(&run_summary.spec.function) + .to_string(); + + Some(DeviceSummary { + device: "local".to_string(), + benchmarks: vec![BenchmarkStats { + function, + samples: samples.len(), + mean_ns: Some(stats.mean_ns), + median_ns: Some(stats.median_ns), + p95_ns: Some(stats.p95_ns), + min_ns: Some(stats.min_ns), + max_ns: Some(stats.max_ns), + }], + }) +} + +#[derive(Clone, Debug)] +struct SampleStats { + mean_ns: u64, + median_ns: u64, + p95_ns: u64, + min_ns: u64, + max_ns: u64, +} + +fn compute_sample_stats(samples: &[u64]) -> Option { + if samples.is_empty() { + return None; + } + + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + let len = sorted.len(); + + let mean_ns = (sorted.iter().map(|v| *v as u128).sum::() / len as u128) as u64; + let median_ns = if len % 2 == 1 { + sorted[len / 2] + } else { + let lower = sorted[(len / 2) - 1]; + let upper = sorted[len / 2]; + (lower + upper) / 2 + }; + let p95_index = percentile_index(len, 0.95); + let p95_ns = sorted[p95_index]; + let min_ns = sorted[0]; + let max_ns = sorted[len - 1]; + + Some(SampleStats { + mean_ns, + median_ns, + p95_ns, + min_ns, + max_ns, + }) +} + +fn percentile_index(len: usize, percentile: f64) -> usize { + if len == 0 { + return 0; + } + let rank = (percentile * len as f64).ceil() as usize; + let index = rank.saturating_sub(1); + index.min(len - 1) +} + +fn extract_samples(value: &Value) -> Vec { + let Some(samples) = value.get("samples").and_then(|s| s.as_array()) else { + return Vec::new(); + }; + let mut durations = Vec::with_capacity(samples.len()); + for sample in samples { + if let Some(duration) = sample + .get("duration_ns") + .and_then(|duration| duration.as_u64()) + { + durations.push(duration); + } else if let Some(duration) = sample.as_u64() { + durations.push(duration); + } + } + durations +} + +fn render_markdown_summary(summary: &SummaryReport) -> String { + let mut output = String::new(); + let devices = if summary.devices.is_empty() { + "none".to_string() + } else { + summary.devices.join(", ") + }; + + let _ = writeln!(output, "# Benchmark Summary"); + let _ = writeln!(output); + let _ = writeln!(output, "- Generated: {}", summary.generated_at); + let _ = writeln!(output, "- Target: {:?}", summary.target); + let _ = writeln!(output, "- Function: {}", summary.function); + let _ = writeln!( + output, + "- Iterations/Warmup: {} / {}", + summary.iterations, summary.warmup + ); + let _ = writeln!(output, "- Devices: {}", devices); + let _ = writeln!(output); + + if summary.device_summaries.is_empty() { + let _ = writeln!(output, "No benchmark samples were collected."); + return output; + } + + for device in &summary.device_summaries { + let _ = writeln!(output, "## Device: {}", device.device); + let _ = writeln!(output); + let _ = writeln!( + output, + "| Function | Samples | Mean (ms) | Median (ms) | P95 (ms) | Min (ms) | Max (ms) |" + ); + let _ = writeln!(output, "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"); + for bench in &device.benchmarks { + let _ = writeln!( + output, + "| {} | {} | {} | {} | {} | {} | {} |", + bench.function, + bench.samples, + format_ms(bench.mean_ns), + format_ms(bench.median_ns), + format_ms(bench.p95_ns), + format_ms(bench.min_ns), + format_ms(bench.max_ns) + ); + } + let _ = writeln!(output); + } + + output +} + +fn render_csv_summary(summary: &SummaryReport) -> String { + let mut output = String::new(); + let _ = writeln!( + output, + "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns" + ); + for device in &summary.device_summaries { + for bench in &device.benchmarks { + let _ = writeln!( + output, + "{},{},{},{},{},{},{},{}", + device.device, + bench.function, + bench.samples, + bench.mean_ns.map_or(String::from(""), |v| v.to_string()), + bench.median_ns.map_or(String::from(""), |v| v.to_string()), + bench.p95_ns.map_or(String::from(""), |v| v.to_string()), + bench.min_ns.map_or(String::from(""), |v| v.to_string()), + bench.max_ns.map_or(String::from(""), |v| v.to_string()) + ); + } + } + output +} + +fn format_ms(value: Option) -> String { + value + .map(|ns| format!("{:.3}", ns as f64 / 1_000_000.0)) + .unwrap_or_else(|| "-".to_string()) +} + fn run_android_build(_ndk_home: &str) -> Result { let root = repo_root()?; let crate_name = @@ -1338,6 +1908,7 @@ mod tests { None, None, None, + false, ) .unwrap(); assert_eq!(spec.function, "sample_fns::fibonacci"); @@ -1366,7 +1937,7 @@ mod tests { #[test] fn ios_requires_artifacts_for_browserstack() { - let err = resolve_run_spec( + let spec = resolve_run_spec( MobileTarget::Ios, "sample_fns::fibonacci".into(), 1, @@ -1375,11 +1946,16 @@ mod tests { None, None, None, + false, ) - .unwrap_err(); + .expect("should auto-package iOS artifacts when missing"); + let ios_artifacts = spec + .ios_xcuitest + .expect("iOS artifacts should be populated"); + assert!(ios_artifacts.app.exists(), "iOS app artifact missing"); assert!( - err.to_string() - .contains("iOS BrowserStack runs require --ios-app and --ios-test-suite") + ios_artifacts.test_suite.exists(), + "iOS test suite artifact missing" ); } } diff --git a/crates/sample-fns/src/bin/generate-bindings.rs b/crates/sample-fns/src/bin/generate-bindings.rs index cb054b2..627a2a5 100644 --- a/crates/sample-fns/src/bin/generate-bindings.rs +++ b/crates/sample-fns/src/bin/generate-bindings.rs @@ -1,92 +1,89 @@ -use camino::Utf8PathBuf; -use std::env; -use std::fs; -use uniffi_bindgen::bindings::{KotlinBindingGenerator, SwiftBindingGenerator}; -use uniffi_bindgen::library_mode::generate_bindings; +#[cfg(feature = "bindgen")] +mod bindgen { + use camino::Utf8PathBuf; + use std::env; + use std::fs; + use uniffi_bindgen::bindings::{KotlinBindingGenerator, SwiftBindingGenerator}; + use uniffi_bindgen::library_mode::generate_bindings; -fn main() { - let manifest_dir = Utf8PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - let root_dir = manifest_dir.parent().unwrap().parent().unwrap(); + pub fn run() { + let manifest_dir = Utf8PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let root_dir = manifest_dir.parent().unwrap().parent().unwrap(); - let lib_file = if let Ok(path) = env::var("UNIFFI_LIBRARY_PATH") { - println!("Using UniFFI library from UNIFFI_LIBRARY_PATH"); - Utf8PathBuf::from(path) - } else { - let profile = env::var("UNIFFI_PROFILE").unwrap_or_else(|_| "release".to_string()); - println!( - "Building library to generate UniFFI metadata (profile={})...", - profile - ); - let target_dir = root_dir.join("target").join(&profile); - let lib_name = if cfg!(target_os = "macos") { - "libsample_fns.dylib" - } else if cfg!(target_os = "linux") { - "libsample_fns.so" + let lib_file = if let Ok(path) = env::var("UNIFFI_LIBRARY_PATH") { + println!("Using UniFFI library from UNIFFI_LIBRARY_PATH"); + Utf8PathBuf::from(path) } else { - "sample_fns.dll" + let profile = env::var("UNIFFI_PROFILE").unwrap_or_else(|_| "release".to_string()); + println!( + "Building library to generate UniFFI metadata (profile={})...", + profile + ); + let target_dir = root_dir.join("target").join(&profile); + let lib_name = if cfg!(target_os = "macos") { + "libsample_fns.dylib" + } else if cfg!(target_os = "linux") { + "libsample_fns.so" + } else { + "sample_fns.dll" + }; + target_dir.join(lib_name) }; - target_dir.join(lib_name) - }; - - println!("Using library: {:?}", lib_file); - if !lib_file.exists() { - eprintln!( - "UniFFI library not found at {:?}. Build it first or set UNIFFI_LIBRARY_PATH.", - lib_file - ); - std::process::exit(1); - } - // Generate Kotlin bindings - let kotlin_out = root_dir.join("android/app/src/main/java"); - fs::create_dir_all(&kotlin_out).unwrap(); - println!("Generating Kotlin bindings to {:?}", kotlin_out); + println!("Using library: {:?}", lib_file); + if !lib_file.exists() { + eprintln!( + "UniFFI library not found at {:?}. Build it first or set UNIFFI_LIBRARY_PATH.", + lib_file + ); + std::process::exit(1); + } - generate_bindings( - &lib_file, - None, // crate name (auto-detect) - &KotlinBindingGenerator, - &uniffi_bindgen::cargo_metadata::CrateConfigSupplier::default(), - None, // config override path - &kotlin_out, - false, // try_format_code - ) - .unwrap(); + let kotlin_out = root_dir.join("android/app/src/main/java"); + fs::create_dir_all(&kotlin_out).unwrap(); + println!("Generating Kotlin bindings to {:?}", kotlin_out); - println!("✓ Kotlin bindings generated"); + generate_bindings( + &lib_file, + None, + &KotlinBindingGenerator, + &uniffi_bindgen::cargo_metadata::CrateConfigSupplier::default(), + None, + &kotlin_out, + false, + ) + .unwrap(); - // Generate Swift bindings - let swift_out = root_dir.join("ios/BenchRunner/BenchRunner/Generated"); - fs::create_dir_all(&swift_out).unwrap(); - println!("Generating Swift bindings to {:?}", swift_out); + println!("✓ Kotlin bindings generated"); - generate_bindings( - &lib_file, - None, // crate name (auto-detect) - &SwiftBindingGenerator, - &uniffi_bindgen::cargo_metadata::CrateConfigSupplier::default(), - None, // config override path - &swift_out, - false, // try_format_code - ) - .unwrap(); + let swift_out = root_dir.join("ios/BenchRunner/BenchRunner/Generated"); + fs::create_dir_all(&swift_out).unwrap(); + println!("Generating Swift bindings to {:?}", swift_out); - println!("✓ Swift bindings generated"); + generate_bindings( + &lib_file, + None, + &SwiftBindingGenerator, + &uniffi_bindgen::cargo_metadata::CrateConfigSupplier::default(), + None, + &swift_out, + false, + ) + .unwrap(); - println!("\n✓ All bindings generated successfully"); + println!("✓ Swift bindings generated"); + println!("\n✓ All bindings generated successfully"); - // List generated files - println!("\nGenerated Kotlin files:"); - list_files_recursively(&kotlin_out); + println!("\nGenerated Kotlin files:"); + list_files_recursively(&kotlin_out); - println!("\nGenerated Swift files:"); - list_files_recursively(&swift_out); -} + println!("\nGenerated Swift files:"); + list_files_recursively(&swift_out); + } -fn list_files_recursively(dir: &Utf8PathBuf) { - if let Ok(entries) = fs::read_dir(dir.as_std_path()) { - for entry in entries { - if let Ok(entry) = entry { + fn list_files_recursively(dir: &Utf8PathBuf) { + if let Ok(entries) = fs::read_dir(dir.as_std_path()) { + for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { println!(" Directory: {}", path.display()); @@ -98,3 +95,14 @@ fn list_files_recursively(dir: &Utf8PathBuf) { } } } + +#[cfg(feature = "bindgen")] +fn main() { + bindgen::run(); +} + +#[cfg(not(feature = "bindgen"))] +fn main() { + eprintln!("generate-bindings requires --features bindgen"); + std::process::exit(1); +} diff --git a/examples/basic-benchmark/Cargo.toml b/examples/basic-benchmark/Cargo.toml index 90532e8..d69a4b0 100644 --- a/examples/basic-benchmark/Cargo.toml +++ b/examples/basic-benchmark/Cargo.toml @@ -9,7 +9,7 @@ name = "basic_benchmark" crate-type = ["lib", "cdylib", "staticlib"] [dependencies] -# Use bench-sdk for the #[benchmark] macro and registry +# Use mobench-sdk for the #[benchmark] macro and registry mobench-sdk = { path = "../../crates/mobench-sdk" } inventory.workspace = true serde = { version = "1", features = ["derive"] } diff --git a/examples/basic-benchmark/src/lib.rs b/examples/basic-benchmark/src/lib.rs index 4b1e3f0..71f5292 100644 --- a/examples/basic-benchmark/src/lib.rs +++ b/examples/basic-benchmark/src/lib.rs @@ -1,6 +1,6 @@ -//! Basic benchmark examples demonstrating bench-sdk usage +//! Basic benchmark examples demonstrating mobench-sdk usage //! -//! This example crate shows how to write benchmarks using the bench-sdk +//! This example crate shows how to write benchmarks using the mobench-sdk //! with the #[benchmark] attribute macro. use mobench_sdk::benchmark; @@ -45,7 +45,7 @@ pub enum BenchError { // Generate UniFFI scaffolding from proc macros uniffi::setup_scaffolding!(); -// Conversion from bench-sdk types +// Conversion from mobench-sdk types impl From for BenchSpec { fn from(spec: mobench_sdk::BenchSpec) -> Self { Self { @@ -100,7 +100,7 @@ impl From for BenchError { /// Run a benchmark by name with the given specification /// /// This is the main FFI entry point called from mobile platforms. -/// It uses bench-sdk's registry to discover and execute benchmarks. +/// It uses mobench-sdk's registry to discover and execute benchmarks. #[uniffi::export] pub fn run_benchmark(spec: BenchSpec) -> Result { let sdk_spec: mobench_sdk::BenchSpec = spec.into(); @@ -144,7 +144,7 @@ pub fn checksum(bytes: &[u8]) -> u64 { // Benchmark Functions // ============================================================================ // These functions are marked with #[benchmark] and automatically registered -// with bench-sdk's registry system. +// with mobench-sdk's registry system. /// Benchmark: Fibonacci calculation (30th number, 1000 iterations) #[benchmark] From 24bd3101a65189aafa307b9c241fbe8a4016bb50 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 16 Jan 2026 15:21:51 +0100 Subject: [PATCH 009/196] Refactor mobench layout and refresh README - split CLI entrypoint into library and cargo-mobench bin - update workspace manifests and lockfile for crate changes - rewrite README with current workflow and crate list --- Cargo.lock | 10 +- Cargo.toml | 2 +- README.md | 549 +------ crates/mobench-sdk/Cargo.toml | 4 +- crates/mobench/Cargo.toml | 6 +- crates/mobench/src/bin/cargo-mobench.rs | 6 + crates/mobench/src/lib.rs | 1961 ++++++++++++++++++++++ crates/mobench/src/main.rs | 1963 +---------------------- 8 files changed, 2021 insertions(+), 2480 deletions(-) create mode 100644 crates/mobench/src/bin/cargo-mobench.rs create mode 100644 crates/mobench/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 07fa5e4..79eaeb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -793,7 +793,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "clap", @@ -813,7 +813,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.5" +version = "0.1.6" dependencies = [ "proc-macro2", "quote", @@ -822,7 +822,7 @@ dependencies = [ [[package]] name = "mobench-runner" -version = "0.1.5" +version = "0.1.6" dependencies = [ "serde", "thiserror 1.0.69", @@ -830,7 +830,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "include_dir", @@ -1156,7 +1156,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.5" +version = "0.1.6" dependencies = [ "camino", "mobench-runner", diff --git a/Cargo.toml b/Cargo.toml index 66d36c7..054991f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.5" +version = "0.1.6" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 5f6abb4..ba19197 100644 --- a/README.md +++ b/README.md @@ -1,527 +1,56 @@ -# mobile-bench-rs → mobench-sdk +# mobench -**Mobile benchmarking SDK for Rust** - Run Rust benchmarks on real Android and iOS devices. +Mobile benchmarking SDK for Rust. Build and run Rust benchmarks on Android and iOS, locally or on BrowserStack, with a library-first workflow. -> **Phase 1 MVP Complete!** This project has been transformed into an importable library crate (`mobench-sdk`) that can be published to crates.io. +## What it is -## 🎯 For SDK Integrators +mobench provides a Rust API and a CLI for running benchmarks on real mobile devices. You define benchmarks in Rust, generate mobile bindings automatically, and drive execution from the CLI with consistent output formats (JSON, Markdown, CSV). -**Importing mobench-sdk into your project?** You do **NOT** need the `scripts/` directory! +## How mobench works -- ✅ Use `cargo mobench build --target ` for all builds -- ✅ All build logic is in pure Rust (no shell scripts required) -- ✅ Templates are embedded in the binary -- ✅ See **[BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md)** for the integration guide +- `#[benchmark]` marks functions and registers them via `inventory` +- `mobench-sdk` builds mobile artifacts and generates app templates from embedded assets +- UniFFI proc macros generate Kotlin and Swift bindings directly from Rust types +- The CLI writes a benchmark spec (function, iterations, warmup) and packages it into the app +- Mobile apps call `run_benchmark` via the generated bindings and return timing samples +- The CLI collects results locally or from BrowserStack and writes summaries -**The `scripts/` directory** is legacy tooling for developing this repository. SDK users should ignore it. +## Workspace crates ---- +- `crates/mobench` ([mobench](https://crates.io/crates/mobench)): CLI tool that builds, runs, and fetches benchmarks +- `crates/mobench-sdk` ([mobench-sdk](https://crates.io/crates/mobench-sdk)): core SDK (builders, registry, codegen) +- `crates/mobench-macros` ([mobench-macros](https://crates.io/crates/mobench-macros)): `#[benchmark]` proc macro +- `crates/mobench-runner` ([mobench-runner](https://crates.io/crates/mobench-runner)): lightweight timing harness +- `crates/bench-cli`: BrowserStack and CLI support utilities +- `crates/bench-runner`: host-side harness utilities +- `crates/sample-fns`: sample benchmarks and UniFFI bindings +- `examples/basic-benchmark`: example SDK integration crate -## 🚀 What's New in Phase 1 - -### Library-First Design - -Use `mobench-sdk` in any Rust project: - -```toml -[dependencies] -mobench-sdk = "0.1" -inventory = "0.3" # Required for registry -``` - -### #[benchmark] Macro - -Mark functions for benchmarking: - -```rust -use mobench_sdk::benchmark; - -#[benchmark] -fn my_expensive_operation() { - let result = compute_something(); - std::hint::black_box(result); -} -``` - -### New CLI Commands - -```bash -# Initialize SDK project -cargo mobench init-sdk --target android --project-name my-bench - -# Build mobile artifacts -cargo mobench build --target android - -# Package iOS app as IPA (for BrowserStack or physical devices) -cargo mobench package-ipa --method adhoc - -# List discovered benchmarks -cargo mobench list -``` - -### Architecture - -- **mobench-sdk**: Core library (registry, runner, builders, codegen) -- **mobench-macros**: `#[benchmark]` proc macro -- **mobench**: CLI tool for building, testing, and running benchmarks -- **examples/basic-benchmark**: Example using the new SDK - ---- - -## Original README (Legacy Information) - -## Layout - -- `crates/mobench`: CLI orchestrator for building/packaging benchmarks and driving BrowserStack runs. -- `crates/bench-runner`: Shared harness that will be embedded in Android/iOS binaries; currently host-side only. -- `crates/sample-fns`: Small Rust functions used as demo benchmarks with UniFFI bindings for mobile platforms. -- `PROJECT_PLAN.md`: Goals, architecture outline, and initial task backlog. -- `android/`: Minimal Android app that loads the Rust demo library; Gradle project for BrowserStack/AppAutomate runs. - -## Quick Start - -### Host Testing (No Mobile Build Required) - -Run the host-side Rust tests: - -```bash -cargo test --all -``` - -### Mobile Testing - -For complete end-to-end testing on Android/iOS, see the **[BrowserStack Workflow](#browserstack-workflow)** section below. - -**Quick commands:** - -- **Android**: `scripts/build-android-app.sh` then install APK -- **iOS**: `scripts/build-ios.sh` then open in Xcode - -### Generate Config Files - -```bash -cargo mobench init --output bench-config.toml -cargo mobench plan --output device-matrix.yaml -``` - -### Run Outputs - -`cargo mobench run` writes a JSON summary to `run-summary.json` by default and -produces a Markdown summary alongside it (`run-summary.md`). Use `--output` to -change the base filename and `--summary-csv` to emit a CSV summary. - -## UniFFI Bindings (Proc Macro Mode) - -This project uses [UniFFI](https://mozilla.github.io/uniffi-rs/) with **proc macros** to generate type-safe Kotlin and Swift bindings from Rust code. - -### Adding New FFI Types - -No UDL file needed! Just add proc macro attributes to your Rust types: - -```rust -#[derive(uniffi::Record)] -pub struct MyBenchmark { - pub name: String, - pub iterations: u32, -} - -#[uniffi::export] -pub fn run_my_benchmark(spec: MyBenchmark) -> Result { - // Your implementation -} - -uniffi::setup_scaffolding!(); // Auto-uses crate name as namespace -``` - -### Regenerating Bindings - -After modifying FFI types in `crates/sample-fns/src/lib.rs`: - -```bash -# Build the library first -cargo build -p sample-fns - -# Generate Kotlin + Swift bindings -./scripts/generate-bindings.sh -``` - -Generated files (committed to git for reproducibility): - -- **Kotlin**: `android/app/src/main/java/uniffi/sample_fns/sample_fns.kt` -- **Swift**: `ios/BenchRunner/BenchRunner/Generated/sample_fns.swift` -- **C header**: `ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h` - -The UniFFI API exposes: - -- `runBenchmark(spec: BenchSpec) -> BenchReport`: Run a benchmark by name -- `BenchSpec(name, iterations, warmup)`: Benchmark configuration -- `BenchReport`: Contains timing samples and statistics -- `BenchError`: Type-safe error handling (InvalidIterations, UnknownFunction, ExecutionFailed) - -## Testing Workflows - -mobile-bench-rs supports two testing workflows: - -1. **[Local Development](#local-development-workflow)**: Test on emulators/simulators or connected devices using Android Studio/Xcode -2. **[BrowserStack Testing](#browserstack-workflow)**: Test on real devices in the cloud using BrowserStack App Automate - ---- - -## Deferred QoL (Post-Feedback) - -These improvements are intentionally deferred until we have real usage feedback: - -- Parallel device runs and retry policies -- Additional percentile/statistics enhancements -- Energy or thermal readings where supported - -## Local Development Workflow - -Test your benchmarks locally using Android Studio or Xcode. This is the fastest way to iterate during development. - -### Android (Local) - -#### Quick Start (All-in-One) - -```bash -# Build everything and create APK -# Set UNIFFI_ANDROID_ABI for emulator ABI (x86_64 for default Android Studio emulators). -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh - -# Install and launch on emulator/device -adb install -r android/app/build/outputs/apk/debug/app-debug.apk -adb shell am start -n dev.world.bench/.MainActivity -``` - -#### Step-by-Step - -```bash -# 1. Build Rust libraries + regenerate bindings (ABI-aware) + sync jniLibs -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh - -# 2. Build the APK with Gradle -cd android && ./gradlew :app:assembleDebug - -# 3. Install and launch -adb install -r app/build/outputs/apk/debug/app-debug.apk -adb shell am start -n dev.world.bench/.MainActivity -``` - -#### Testing with Custom Parameters - -```bash -# Launch with custom benchmark function and parameters -adb shell am start -n dev.world.bench/.MainActivity \ - --es bench_function sample_fns::checksum \ - --ei bench_iterations 30 \ - --ei bench_warmup 5 -``` - -#### Using Android Studio - -1. Open the `android/` directory in Android Studio -2. Ensure Rust libraries are built: `scripts/build-android.sh` -3. Sync libs: `scripts/sync-android-libs.sh` -4. Click Run (the app module should auto-sync) -5. Select emulator/device and run - -**Expected Output**: The app displays formatted benchmark results with individual sample timings and statistics (min/max/avg). - -### iOS (Local) - -#### Prerequisites - -```bash -# Install xcodegen if not already installed -brew install xcodegen - -# Install Rust iOS targets -rustup target add aarch64-apple-ios aarch64-apple-ios-sim -``` - -#### Step-by-Step - -```bash -# 1. Build Rust xcframework for iOS (includes UniFFI headers and automatic code signing) -scripts/build-ios.sh - -# The script creates a properly structured xcframework with: -# - Static libraries for device (aarch64-apple-ios) and simulator (aarch64-apple-ios-sim) -# - UniFFI-generated C headers in each framework slice -# - Module maps for Swift interop -# - Correct bundle identifiers and platform identifiers -# - Automatic code signing for Xcode compatibility - -# 2. Generate Xcode project from project.yml -cd ios/BenchRunner -xcodegen generate - -# 3. Open in Xcode -open BenchRunner.xcodeproj -``` - -Then in Xcode: - -1. Select a simulator (e.g., iPhone 15) or connected device -2. Click Run (⌘R) or Product → Run -3. The app will display benchmark results - -**Note**: The project uses a bridging header to expose C FFI types from Rust to Swift. The UniFFI-generated Swift bindings are compiled directly into the app (no module import needed). - -#### Testing with Custom Parameters - -**Method 1: Edit Scheme (Xcode)** - -1. Product → Scheme → Edit Scheme... -2. Run → Arguments → Environment Variables -3. Add variables: - - `BENCH_FUNCTION` = `sample_fns::checksum` - - `BENCH_ITERATIONS` = `30` - - `BENCH_WARMUP` = `5` -4. Run the app - -**Method 2: Command Line (simulator only)** +## Quick start ```bash -# Build and run with xcrun -xcrun simctl launch booted dev.world.bench.BenchRunner \ - --bench-function=sample_fns::checksum \ - --bench-iterations=30 \ - --bench-warmup=5 -``` - -**Expected Output**: The app displays formatted benchmark results with individual sample timings and statistics (min/max/avg). +# Install the CLI +cargo install mobench -### Key Features +# Add the SDK to your project +cargo add mobench-sdk inventory -The build process is **streamlined** with UniFFI proc macros: - -- ✅ No UDL file needed - proc macros define the FFI from Rust code -- ✅ No need to run `cbindgen` manually -- ✅ UniFFI headers (`sample_fnsFFI.h`) are automatically generated during `build-ios.sh` -- ✅ Kotlin/Swift bindings are already committed to git -- ✅ Only regenerate bindings if you change FFI types in Rust (via `./scripts/generate-bindings.sh`) -- ✅ Apps show formatted output with statistics (min/max/avg in microseconds) -- ✅ Type-safe error handling (no more string parsing) - ---- - -## BrowserStack Workflow - -Test your benchmarks on real devices in the cloud using BrowserStack App Automate. This workflow uploads your app to BrowserStack, runs tests remotely, and downloads results. - -### Prerequisites - -1. **BrowserStack Account**: Sign up at [browserstack.com](https://www.browserstack.com/) -2. **Credentials**: Set environment variables: - ```bash - export BROWSERSTACK_USERNAME="your_username" - export BROWSERSTACK_ACCESS_KEY="your_access_key" - ``` -3. **Built Artifacts**: Build your app and test suite first (see below) - -### Android + BrowserStack (Espresso) - -#### Step 1: Build Artifacts - -```bash -# Build Android app APK and test suite -UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh - -# Build test APK (if needed) -cd android -./gradlew :app:assembleDebugAndroidTest -cd .. -``` - -Artifacts created: -- **App APK**: `android/app/build/outputs/apk/debug/app-debug.apk` -- **Test APK**: `android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk` - -#### Step 2: Run on BrowserStack - -```bash -# Run benchmark on specific device -cargo mobench run \ - --target android \ - --function sample_fns::fibonacci \ - --iterations 100 \ - --warmup 10 \ - --devices "Google Pixel 7-13.0" \ - --output run-summary.json -``` - -**What happens:** -1. CLI uploads APKs to BrowserStack -2. Schedules Espresso test run on specified device -3. Waits for completion -4. Downloads logs and results -5. Saves summary to `run-summary.json` - -#### Step 3: View Results - -```bash -# Results are in run-summary.json -cat run-summary.json - -# BrowserStack artifacts downloaded to: -# target/browserstack/{build_id}/session-{session_id}/ -``` - -**BrowserStack Dashboard**: View live test execution at https://app-automate.browserstack.com/dashboard - -### iOS + BrowserStack (XCUITest) - -#### Step 1: Build Artifacts - -```bash -# Build iOS app and xcframework -./scripts/build-ios.sh - -# Generate Xcode project -cd ios/BenchRunner -xcodegen generate - -# Build app for device (requires signing) -xcodebuild -project BenchRunner.xcodeproj \ - -scheme BenchRunner \ - -sdk iphoneos \ - -configuration Release \ - -derivedDataPath build \ - CODE_SIGN_IDENTITY="-" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO - -# Create IPA -mkdir -p Payload -cp -r build/Build/Products/Release-iphoneos/BenchRunner.app Payload/ -zip -r BenchRunner.ipa Payload/ -mv BenchRunner.ipa ../../target/ios/ - -# Build XCUITest runner -xcodebuild build-for-testing \ - -project BenchRunner.xcodeproj \ - -scheme BenchRunner \ - -sdk iphoneos \ - -derivedDataPath build - -# Package test runner -cd build/Build/Products/Release-iphoneos -zip -r BenchRunnerUITests-Runner.zip BenchRunnerUITests-Runner.app -mv BenchRunnerUITests-Runner.zip ../../../../target/ios/ -cd ../../../.. -``` - -Artifacts created: -- **App IPA**: `target/ios/BenchRunner.ipa` -- **Test Suite**: `target/ios/BenchRunnerUITests-Runner.zip` - -#### Step 2: Run on BrowserStack - -```bash -# Run benchmark on specific device -cargo mobench run \ - --target ios \ - --function sample_fns::fibonacci \ - --iterations 100 \ - --warmup 10 \ - --devices "iPhone 14-16" \ - --ios-app target/ios/BenchRunner.ipa \ - --ios-test-suite target/ios/BenchRunnerUITests-Runner.zip \ - --output run-summary.json -``` - -**What happens:** -1. CLI uploads IPA and test suite to BrowserStack -2. Schedules XCUITest run on specified device -3. Waits for completion -4. Downloads logs and results -5. Saves summary to `run-summary.json` - -### Using Config Files (Recommended) - -For repeated runs, use config files: - -```bash -# Generate templates -cargo mobench init --output bench-config.toml --target android -cargo mobench plan --output device-matrix.yaml -``` - -**bench-config.toml:** -```toml -target = "android" -function = "sample_fns::fibonacci" -iterations = 100 -warmup = 10 -device_matrix = "device-matrix.yaml" - -[browserstack] -app_automate_username = "${BROWSERSTACK_USERNAME}" -app_automate_access_key = "${BROWSERSTACK_ACCESS_KEY}" -project = "mobile-bench-rs" -``` +# Build artifacts +cargo mobench build --target android +cargo mobench build --target ios -**device-matrix.yaml:** -```yaml -devices: - - name: Google Pixel 7-13.0 - os: android - os_version: "13.0" - tags: [default, pixel] - - name: Samsung Galaxy S23-13.0 - os: android - os_version: "13.0" - tags: [samsung] +# Run a benchmark +cargo mobench run --target android --function sample_fns::fibonacci ``` -**Run with config:** -```bash -cargo mobench run --config bench-config.toml -``` - -### BrowserStack Features - -- **Device Logs**: Automatically downloaded to `target/browserstack/{build_id}/session-{session_id}/` -- **Screenshots/Video**: Available in BrowserStack dashboard -- **Parallel Testing**: Specify multiple devices to run in parallel -- **Network Conditions**: Configure via BrowserStack dashboard -- **Real Devices**: Tests run on actual hardware, not emulators - ---- - -## Requirements - -### Android - -- Android Studio (SDK + NDK manager): https://developer.android.com/studio -- Android NDK (API level 24+): https://developer.android.com/ndk/downloads -- `ANDROID_NDK_HOME` environment variable set -- `cargo-ndk` installed: `cargo install cargo-ndk` (https://github.com/bbqsrc/cargo-ndk) -- JDK 17+ (for Gradle; any distribution): https://openjdk.org/install/ - - Note: Android Gradle Plugin (AGP) officially supports Java 17. -- For local testing: Android emulator or physical device -- For BrowserStack: BrowserStack account and credentials - -### iOS - -- macOS with Xcode command-line tools: https://developer.apple.com/xcode/ -- Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim` (https://doc.rust-lang.org/rustup/targets.html) -- `xcodegen` installed (optional): https://github.com/yonaskolb/XcodeGen -- For local testing: iOS Simulator or physical device (requires code signing) -- For BrowserStack: BrowserStack account and credentials - ---- - -## Additional Documentation - -- **`BUILD.md`**: Complete build reference guide for Android and iOS (prerequisites, step-by-step instructions, troubleshooting) -- **`TESTING.md`**: Comprehensive testing guide with troubleshooting and advanced scenarios -- **`PROJECT_PLAN.md`**: Project goals, architecture, and task backlog -- **`CLAUDE.md`**: Developer guide for this codebase - ---- +## Project docs -## License +- `BENCH_SDK_INTEGRATION.md`: SDK integration guide +- `BUILD.md`: build prerequisites and troubleshooting +- `TESTING.md`: testing guide and device workflows +- `BROWSERSTACK_CI_INTEGRATION.md`: BrowserStack CI setup +- `FETCH_RESULTS_GUIDE.md`: fetching and summarizing results +- `PROJECT_PLAN.md`: goals and backlog +- `CLAUDE.md`: developer guide -MIT OR Apache-2.0 +MIT licensed — World Foundation 2026. diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 9b55601..4ffac50 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -21,8 +21,8 @@ crate-type = ["lib"] [dependencies] # Core dependencies -mobench-runner = { version = "0.1", path = "../mobench-runner" } -mobench-macros = { version = "0.1", path = "../mobench-macros" } +mobench-runner = { version = "0.1.6", path = "../mobench-runner" } +mobench-macros = { version = "0.1.6", path = "../mobench-macros" } # Registry inventory.workspace = true diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 87ab466..eed6932 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -22,12 +22,12 @@ path = "src/main.rs" [[bin]] name = "cargo-mobench" -path = "src/main.rs" +path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1", path = "../mobench-sdk" } -mobench-runner = { version = "0.1", path = "../mobench-runner" } +mobench-sdk = { version = "0.1.6", path = "../mobench-sdk" } +mobench-runner = { version = "0.1.6", path = "../mobench-runner" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/src/bin/cargo-mobench.rs b/crates/mobench/src/bin/cargo-mobench.rs new file mode 100644 index 0000000..f5e8e59 --- /dev/null +++ b/crates/mobench/src/bin/cargo-mobench.rs @@ -0,0 +1,6 @@ +fn main() { + if let Err(err) = mobench::run() { + eprintln!("{err:#}"); + std::process::exit(1); + } +} diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs new file mode 100644 index 0000000..0c7a816 --- /dev/null +++ b/crates/mobench/src/lib.rs @@ -0,0 +1,1961 @@ +use anyhow::{Context, Result, anyhow, bail}; +use clap::{Parser, Subcommand, ValueEnum}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::env; +use std::fmt::Write; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use browserstack::{BrowserStackAuth, BrowserStackClient}; + +mod browserstack; + +/// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. +#[derive(Parser, Debug)] +#[command(name = "mobench", author, version, about = "Mobile Rust benchmarking orchestrator", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Run a benchmark against a target platform (mobile integration stub for now). + Run { + #[arg(long, value_enum)] + target: MobileTarget, + #[arg(long, help = "Fully-qualified Rust function to benchmark")] + function: String, + #[arg(long, default_value_t = 100)] + iterations: u32, + #[arg(long, default_value_t = 10)] + warmup: u32, + #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] + devices: Vec, + #[arg(long, help = "Optional path to config file")] + config: Option, + #[arg(long, help = "Optional output path for JSON report")] + output: Option, + #[arg(long, help = "Write CSV summary alongside JSON")] + summary_csv: bool, + #[arg(long, help = "Skip mobile builds and only run the host harness")] + local_only: bool, + #[arg( + long, + help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" + )] + ios_app: Option, + #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] + ios_test_suite: Option, + #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] + fetch: bool, + #[arg(long, default_value = "target/browserstack")] + fetch_output_dir: PathBuf, + #[arg(long, default_value_t = 5)] + fetch_poll_interval_secs: u64, + #[arg(long, default_value_t = 300)] + fetch_timeout_secs: u64, + }, + /// Scaffold a base config file for the CLI. + Init { + #[arg(long, default_value = "bench-config.toml")] + output: PathBuf, + #[arg(long, value_enum, default_value_t = MobileTarget::Android)] + target: MobileTarget, + }, + /// Generate a sample device matrix file. + Plan { + #[arg(long, default_value = "device-matrix.yaml")] + output: PathBuf, + }, + /// Fetch BrowserStack build artifacts (logs, session JSON) for CI. + Fetch { + #[arg(long, value_enum)] + target: MobileTarget, + #[arg(long)] + build_id: String, + #[arg(long, default_value = "target/browserstack")] + output_dir: PathBuf, + #[arg(long, default_value_t = true)] + wait: bool, + #[arg(long, default_value_t = 10)] + poll_interval_secs: u64, + #[arg(long, default_value_t = 1800)] + timeout_secs: u64, + }, + /// Compare two run summaries for regressions. + Compare { + #[arg(long, help = "Baseline JSON summary to compare against")] + baseline: PathBuf, + #[arg(long, help = "Candidate JSON summary to compare")] + candidate: PathBuf, + #[arg(long, help = "Optional output path for markdown report")] + output: Option, + }, + /// Initialize a new benchmark project with SDK (Phase 1 MVP). + InitSdk { + #[arg(long, value_enum)] + target: SdkTarget, + #[arg(long, default_value = "bench-project")] + project_name: String, + #[arg(long, default_value = ".")] + output_dir: PathBuf, + #[arg(long, help = "Generate example benchmarks")] + examples: bool, + }, + /// Build mobile artifacts (Phase 1 MVP). + Build { + #[arg(long, value_enum)] + target: SdkTarget, + #[arg(long, help = "Build in release mode")] + release: bool, + }, + /// Package iOS app as IPA for distribution or testing. + PackageIpa { + #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] + scheme: String, + #[arg(long, value_enum, default_value = "adhoc", help = "Signing method")] + method: IosSigningMethodArg, + }, + /// List all discovered benchmark functions (Phase 1 MVP). + List, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum MobileTarget { + Android, + Ios, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +enum SdkTarget { + Android, + Ios, + Both, +} + +impl From for mobench_sdk::Target { + fn from(target: SdkTarget) -> Self { + match target { + SdkTarget::Android => mobench_sdk::Target::Android, + SdkTarget::Ios => mobench_sdk::Target::Ios, + SdkTarget::Both => mobench_sdk::Target::Both, + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +enum IosSigningMethodArg { + /// Ad-hoc signing (no Apple ID needed, works for BrowserStack) + Adhoc, + /// Development signing (requires Apple Developer account) + Development, +} + +impl From for mobench_sdk::builders::SigningMethod { + fn from(arg: IosSigningMethodArg) -> Self { + match arg { + IosSigningMethodArg::Adhoc => mobench_sdk::builders::SigningMethod::AdHoc, + IosSigningMethodArg::Development => mobench_sdk::builders::SigningMethod::Development, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct BrowserStackConfig { + app_automate_username: String, + app_automate_access_key: String, + project: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct IosXcuitestArtifacts { + app: PathBuf, + test_suite: PathBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +struct BenchConfig { + target: MobileTarget, + function: String, + iterations: u32, + warmup: u32, + device_matrix: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + device_tags: Option>, + browserstack: BrowserStackConfig, + #[serde(skip_serializing_if = "Option::is_none", default)] + ios_xcuitest: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct DeviceEntry { + name: String, + os: String, + os_version: String, + tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DeviceMatrix { + devices: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RunSpec { + target: MobileTarget, + function: String, + iterations: u32, + warmup: u32, + devices: Vec, + #[serde(skip_serializing, skip_deserializing, default)] + browserstack: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + ios_xcuitest: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "platform", rename_all = "lowercase")] +enum MobileArtifacts { + Android { + apk: PathBuf, + }, + Ios { + xcframework: PathBuf, + header: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + app: Option, + #[serde(skip_serializing_if = "Option::is_none")] + test_suite: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct RunSummary { + spec: RunSpec, + artifacts: Option, + local_report: Value, + remote_run: Option, + summary: SummaryReport, + #[serde(skip_serializing_if = "Option::is_none")] + benchmark_results: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + performance_metrics: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct SummaryReport { + generated_at: String, + generated_at_unix: u64, + target: MobileTarget, + function: String, + iterations: u32, + warmup: u32, + devices: Vec, + device_summaries: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct DeviceSummary { + device: String, + benchmarks: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct BenchmarkStats { + function: String, + samples: usize, + mean_ns: Option, + median_ns: Option, + p95_ns: Option, + min_ns: Option, + max_ns: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "platform", rename_all = "lowercase")] +enum RemoteRun { + Android { + app_url: String, + build_id: String, + }, + Ios { + app_url: String, + test_suite_url: String, + build_id: String, + }, +} + +pub fn run() -> Result<()> { + load_dotenv(); + let cli = Cli::parse(); + match cli.command { + Command::Run { + target, + function, + iterations, + warmup, + devices, + config, + output, + summary_csv, + local_only, + ios_app, + ios_test_suite, + fetch, + fetch_output_dir, + fetch_poll_interval_secs, + fetch_timeout_secs, + } => { + let spec = resolve_run_spec( + target, + function, + iterations, + warmup, + devices, + config.as_deref(), + ios_app, + ios_test_suite, + local_only, + )?; + let summary_paths = resolve_summary_paths(output.as_deref())?; + println!( + "Preparing benchmark run for {:?}: {} (iterations={}, warmup={})", + spec.target, spec.function, spec.iterations, spec.warmup + ); + persist_mobile_spec(&spec)?; + if !spec.devices.is_empty() { + println!("Devices: {}", spec.devices.join(", ")); + } + println!("JSON summary will be written to {:?}", summary_paths.json); + println!( + "Markdown summary will be written to {:?}", + summary_paths.markdown + ); + if summary_csv { + println!("CSV summary will be written to {:?}", summary_paths.csv); + } + + // Skip local smoke test - sample-fns uses direct dispatch, not inventory registry + // Benchmarks will run on the actual mobile device + println!("Skipping local smoke test - benchmarks will run on mobile device"); + let local_report = json!({ + "skipped": true, + "reason": "Local smoke test disabled - benchmarks run on mobile device only" + }); + let mut remote_run = None; + let artifacts = if local_only { + println!("Skipping mobile build: --local-only set"); + None + } else { + match spec.target { + MobileTarget::Android => { + let ndk = std::env::var("ANDROID_NDK_HOME") + .context("ANDROID_NDK_HOME must be set for Android builds")?; + let build = run_android_build(&ndk)?; + let apk = build.app_path; + println!("Built Android APK at {:?}", apk); + if spec.devices.is_empty() { + println!("Skipping BrowserStack upload/run: no devices provided"); + Some(MobileArtifacts::Android { apk }) + } else { + let test_apk = build.test_suite_path.as_ref().context( + "Android test suite APK missing; run ./gradlew assembleDebugAndroidTest", + )?; + let run = trigger_browserstack_espresso(&spec, &apk, test_apk)?; + remote_run = Some(run); + Some(MobileArtifacts::Android { apk }) + } + } + MobileTarget::Ios => { + let (xcframework, header) = run_ios_build()?; + println!("Built iOS xcframework at {:?}", xcframework); + let ios_xcuitest = spec.ios_xcuitest.clone(); + + if spec.devices.is_empty() { + println!("Skipping BrowserStack upload/run: no devices provided"); + } else { + let xcui = spec.ios_xcuitest.as_ref().context( + "iOS XCUITest artifacts required when targeting BrowserStack devices; provide --ios-app and --ios-test-suite or set ios_xcuitest in the config", + )?; + let run = trigger_browserstack_xcuitest(&spec, xcui)?; + remote_run = Some(run); + } + + Some(MobileArtifacts::Ios { + xcframework, + header, + app: ios_xcuitest.as_ref().map(|a| a.app.clone()), + test_suite: ios_xcuitest.map(|a| a.test_suite), + }) + } + } + }; + + let summary_placeholder = empty_summary(&spec); + let mut run_summary = RunSummary { + spec, + artifacts, + local_report, + remote_run, + summary: summary_placeholder, + benchmark_results: None, + performance_metrics: None, + }; + + if fetch && let Some(remote) = &run_summary.remote_run { + let build_id = match remote { + RemoteRun::Android { build_id, .. } => build_id, + RemoteRun::Ios { build_id, .. } => build_id, + }; + let creds = + resolve_browserstack_credentials(run_summary.spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + )?; + + let platform = match run_summary.spec.target { + MobileTarget::Android => "espresso", + MobileTarget::Ios => "xcuitest", + }; + + let dashboard_url = format!( + "https://app-automate.browserstack.com/dashboard/v2/builds/{}", + build_id + ); + + println!("Waiting for build {} to complete...", build_id); + println!("Dashboard: {}", dashboard_url); + + match client.wait_and_fetch_all_results_with_poll( + build_id, + platform, + Some(fetch_timeout_secs), + Some(fetch_poll_interval_secs), + ) { + Ok((bench_results, perf_metrics)) => { + println!( + "\n✓ Successfully fetched results from {} device(s)", + bench_results.len() + ); + + // Print summary of benchmark results + for (device, results) in &bench_results { + println!("\n Device: {}", device); + for (idx, result) in results.iter().enumerate() { + if let Some(function) = + result.get("function").and_then(|f| f.as_str()) + { + println!(" Benchmark {}: {}", idx + 1, function); + } + if let Some(mean) = result.get("mean_ns").and_then(|m| m.as_u64()) { + println!( + " Mean: {} ns ({:.2} ms)", + mean, + mean as f64 / 1_000_000.0 + ); + } + if let Some(samples) = + result.get("samples").and_then(|s| s.as_array()) + { + println!(" Samples: {}", samples.len()); + } + } + + // Print performance metrics if available + if let Some(metrics) = + perf_metrics.get(device).filter(|m| m.sample_count > 0) + { + println!("\n Performance Metrics:"); + if let Some(mem) = &metrics.memory { + println!(" Memory:"); + println!(" Peak: {:.2} MB", mem.peak_mb); + println!(" Average: {:.2} MB", mem.average_mb); + } + if let Some(cpu) = &metrics.cpu { + println!(" CPU:"); + println!(" Peak: {:.1}%", cpu.peak_percent); + println!(" Average: {:.1}%", cpu.average_percent); + } + } + } + + println!("\n View full results: {}", dashboard_url); + run_summary.benchmark_results = Some(bench_results.into_iter().collect()); + run_summary.performance_metrics = Some(perf_metrics.into_iter().collect()); + } + Err(e) => { + println!("\nWarning: Failed to fetch results: {}", e); + println!("Build may still be accessible at: {}", dashboard_url); + } + } + + // Also save detailed artifacts to separate directory + let output_root = fetch_output_dir.join(build_id); + if let Err(e) = fetch_browserstack_artifacts( + &client, + run_summary.spec.target, + build_id, + &output_root, + false, // Don't wait again, we already did + fetch_poll_interval_secs, + fetch_timeout_secs, + ) { + println!("Warning: Failed to fetch detailed artifacts: {}", e); + } + } else if fetch { + println!("No BrowserStack run to fetch (devices not provided?)"); + } + + run_summary.summary = build_summary(&run_summary)?; + write_summary(&run_summary, &summary_paths, summary_csv)?; + } + Command::Init { output, target } => { + write_config_template(&output, target)?; + println!("Wrote starter config to {:?}", output); + } + Command::Plan { output } => { + write_device_matrix_template(&output)?; + println!("Wrote sample device matrix to {:?}", output); + } + Command::Fetch { + target, + build_id, + output_dir, + wait, + poll_interval_secs, + timeout_secs, + } => { + let creds = resolve_browserstack_credentials(None)?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + )?; + let output_root = output_dir.join(&build_id); + fetch_browserstack_artifacts( + &client, + target, + &build_id, + &output_root, + wait, + poll_interval_secs, + timeout_secs, + )?; + } + Command::Compare { + baseline, + candidate, + output, + } => { + let report = compare_summaries(&baseline, &candidate)?; + write_compare_report(&report, output.as_deref())?; + } + Command::InitSdk { + target, + project_name, + output_dir, + examples, + } => { + cmd_init_sdk(target, project_name, output_dir, examples)?; + } + Command::Build { target, release } => { + cmd_build(target, release)?; + } + Command::PackageIpa { scheme, method } => { + cmd_package_ipa(&scheme, method)?; + } + Command::List => { + cmd_list()?; + } + } + + Ok(()) +} + +fn write_config_template(path: &Path, target: MobileTarget) -> Result<()> { + ensure_can_write(path)?; + + let ios_xcuitest = if target == MobileTarget::Ios { + Some(IosXcuitestArtifacts { + app: PathBuf::from("target/ios/BenchRunner.ipa"), + test_suite: PathBuf::from("target/ios/BenchRunnerUITests.zip"), + }) + } else { + None + }; + + let cfg = BenchConfig { + target, + function: "sample_fns::fibonacci".into(), + iterations: 100, + warmup: 10, + device_matrix: PathBuf::from("device-matrix.yaml"), + device_tags: Some(vec!["default".into()]), + browserstack: BrowserStackConfig { + app_automate_username: "${BROWSERSTACK_USERNAME}".into(), + app_automate_access_key: "${BROWSERSTACK_ACCESS_KEY}".into(), + project: Some("mobile-bench-rs".into()), + }, + ios_xcuitest, + }; + + let contents = toml::to_string_pretty(&cfg)?; + write_file(path, contents.as_bytes()) +} + +fn write_device_matrix_template(path: &Path) -> Result<()> { + ensure_can_write(path)?; + + let matrix = DeviceMatrix { + devices: vec![ + DeviceEntry { + name: "Pixel 7".into(), + os: "android".into(), + os_version: "13.0".into(), + tags: Some(vec!["default".into(), "pixel".into()]), + }, + DeviceEntry { + name: "iPhone 14".into(), + os: "ios".into(), + os_version: "16".into(), + tags: Some(vec!["default".into(), "iphone".into()]), + }, + ], + }; + + let contents = serde_yaml::to_string(&matrix)?; + write_file(path, contents.as_bytes()) +} + +fn fetch_browserstack_artifacts( + client: &BrowserStackClient, + target: MobileTarget, + build_id: &str, + output_root: &Path, + wait: bool, + poll_interval_secs: u64, + timeout_secs: u64, +) -> Result<()> { + fs::create_dir_all(output_root) + .with_context(|| format!("creating output dir {:?}", output_root))?; + + let base = browserstack_base_path(target); + let build_path = format!("{base}/builds/{build_id}"); + let sessions_path = format!("{base}/builds/{build_id}/sessions"); + + if wait { + wait_for_build(client, &build_path, poll_interval_secs, timeout_secs)?; + } + + let build_json = client.get_json(&build_path)?; + write_json(output_root.join("build.json"), &build_json)?; + + let mut session_ids = extract_session_ids(&build_json); + if session_ids.is_empty() { + match client.get_json(&sessions_path) { + Ok(value) => { + write_json(output_root.join("sessions.json"), &value)?; + session_ids = extract_session_ids(&value); + } + Err(err) => { + let msg = shorten_html_error(&err.to_string()); + println!("Sessions endpoint unavailable; falling back to build.json: {msg}"); + } + } + } + + if session_ids.is_empty() { + println!("No sessions found for build {}", build_id); + return Ok(()); + } + + for session_id in session_ids { + let session_path = format!("{base}/builds/{build_id}/sessions/{session_id}"); + let session_json = client.get_json(&session_path)?; + let session_dir = output_root.join(format!("session-{}", session_id)); + fs::create_dir_all(&session_dir) + .with_context(|| format!("creating session dir {:?}", session_dir))?; + write_json(session_dir.join("session.json"), &session_json)?; + + let mut bench_report: Option = None; + for (key, url) in extract_url_fields(&session_json) { + let file_name = filename_for_url(&key, &url); + let dest = session_dir.join(file_name); + if let Err(err) = client.download_url(&url, &dest) { + println!("Skipping download for {key}: {err}"); + continue; + } + if (key.contains("device_log") + || key.contains("instrumentation_log") + || key.contains("app_log")) + && let Ok(contents) = fs::read_to_string(&dest) + && let Some(parsed) = extract_bench_json(&contents) + { + bench_report = Some(parsed); + } + } + + if let Some(report) = bench_report { + write_json(session_dir.join("bench-report.json"), &report)?; + } + } + + println!("Fetched BrowserStack artifacts to {:?}", output_root); + Ok(()) +} + +fn browserstack_base_path(target: MobileTarget) -> &'static str { + match target { + MobileTarget::Android => "app-automate/espresso/v2", + MobileTarget::Ios => "app-automate/xcuitest/v2", + } +} + +fn wait_for_build( + client: &BrowserStackClient, + build_path: &str, + poll_interval_secs: u64, + timeout_secs: u64, +) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + loop { + let build_json = client.get_json(build_path)?; + if let Some(status) = build_json + .get("status") + .and_then(|val| val.as_str()) + .map(|val| val.to_lowercase()) + { + if status == "failed" || status == "error" { + println!("Build status: {status}"); + return Ok(()); + } + if status == "done" || status == "passed" || status == "completed" { + println!("Build status: {status}"); + return Ok(()); + } + println!("Build status: {status} (waiting)"); + } else { + println!("Build status missing; continuing without wait"); + return Ok(()); + } + + if Instant::now() >= deadline { + println!("Timed out waiting for build status"); + return Ok(()); + } + std::thread::sleep(Duration::from_secs(poll_interval_secs)); + } +} + +fn extract_session_ids(value: &Value) -> Vec { + let sessions = value + .get("sessions") + .and_then(|val| val.as_array()) + .or_else(|| value.as_array()); + let mut ids = Vec::new(); + if let Some(entries) = sessions { + for entry in entries { + let id = entry + .get("id") + .or_else(|| entry.get("session_id")) + .or_else(|| entry.get("sessionId")) + .and_then(|val| val.as_str()); + if let Some(id) = id { + ids.push(id.to_string()); + } + } + } + if ids.is_empty() + && let Some(devices) = value.get("devices").and_then(|val| val.as_array()) + { + for device in devices { + if let Some(sessions) = device.get("sessions").and_then(|val| val.as_array()) { + for entry in sessions { + if let Some(id) = entry.get("id").and_then(|val| val.as_str()) { + ids.push(id.to_string()); + } + } + } + } + } + ids +} + +fn extract_url_fields(value: &Value) -> Vec<(String, String)> { + let mut urls = Vec::new(); + extract_url_fields_recursive(value, "", &mut urls); + urls +} + +fn extract_url_fields_recursive(value: &Value, prefix: &str, out: &mut Vec<(String, String)>) { + match value { + Value::Object(map) => { + for (key, val) in map { + let next = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + if let Value::String(url) = val + && (url.starts_with("http") || url.starts_with("bs://")) + { + out.push((next.clone(), url.clone())); + } + extract_url_fields_recursive(val, &next, out); + } + } + Value::Array(items) => { + for (idx, val) in items.iter().enumerate() { + let next = format!("{}[{}]", prefix, idx); + extract_url_fields_recursive(val, &next, out); + } + } + _ => {} + } +} + +fn filename_for_url(key: &str, url: &str) -> String { + let stripped = url.split('?').next().unwrap_or(url); + let ext = Path::new(stripped) + .extension() + .and_then(|val| val.to_str()) + .unwrap_or("log"); + let mut safe = String::with_capacity(key.len()); + for ch in key.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + safe.push(ch); + } else { + safe.push('_'); + } + } + format!("{}.{}", safe, ext) +} + +fn extract_bench_json(contents: &str) -> Option { + let marker = "BENCH_JSON "; + for line in contents.lines().rev() { + if let Some(idx) = line.find(marker) { + let json_part = &line[idx + marker.len()..]; + if let Ok(value) = serde_json::from_str::(json_part) { + return Some(value); + } + } + } + None +} + +fn write_json(path: PathBuf, value: &Value) -> Result<()> { + let contents = serde_json::to_string_pretty(value)?; + write_file(&path, contents.as_bytes()) +} + +fn shorten_html_error(message: &str) -> String { + if message.contains("") || message.contains(", + config: Option<&Path>, + ios_app: Option, + ios_test_suite: Option, + local_only: bool, +) -> Result { + if let Some(cfg_path) = config { + let cfg = load_config(cfg_path)?; + let matrix = load_device_matrix(&cfg.device_matrix)?; + let device_names = match &cfg.device_tags { + Some(tags) if !tags.is_empty() => filter_devices_by_tags(matrix.devices, tags)?, + _ => matrix.devices.into_iter().map(|d| d.name).collect(), + }; + return Ok(RunSpec { + target: cfg.target, + function: cfg.function, + iterations: cfg.iterations, + warmup: cfg.warmup, + devices: device_names, + browserstack: Some(cfg.browserstack), + ios_xcuitest: cfg.ios_xcuitest, + }); + } + + if function.trim().is_empty() { + bail!("function must not be empty"); + } + + let ios_xcuitest = match (ios_app, ios_test_suite) { + (Some(app), Some(test_suite)) => Some(IosXcuitestArtifacts { app, test_suite }), + (None, None) => None, + _ => bail!("both --ios-app and --ios-test-suite must be provided together"), + }; + + let ios_xcuitest = if target == MobileTarget::Ios + && !local_only + && !devices.is_empty() + && ios_xcuitest.is_none() + { + Some(package_ios_xcuitest_artifacts()?) + } else { + ios_xcuitest + }; + + Ok(RunSpec { + target, + function, + iterations, + warmup, + devices, + browserstack: None, + ios_xcuitest, + }) +} + +fn load_config(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("reading config {:?}", path))?; + toml::from_str(&contents).with_context(|| format!("parsing config {:?}", path)) +} + +fn load_device_matrix(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("reading device matrix {:?}", path))?; + serde_yaml::from_str(&contents).with_context(|| format!("parsing device matrix {:?}", path)) +} + +fn filter_devices_by_tags(devices: Vec, tags: &[String]) -> Result> { + let wanted: Vec = tags + .iter() + .map(|tag| tag.trim().to_lowercase()) + .filter(|tag| !tag.is_empty()) + .collect(); + if wanted.is_empty() { + return Ok(devices.into_iter().map(|d| d.name).collect()); + } + + let mut matched = Vec::new(); + for device in devices { + let Some(device_tags) = device.tags.as_ref() else { + continue; + }; + let has_match = device_tags.iter().any(|tag| { + let candidate = tag.trim().to_lowercase(); + wanted.iter().any(|wanted_tag| wanted_tag == &candidate) + }); + if has_match { + matched.push(device.name); + } + } + + if matched.is_empty() { + bail!( + "no devices matched tags [{}] in device matrix", + wanted.join(", ") + ); + } + Ok(matched) +} + +fn run_ios_build() -> Result<(PathBuf, PathBuf)> { + let root = repo_root()?; + let crate_name = + detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); + let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); + let cfg = mobench_sdk::BuildConfig { + target: mobench_sdk::Target::Ios, + profile: mobench_sdk::BuildProfile::Debug, + incremental: true, + }; + let result = builder.build(&cfg)?; + let header = root.join("target/ios/include").join(format!( + "{}.h", + result + .app_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("module") + )); + Ok((result.app_path, header)) +} + +fn package_ios_xcuitest_artifacts() -> Result { + let root = repo_root()?; + let crate_name = + detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); + let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); + let cfg = mobench_sdk::BuildConfig { + target: mobench_sdk::Target::Ios, + profile: mobench_sdk::BuildProfile::Debug, + incremental: true, + }; + builder + .build(&cfg) + .context("Failed to build iOS xcframework before packaging")?; + let app = builder + .package_ipa("BenchRunner", mobench_sdk::builders::SigningMethod::AdHoc) + .context("Failed to package iOS IPA for BrowserStack")?; + let test_suite = builder + .package_xcuitest("BenchRunner") + .context("Failed to package iOS XCUITest runner for BrowserStack")?; + Ok(IosXcuitestArtifacts { app, test_suite }) +} + +#[derive(Debug, Clone)] +struct ResolvedBrowserStack { + username: String, + access_key: String, + project: Option, +} + +fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path, test_apk: &Path) -> Result { + let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username.clone(), + access_key: creds.access_key.clone(), + }, + creds.project.clone(), + )?; + + // Upload the app-under-test APK. + let upload = client.upload_espresso_app(apk)?; + + // Upload the Espresso test-suite APK produced by Gradle. + let test_upload = client.upload_espresso_test_suite(test_apk)?; + + // Schedule the Espresso build with both app and testSuite, as required by BrowserStack. + let run = client.schedule_espresso_run( + &spec.devices, + &upload.app_url, + &test_upload.test_suite_url, + )?; + println!( + "Queued BrowserStack Espresso build {} for devices: {}", + run.build_id, + spec.devices.join(", ") + ); + + Ok(RemoteRun::Android { + app_url: upload.app_url, + build_id: run.build_id, + }) +} + +fn trigger_browserstack_xcuitest( + spec: &RunSpec, + artifacts: &IosXcuitestArtifacts, +) -> Result { + let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username.clone(), + access_key: creds.access_key.clone(), + }, + creds.project.clone(), + )?; + + if !artifacts.app.exists() { + bail!( + "iOS app artifact not found at {:?}; provide a .ipa or zipped .app", + artifacts.app + ); + } + if !artifacts.test_suite.exists() { + bail!( + "iOS XCUITest test suite artifact not found at {:?}; provide the zipped test runner bundle", + artifacts.test_suite + ); + } + + let app_upload = client.upload_xcuitest_app(&artifacts.app)?; + let test_upload = client.upload_xcuitest_test_suite(&artifacts.test_suite)?; + let run = client.schedule_xcuitest_run( + &spec.devices, + &app_upload.app_url, + &test_upload.test_suite_url, + )?; + println!( + "Queued BrowserStack XCUITest build {} for devices: {}", + run.build_id, + spec.devices.join(", ") + ); + + Ok(RemoteRun::Ios { + app_url: app_upload.app_url, + test_suite_url: test_upload.test_suite_url, + build_id: run.build_id, + }) +} + +fn resolve_browserstack_credentials( + config: Option<&BrowserStackConfig>, +) -> Result { + let mut username = None; + let mut access_key = None; + let mut project = None; + + if let Some(cfg) = config { + username = Some(expand_env_var(&cfg.app_automate_username)?); + access_key = Some(expand_env_var(&cfg.app_automate_access_key)?); + project = cfg + .project + .as_ref() + .map(|p| expand_env_var(p)) + .transpose()?; + } + + if username.as_deref().map(str::is_empty).unwrap_or(true) + && let Ok(val) = env::var("BROWSERSTACK_USERNAME") + && !val.is_empty() + { + username = Some(val); + } + if access_key.as_deref().map(str::is_empty).unwrap_or(true) + && let Ok(val) = env::var("BROWSERSTACK_ACCESS_KEY") + && !val.is_empty() + { + access_key = Some(val); + } + if project.is_none() + && let Ok(val) = env::var("BROWSERSTACK_PROJECT") + && !val.is_empty() + { + project = Some(val); + } + + let username = username.filter(|s| !s.is_empty()).ok_or_else(|| { + anyhow!("BrowserStack username missing; set BROWSERSTACK_USERNAME or provide in config") + })?; + let access_key = access_key.filter(|s| !s.is_empty()).ok_or_else(|| { + anyhow!("BrowserStack access key missing; set BROWSERSTACK_ACCESS_KEY or provide in config") + })?; + + Ok(ResolvedBrowserStack { + username, + access_key, + project, + }) +} + +fn expand_env_var(raw: &str) -> Result { + if let Some(stripped) = raw.strip_prefix("${").and_then(|s| s.strip_suffix('}')) { + let val = env::var(stripped) + .with_context(|| format!("resolving env var {stripped} for BrowserStack config"))?; + return Ok(val); + } + Ok(raw.to_string()) +} + +#[cfg(test)] +fn run_local_smoke(spec: &RunSpec) -> Result { + println!("Running local smoke test for {}...", spec.function); + + let bench_spec = mobench_sdk::BenchSpec { + name: spec.function.clone(), + iterations: spec.iterations, + warmup: spec.warmup, + }; + + let report = + mobench_sdk::run_benchmark(bench_spec).map_err(|e| anyhow!("benchmark failed: {:?}", e))?; + + serde_json::to_value(&report).context("serializing benchmark report") +} + +fn persist_mobile_spec(spec: &RunSpec) -> Result<()> { + let root = repo_root()?; + let payload = json!({ + "function": spec.function, + "iterations": spec.iterations, + "warmup": spec.warmup, + }); + let contents = serde_json::to_string_pretty(&payload)?; + let targets = [ + root.join("target/mobile-spec/android/bench_spec.json"), + root.join("target/mobile-spec/ios/bench_spec.json"), + ]; + for path in targets { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating directory {:?}", parent))?; + } + write_file(&path, contents.as_bytes())?; + } + Ok(()) +} + +#[derive(Debug)] +struct SummaryPaths { + json: PathBuf, + markdown: PathBuf, + csv: PathBuf, +} + +fn resolve_summary_paths(output: Option<&Path>) -> Result { + let json = output + .map(ToOwned::to_owned) + .unwrap_or_else(|| PathBuf::from("run-summary.json")); + let markdown = json.with_extension("md"); + let csv = json.with_extension("csv"); + Ok(SummaryPaths { + json, + markdown, + csv, + }) +} + +fn empty_summary(spec: &RunSpec) -> SummaryReport { + SummaryReport { + generated_at: "pending".to_string(), + generated_at_unix: 0, + target: spec.target, + function: spec.function.clone(), + iterations: spec.iterations, + warmup: spec.warmup, + devices: spec.devices.clone(), + device_summaries: Vec::new(), + } +} + +fn build_summary(run_summary: &RunSummary) -> Result { + let generated_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("generating timestamp")? + .as_secs(); + let generated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| generated_at_unix.to_string()); + + let mut device_summaries = Vec::new(); + + if let Some(results) = &run_summary.benchmark_results { + for (device, entries) in results { + let mut benchmarks = Vec::new(); + for entry in entries { + let function = entry + .get("function") + .and_then(|f| f.as_str()) + .unwrap_or("unknown") + .to_string(); + let samples = extract_samples(entry); + let stats = compute_sample_stats(&samples); + let mean_ns = stats + .as_ref() + .map(|s| s.mean_ns) + .or_else(|| entry.get("mean_ns").and_then(|m| m.as_u64())); + + benchmarks.push(BenchmarkStats { + function, + samples: samples.len(), + mean_ns, + median_ns: stats.as_ref().map(|s| s.median_ns), + p95_ns: stats.as_ref().map(|s| s.p95_ns), + min_ns: stats.as_ref().map(|s| s.min_ns), + max_ns: stats.as_ref().map(|s| s.max_ns), + }); + } + + benchmarks.sort_by(|a, b| a.function.cmp(&b.function)); + device_summaries.push(DeviceSummary { + device: device.clone(), + benchmarks, + }); + } + } + + if device_summaries.is_empty() + && let Some(local_summary) = summarize_local_report(run_summary) + { + device_summaries.push(local_summary); + } + + Ok(SummaryReport { + generated_at, + generated_at_unix, + target: run_summary.spec.target, + function: run_summary.spec.function.clone(), + iterations: run_summary.spec.iterations, + warmup: run_summary.spec.warmup, + devices: run_summary.spec.devices.clone(), + device_summaries, + }) +} + +fn write_summary(summary: &RunSummary, paths: &SummaryPaths, summary_csv: bool) -> Result<()> { + let json = serde_json::to_string_pretty(summary)?; + ensure_parent_dir(&paths.json)?; + write_file(&paths.json, json.as_bytes())?; + println!("Wrote run summary to {:?}", paths.json); + + let markdown = render_markdown_summary(&summary.summary); + ensure_parent_dir(&paths.markdown)?; + write_file(&paths.markdown, markdown.as_bytes())?; + println!("Wrote markdown summary to {:?}", paths.markdown); + + if summary_csv { + let csv = render_csv_summary(&summary.summary); + ensure_parent_dir(&paths.csv)?; + write_file(&paths.csv, csv.as_bytes())?; + println!("Wrote CSV summary to {:?}", paths.csv); + } + Ok(()) +} + +fn ensure_parent_dir(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent).with_context(|| format!("creating directory {:?}", parent))?; + } + Ok(()) +} + +#[derive(Debug)] +struct CompareReport { + baseline: PathBuf, + candidate: PathBuf, + rows: Vec, +} + +#[derive(Debug)] +struct CompareRow { + device: String, + function: String, + baseline_median_ns: Option, + candidate_median_ns: Option, + median_delta_pct: Option, + baseline_p95_ns: Option, + candidate_p95_ns: Option, + p95_delta_pct: Option, +} + +fn compare_summaries(baseline: &Path, candidate: &Path) -> Result { + let baseline_summary = load_run_summary(baseline)?; + let candidate_summary = load_run_summary(candidate)?; + + let baseline_map = summary_lookup(&baseline_summary.summary); + let candidate_map = summary_lookup(&candidate_summary.summary); + + let mut rows = Vec::new(); + let mut devices: BTreeMap = BTreeMap::new(); + devices.extend(baseline_map.keys().map(|k| (k.clone(), ()))); + devices.extend(candidate_map.keys().map(|k| (k.clone(), ()))); + + for device in devices.keys() { + let mut functions: BTreeMap = BTreeMap::new(); + if let Some(entry) = baseline_map.get(device) { + functions.extend(entry.keys().map(|k| (k.clone(), ()))); + } + if let Some(entry) = candidate_map.get(device) { + functions.extend(entry.keys().map(|k| (k.clone(), ()))); + } + + for function in functions.keys() { + let baseline_stats = baseline_map + .get(device) + .and_then(|entry| entry.get(function)); + let candidate_stats = candidate_map + .get(device) + .and_then(|entry| entry.get(function)); + + let baseline_median = baseline_stats.and_then(|s| s.median_ns); + let candidate_median = candidate_stats.and_then(|s| s.median_ns); + let median_delta = percent_delta(baseline_median, candidate_median); + + let baseline_p95 = baseline_stats.and_then(|s| s.p95_ns); + let candidate_p95 = candidate_stats.and_then(|s| s.p95_ns); + let p95_delta = percent_delta(baseline_p95, candidate_p95); + + rows.push(CompareRow { + device: device.clone(), + function: function.clone(), + baseline_median_ns: baseline_median, + candidate_median_ns: candidate_median, + median_delta_pct: median_delta, + baseline_p95_ns: baseline_p95, + candidate_p95_ns: candidate_p95, + p95_delta_pct: p95_delta, + }); + } + } + + Ok(CompareReport { + baseline: baseline.to_path_buf(), + candidate: candidate.to_path_buf(), + rows, + }) +} + +fn load_run_summary(path: &Path) -> Result { + let contents = fs::read_to_string(path).with_context(|| format!("reading {:?}", path))?; + serde_json::from_str(&contents).with_context(|| format!("parsing summary {:?}", path)) +} + +fn summary_lookup(summary: &SummaryReport) -> BTreeMap> { + let mut map = BTreeMap::new(); + for device in &summary.device_summaries { + let mut functions = BTreeMap::new(); + for bench in &device.benchmarks { + functions.insert(bench.function.clone(), bench.clone()); + } + map.insert(device.device.clone(), functions); + } + map +} + +fn percent_delta(baseline: Option, candidate: Option) -> Option { + let baseline = baseline? as f64; + let candidate = candidate? as f64; + if baseline == 0.0 { + return None; + } + Some(((candidate - baseline) / baseline) * 100.0) +} + +fn write_compare_report(report: &CompareReport, output: Option<&Path>) -> Result<()> { + let markdown = render_compare_markdown(report); + if let Some(path) = output { + ensure_parent_dir(path)?; + write_file(path, markdown.as_bytes())?; + println!("Wrote compare report to {:?}", path); + } else { + println!("{markdown}"); + } + Ok(()) +} + +fn render_compare_markdown(report: &CompareReport) -> String { + let mut output = String::new(); + let _ = writeln!(output, "# Benchmark Comparison"); + let _ = writeln!(output); + let _ = writeln!(output, "- Baseline: {}", report.baseline.display()); + let _ = writeln!(output, "- Candidate: {}", report.candidate.display()); + let _ = writeln!(output); + let _ = writeln!( + output, + "| Device | Function | Median (base ms) | Median (cand ms) | Median Δ% | P95 (base ms) | P95 (cand ms) | P95 Δ% |" + ); + let _ = writeln!( + output, + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |" + ); + for row in &report.rows { + let _ = writeln!( + output, + "| {} | {} | {} | {} | {} | {} | {} | {} |", + row.device, + row.function, + format_ms(row.baseline_median_ns), + format_ms(row.candidate_median_ns), + format_delta(row.median_delta_pct), + format_ms(row.baseline_p95_ns), + format_ms(row.candidate_p95_ns), + format_delta(row.p95_delta_pct) + ); + } + output +} + +fn format_delta(value: Option) -> String { + value + .map(|delta| format!("{:+.2}%", delta)) + .unwrap_or_else(|| "-".to_string()) +} + +fn summarize_local_report(run_summary: &RunSummary) -> Option { + let samples = extract_samples(&run_summary.local_report); + if samples.is_empty() { + return None; + } + let stats = compute_sample_stats(&samples)?; + let function = run_summary + .local_report + .get("spec") + .and_then(|spec| spec.get("name")) + .and_then(|name| name.as_str()) + .unwrap_or(&run_summary.spec.function) + .to_string(); + + Some(DeviceSummary { + device: "local".to_string(), + benchmarks: vec![BenchmarkStats { + function, + samples: samples.len(), + mean_ns: Some(stats.mean_ns), + median_ns: Some(stats.median_ns), + p95_ns: Some(stats.p95_ns), + min_ns: Some(stats.min_ns), + max_ns: Some(stats.max_ns), + }], + }) +} + +#[derive(Clone, Debug)] +struct SampleStats { + mean_ns: u64, + median_ns: u64, + p95_ns: u64, + min_ns: u64, + max_ns: u64, +} + +fn compute_sample_stats(samples: &[u64]) -> Option { + if samples.is_empty() { + return None; + } + + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + let len = sorted.len(); + + let mean_ns = (sorted.iter().map(|v| *v as u128).sum::() / len as u128) as u64; + let median_ns = if len % 2 == 1 { + sorted[len / 2] + } else { + let lower = sorted[(len / 2) - 1]; + let upper = sorted[len / 2]; + (lower + upper) / 2 + }; + let p95_index = percentile_index(len, 0.95); + let p95_ns = sorted[p95_index]; + let min_ns = sorted[0]; + let max_ns = sorted[len - 1]; + + Some(SampleStats { + mean_ns, + median_ns, + p95_ns, + min_ns, + max_ns, + }) +} + +fn percentile_index(len: usize, percentile: f64) -> usize { + if len == 0 { + return 0; + } + let rank = (percentile * len as f64).ceil() as usize; + let index = rank.saturating_sub(1); + index.min(len - 1) +} + +fn extract_samples(value: &Value) -> Vec { + let Some(samples) = value.get("samples").and_then(|s| s.as_array()) else { + return Vec::new(); + }; + let mut durations = Vec::with_capacity(samples.len()); + for sample in samples { + if let Some(duration) = sample + .get("duration_ns") + .and_then(|duration| duration.as_u64()) + { + durations.push(duration); + } else if let Some(duration) = sample.as_u64() { + durations.push(duration); + } + } + durations +} + +fn render_markdown_summary(summary: &SummaryReport) -> String { + let mut output = String::new(); + let devices = if summary.devices.is_empty() { + "none".to_string() + } else { + summary.devices.join(", ") + }; + + let _ = writeln!(output, "# Benchmark Summary"); + let _ = writeln!(output); + let _ = writeln!(output, "- Generated: {}", summary.generated_at); + let _ = writeln!(output, "- Target: {:?}", summary.target); + let _ = writeln!(output, "- Function: {}", summary.function); + let _ = writeln!( + output, + "- Iterations/Warmup: {} / {}", + summary.iterations, summary.warmup + ); + let _ = writeln!(output, "- Devices: {}", devices); + let _ = writeln!(output); + + if summary.device_summaries.is_empty() { + let _ = writeln!(output, "No benchmark samples were collected."); + return output; + } + + for device in &summary.device_summaries { + let _ = writeln!(output, "## Device: {}", device.device); + let _ = writeln!(output); + let _ = writeln!( + output, + "| Function | Samples | Mean (ms) | Median (ms) | P95 (ms) | Min (ms) | Max (ms) |" + ); + let _ = writeln!(output, "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"); + for bench in &device.benchmarks { + let _ = writeln!( + output, + "| {} | {} | {} | {} | {} | {} | {} |", + bench.function, + bench.samples, + format_ms(bench.mean_ns), + format_ms(bench.median_ns), + format_ms(bench.p95_ns), + format_ms(bench.min_ns), + format_ms(bench.max_ns) + ); + } + let _ = writeln!(output); + } + + output +} + +fn render_csv_summary(summary: &SummaryReport) -> String { + let mut output = String::new(); + let _ = writeln!( + output, + "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns" + ); + for device in &summary.device_summaries { + for bench in &device.benchmarks { + let _ = writeln!( + output, + "{},{},{},{},{},{},{},{}", + device.device, + bench.function, + bench.samples, + bench.mean_ns.map_or(String::from(""), |v| v.to_string()), + bench.median_ns.map_or(String::from(""), |v| v.to_string()), + bench.p95_ns.map_or(String::from(""), |v| v.to_string()), + bench.min_ns.map_or(String::from(""), |v| v.to_string()), + bench.max_ns.map_or(String::from(""), |v| v.to_string()) + ); + } + } + output +} + +fn format_ms(value: Option) -> String { + value + .map(|ns| format!("{:.3}", ns as f64 / 1_000_000.0)) + .unwrap_or_else(|| "-".to_string()) +} + +fn run_android_build(_ndk_home: &str) -> Result { + let root = repo_root()?; + let crate_name = + detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); + + let cfg = mobench_sdk::BuildConfig { + target: mobench_sdk::Target::Android, + profile: mobench_sdk::BuildProfile::Debug, + incremental: true, + }; + let builder = mobench_sdk::builders::AndroidBuilder::new(&root, crate_name).verbose(true); + let result = builder.build(&cfg)?; + Ok(result) +} + +fn load_dotenv() { + if let Ok(root) = repo_root() { + let path = root.join(".env.local"); + let _ = dotenvy::from_path(path); + } +} + +fn repo_root() -> Result { + // Prefer the build-time repo root but fall back to the current directory for installed binaries. + let compiled = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(".."); + if let Ok(path) = compiled.canonicalize() { + return Ok(path); + } + std::env::current_dir().context("resolving repo root from current directory") +} + +fn ensure_can_write(path: &Path) -> Result<()> { + if path.exists() { + bail!("refusing to overwrite existing file: {:?}", path); + } + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent) + .with_context(|| format!("creating parent directory {:?}", parent))?; + } + Ok(()) +} + +fn write_file(path: &Path, contents: &[u8]) -> Result<()> { + fs::write(path, contents).with_context(|| format!("writing file {:?}", path)) +} + +/// Initialize a new benchmark project using mobench-sdk (Phase 1 MVP) +fn cmd_init_sdk( + target: SdkTarget, + project_name: String, + output_dir: PathBuf, + generate_examples: bool, +) -> Result<()> { + println!("Initializing benchmark project with mobench-sdk..."); + println!(" Project name: {}", project_name); + println!(" Target: {:?}", target); + println!(" Output directory: {:?}", output_dir); + + let config = mobench_sdk::InitConfig { + target: target.into(), + project_name: project_name.clone(), + output_dir: output_dir.clone(), + generate_examples, + }; + + mobench_sdk::codegen::generate_project(&config).context("Failed to generate project")?; + + println!("\n✓ Project initialized successfully!"); + println!("\nNext steps:"); + println!(" 1. Add benchmark functions to your code with #[benchmark]"); + println!(" 2. Run 'cargo build --target ' to build"); + println!(" 3. Run benchmarks with 'cargo mobench build --target '"); + + Ok(()) +} + +/// Build mobile artifacts using mobench-sdk (Phase 1 MVP) +fn cmd_build(target: SdkTarget, release: bool) -> Result<()> { + println!("Building mobile artifacts..."); + println!(" Target: {:?}", target); + println!(" Profile: {}", if release { "release" } else { "debug" }); + + let project_root = std::env::current_dir().context("Failed to get current directory")?; + let crate_name = detect_bench_mobile_crate_name(&project_root) + .unwrap_or_else(|_| "bench-mobile".to_string()); // Fallback for legacy layouts + + let build_config = mobench_sdk::BuildConfig { + target: target.into(), + profile: if release { + mobench_sdk::BuildProfile::Release + } else { + mobench_sdk::BuildProfile::Debug + }, + incremental: true, + }; + + match target { + SdkTarget::Android => { + let builder = + mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) + .verbose(true); + let result = builder.build(&build_config)?; + println!("\n✓ Android build completed!"); + println!(" APK: {:?}", result.app_path); + } + SdkTarget::Ios => { + let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name.clone()) + .verbose(true); + let result = builder.build(&build_config)?; + println!("\n✓ iOS build completed!"); + println!(" Framework: {:?}", result.app_path); + } + SdkTarget::Both => { + // Build Android + let android_builder = + mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) + .verbose(true); + let android_result = android_builder.build(&build_config)?; + println!("\n✓ Android build completed!"); + println!(" APK: {:?}", android_result.app_path); + + // Build iOS + let ios_builder = + mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + let ios_result = ios_builder.build(&build_config)?; + println!("\n✓ iOS build completed!"); + println!(" Framework: {:?}", ios_result.app_path); + } + } + + Ok(()) +} + +fn detect_bench_mobile_crate_name(root: &Path) -> Result { + // Try bench-mobile/ first (SDK projects) + let bench_mobile_path = root.join("bench-mobile").join("Cargo.toml"); + if bench_mobile_path.exists() { + let contents = fs::read_to_string(&bench_mobile_path) + .with_context(|| format!("reading bench-mobile manifest at {:?}", bench_mobile_path))?; + let value: toml::Value = toml::from_str(&contents) + .with_context(|| format!("parsing bench-mobile manifest {:?}", bench_mobile_path))?; + let name = value + .get("package") + .and_then(|pkg| pkg.get("name")) + .and_then(|n| n.as_str()) + .ok_or_else(|| { + anyhow!( + "bench-mobile package.name missing in {:?}", + bench_mobile_path + ) + })?; + return Ok(name.to_string()); + } + + // Fallback: Try crates/sample-fns (repository testing) + let sample_fns_path = root.join("crates").join("sample-fns").join("Cargo.toml"); + if sample_fns_path.exists() { + let contents = fs::read_to_string(&sample_fns_path) + .with_context(|| format!("reading sample-fns manifest at {:?}", sample_fns_path))?; + let value: toml::Value = toml::from_str(&contents) + .with_context(|| format!("parsing sample-fns manifest {:?}", sample_fns_path))?; + let name = value + .get("package") + .and_then(|pkg| pkg.get("name")) + .and_then(|n| n.as_str()) + .ok_or_else(|| anyhow!("sample-fns package.name missing in {:?}", sample_fns_path))?; + return Ok(name.to_string()); + } + + bail!("No benchmark crate found. Expected 'bench-mobile/' or 'crates/sample-fns/'") +} + +/// List all discovered benchmark functions (Phase 1 MVP) +fn cmd_list() -> Result<()> { + println!("Discovering benchmark functions...\n"); + + let benchmarks = mobench_sdk::discover_benchmarks(); + + if benchmarks.is_empty() { + println!("No benchmarks found."); + println!("\nTo add benchmarks:"); + println!(" 1. Add #[benchmark] attribute to functions"); + println!(" 2. Make sure mobench-sdk is in your dependencies"); + println!(" 3. Rebuild your project"); + } else { + println!("Found {} benchmark(s):", benchmarks.len()); + for bench in benchmarks { + println!(" - {}", bench.name); + } + } + + Ok(()) +} + +/// Package iOS app as IPA for distribution or testing +fn cmd_package_ipa(scheme: &str, method: IosSigningMethodArg) -> Result<()> { + println!("Packaging iOS app as IPA..."); + println!(" Scheme: {}", scheme); + println!(" Method: {:?}", method); + + let project_root = repo_root()?; + let crate_name = detect_bench_mobile_crate_name(&project_root) + .unwrap_or_else(|_| "bench-mobile".to_string()); + + let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + + let signing_method: mobench_sdk::builders::SigningMethod = method.into(); + let ipa_path = builder + .package_ipa(scheme, signing_method) + .context("Failed to package IPA")?; + + println!("\n✓ IPA packaged successfully!"); + println!(" Path: {:?}", ipa_path); + println!("\nYou can now:"); + println!(" - Install on device: Use Xcode or ios-deploy"); + println!( + " - Test on BrowserStack: cargo mobench run --target ios --ios-app {:?}", + ipa_path + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Register a lightweight benchmark for tests so the inventory contains at least one entry. + #[mobench_sdk::benchmark] + fn noop_benchmark() { + std::hint::black_box(1u8); + } + + #[test] + fn resolves_cli_spec() { + let spec = resolve_run_spec( + MobileTarget::Android, + "sample_fns::fibonacci".into(), + 5, + 1, + vec!["pixel".into()], + None, + None, + None, + false, + ) + .unwrap(); + assert_eq!(spec.function, "sample_fns::fibonacci"); + assert_eq!(spec.iterations, 5); + assert_eq!(spec.warmup, 1); + assert_eq!(spec.devices, vec!["pixel".to_string()]); + assert!(spec.browserstack.is_none()); + assert!(spec.ios_xcuitest.is_none()); + } + + #[test] + fn local_smoke_produces_samples() { + let spec = RunSpec { + target: MobileTarget::Android, + function: "noop_benchmark".into(), + iterations: 3, + warmup: 1, + devices: vec![], + browserstack: None, + ios_xcuitest: None, + }; + let report = run_local_smoke(&spec).expect("local harness"); + assert!(report["samples"].is_array()); + assert_eq!(report["spec"]["name"], "noop_benchmark"); + } + + #[test] + fn ios_requires_artifacts_for_browserstack() { + let spec = resolve_run_spec( + MobileTarget::Ios, + "sample_fns::fibonacci".into(), + 1, + 0, + vec!["iphone".into()], + None, + None, + None, + false, + ) + .expect("should auto-package iOS artifacts when missing"); + let ios_artifacts = spec + .ios_xcuitest + .expect("iOS artifacts should be populated"); + assert!(ios_artifacts.app.exists(), "iOS app artifact missing"); + assert!( + ios_artifacts.test_suite.exists(), + "iOS test suite artifact missing" + ); + } +} diff --git a/crates/mobench/src/main.rs b/crates/mobench/src/main.rs index f1d536e..f5e8e59 100644 --- a/crates/mobench/src/main.rs +++ b/crates/mobench/src/main.rs @@ -1,1961 +1,6 @@ -use anyhow::{Context, Result, anyhow, bail}; -use clap::{Parser, Subcommand, ValueEnum}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::collections::BTreeMap; -use std::env; -use std::fmt::Write; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use time::OffsetDateTime; -use time::format_description::well_known::Rfc3339; - -use browserstack::{BrowserStackAuth, BrowserStackClient}; - -mod browserstack; - -/// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. -#[derive(Parser, Debug)] -#[command(name = "mobench", author, version, about = "Mobile Rust benchmarking orchestrator", long_about = None)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// Run a benchmark against a target platform (mobile integration stub for now). - Run { - #[arg(long, value_enum)] - target: MobileTarget, - #[arg(long, help = "Fully-qualified Rust function to benchmark")] - function: String, - #[arg(long, default_value_t = 100)] - iterations: u32, - #[arg(long, default_value_t = 10)] - warmup: u32, - #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] - devices: Vec, - #[arg(long, help = "Optional path to config file")] - config: Option, - #[arg(long, help = "Optional output path for JSON report")] - output: Option, - #[arg(long, help = "Write CSV summary alongside JSON")] - summary_csv: bool, - #[arg(long, help = "Skip mobile builds and only run the host harness")] - local_only: bool, - #[arg( - long, - help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" - )] - ios_app: Option, - #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] - ios_test_suite: Option, - #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] - fetch: bool, - #[arg(long, default_value = "target/browserstack")] - fetch_output_dir: PathBuf, - #[arg(long, default_value_t = 5)] - fetch_poll_interval_secs: u64, - #[arg(long, default_value_t = 300)] - fetch_timeout_secs: u64, - }, - /// Scaffold a base config file for the CLI. - Init { - #[arg(long, default_value = "bench-config.toml")] - output: PathBuf, - #[arg(long, value_enum, default_value_t = MobileTarget::Android)] - target: MobileTarget, - }, - /// Generate a sample device matrix file. - Plan { - #[arg(long, default_value = "device-matrix.yaml")] - output: PathBuf, - }, - /// Fetch BrowserStack build artifacts (logs, session JSON) for CI. - Fetch { - #[arg(long, value_enum)] - target: MobileTarget, - #[arg(long)] - build_id: String, - #[arg(long, default_value = "target/browserstack")] - output_dir: PathBuf, - #[arg(long, default_value_t = true)] - wait: bool, - #[arg(long, default_value_t = 10)] - poll_interval_secs: u64, - #[arg(long, default_value_t = 1800)] - timeout_secs: u64, - }, - /// Compare two run summaries for regressions. - Compare { - #[arg(long, help = "Baseline JSON summary to compare against")] - baseline: PathBuf, - #[arg(long, help = "Candidate JSON summary to compare")] - candidate: PathBuf, - #[arg(long, help = "Optional output path for markdown report")] - output: Option, - }, - /// Initialize a new benchmark project with SDK (Phase 1 MVP). - InitSdk { - #[arg(long, value_enum)] - target: SdkTarget, - #[arg(long, default_value = "bench-project")] - project_name: String, - #[arg(long, default_value = ".")] - output_dir: PathBuf, - #[arg(long, help = "Generate example benchmarks")] - examples: bool, - }, - /// Build mobile artifacts (Phase 1 MVP). - Build { - #[arg(long, value_enum)] - target: SdkTarget, - #[arg(long, help = "Build in release mode")] - release: bool, - }, - /// Package iOS app as IPA for distribution or testing. - PackageIpa { - #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] - scheme: String, - #[arg(long, value_enum, default_value = "adhoc", help = "Signing method")] - method: IosSigningMethodArg, - }, - /// List all discovered benchmark functions (Phase 1 MVP). - List, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -enum MobileTarget { - Android, - Ios, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] -#[clap(rename_all = "lowercase")] -enum SdkTarget { - Android, - Ios, - Both, -} - -impl From for mobench_sdk::Target { - fn from(target: SdkTarget) -> Self { - match target { - SdkTarget::Android => mobench_sdk::Target::Android, - SdkTarget::Ios => mobench_sdk::Target::Ios, - SdkTarget::Both => mobench_sdk::Target::Both, - } - } -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] -#[clap(rename_all = "lowercase")] -enum IosSigningMethodArg { - /// Ad-hoc signing (no Apple ID needed, works for BrowserStack) - Adhoc, - /// Development signing (requires Apple Developer account) - Development, -} - -impl From for mobench_sdk::builders::SigningMethod { - fn from(arg: IosSigningMethodArg) -> Self { - match arg { - IosSigningMethodArg::Adhoc => mobench_sdk::builders::SigningMethod::AdHoc, - IosSigningMethodArg::Development => mobench_sdk::builders::SigningMethod::Development, - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct BrowserStackConfig { - app_automate_username: String, - app_automate_access_key: String, - project: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct IosXcuitestArtifacts { - app: PathBuf, - test_suite: PathBuf, -} - -#[derive(Debug, Serialize, Deserialize)] -struct BenchConfig { - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, - device_matrix: PathBuf, - #[serde(default, skip_serializing_if = "Option::is_none")] - device_tags: Option>, - browserstack: BrowserStackConfig, - #[serde(skip_serializing_if = "Option::is_none", default)] - ios_xcuitest: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct DeviceEntry { - name: String, - os: String, - os_version: String, - tags: Option>, -} - -#[derive(Debug, Serialize, Deserialize)] -struct DeviceMatrix { - devices: Vec, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct RunSpec { - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, - devices: Vec, - #[serde(skip_serializing, skip_deserializing, default)] - browserstack: Option, - #[serde(skip_serializing_if = "Option::is_none", default)] - ios_xcuitest: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "platform", rename_all = "lowercase")] -enum MobileArtifacts { - Android { - apk: PathBuf, - }, - Ios { - xcframework: PathBuf, - header: PathBuf, - #[serde(skip_serializing_if = "Option::is_none")] - app: Option, - #[serde(skip_serializing_if = "Option::is_none")] - test_suite: Option, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -struct RunSummary { - spec: RunSpec, - artifacts: Option, - local_report: Value, - remote_run: Option, - summary: SummaryReport, - #[serde(skip_serializing_if = "Option::is_none")] - benchmark_results: Option>>, - #[serde(skip_serializing_if = "Option::is_none")] - performance_metrics: Option>, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct SummaryReport { - generated_at: String, - generated_at_unix: u64, - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, - devices: Vec, - device_summaries: Vec, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct DeviceSummary { - device: String, - benchmarks: Vec, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct BenchmarkStats { - function: String, - samples: usize, - mean_ns: Option, - median_ns: Option, - p95_ns: Option, - min_ns: Option, - max_ns: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "platform", rename_all = "lowercase")] -enum RemoteRun { - Android { - app_url: String, - build_id: String, - }, - Ios { - app_url: String, - test_suite_url: String, - build_id: String, - }, -} - -fn main() -> Result<()> { - load_dotenv(); - let cli = Cli::parse(); - match cli.command { - Command::Run { - target, - function, - iterations, - warmup, - devices, - config, - output, - summary_csv, - local_only, - ios_app, - ios_test_suite, - fetch, - fetch_output_dir, - fetch_poll_interval_secs, - fetch_timeout_secs, - } => { - let spec = resolve_run_spec( - target, - function, - iterations, - warmup, - devices, - config.as_deref(), - ios_app, - ios_test_suite, - local_only, - )?; - let summary_paths = resolve_summary_paths(output.as_deref())?; - println!( - "Preparing benchmark run for {:?}: {} (iterations={}, warmup={})", - spec.target, spec.function, spec.iterations, spec.warmup - ); - persist_mobile_spec(&spec)?; - if !spec.devices.is_empty() { - println!("Devices: {}", spec.devices.join(", ")); - } - println!("JSON summary will be written to {:?}", summary_paths.json); - println!( - "Markdown summary will be written to {:?}", - summary_paths.markdown - ); - if summary_csv { - println!("CSV summary will be written to {:?}", summary_paths.csv); - } - - // Skip local smoke test - sample-fns uses direct dispatch, not inventory registry - // Benchmarks will run on the actual mobile device - println!("Skipping local smoke test - benchmarks will run on mobile device"); - let local_report = json!({ - "skipped": true, - "reason": "Local smoke test disabled - benchmarks run on mobile device only" - }); - let mut remote_run = None; - let artifacts = if local_only { - println!("Skipping mobile build: --local-only set"); - None - } else { - match spec.target { - MobileTarget::Android => { - let ndk = std::env::var("ANDROID_NDK_HOME") - .context("ANDROID_NDK_HOME must be set for Android builds")?; - let build = run_android_build(&ndk)?; - let apk = build.app_path; - println!("Built Android APK at {:?}", apk); - if spec.devices.is_empty() { - println!("Skipping BrowserStack upload/run: no devices provided"); - Some(MobileArtifacts::Android { apk }) - } else { - let test_apk = build.test_suite_path.as_ref().context( - "Android test suite APK missing; run ./gradlew assembleDebugAndroidTest", - )?; - let run = trigger_browserstack_espresso(&spec, &apk, test_apk)?; - remote_run = Some(run); - Some(MobileArtifacts::Android { apk }) - } - } - MobileTarget::Ios => { - let (xcframework, header) = run_ios_build()?; - println!("Built iOS xcframework at {:?}", xcframework); - let ios_xcuitest = spec.ios_xcuitest.clone(); - - if spec.devices.is_empty() { - println!("Skipping BrowserStack upload/run: no devices provided"); - } else { - let xcui = spec.ios_xcuitest.as_ref().context( - "iOS XCUITest artifacts required when targeting BrowserStack devices; provide --ios-app and --ios-test-suite or set ios_xcuitest in the config", - )?; - let run = trigger_browserstack_xcuitest(&spec, xcui)?; - remote_run = Some(run); - } - - Some(MobileArtifacts::Ios { - xcframework, - header, - app: ios_xcuitest.as_ref().map(|a| a.app.clone()), - test_suite: ios_xcuitest.map(|a| a.test_suite), - }) - } - } - }; - - let summary_placeholder = empty_summary(&spec); - let mut run_summary = RunSummary { - spec, - artifacts, - local_report, - remote_run, - summary: summary_placeholder, - benchmark_results: None, - performance_metrics: None, - }; - - if fetch && let Some(remote) = &run_summary.remote_run { - let build_id = match remote { - RemoteRun::Android { build_id, .. } => build_id, - RemoteRun::Ios { build_id, .. } => build_id, - }; - let creds = - resolve_browserstack_credentials(run_summary.spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username, - access_key: creds.access_key, - }, - creds.project, - )?; - - let platform = match run_summary.spec.target { - MobileTarget::Android => "espresso", - MobileTarget::Ios => "xcuitest", - }; - - let dashboard_url = format!( - "https://app-automate.browserstack.com/dashboard/v2/builds/{}", - build_id - ); - - println!("Waiting for build {} to complete...", build_id); - println!("Dashboard: {}", dashboard_url); - - match client.wait_and_fetch_all_results_with_poll( - build_id, - platform, - Some(fetch_timeout_secs), - Some(fetch_poll_interval_secs), - ) { - Ok((bench_results, perf_metrics)) => { - println!( - "\n✓ Successfully fetched results from {} device(s)", - bench_results.len() - ); - - // Print summary of benchmark results - for (device, results) in &bench_results { - println!("\n Device: {}", device); - for (idx, result) in results.iter().enumerate() { - if let Some(function) = - result.get("function").and_then(|f| f.as_str()) - { - println!(" Benchmark {}: {}", idx + 1, function); - } - if let Some(mean) = result.get("mean_ns").and_then(|m| m.as_u64()) { - println!( - " Mean: {} ns ({:.2} ms)", - mean, - mean as f64 / 1_000_000.0 - ); - } - if let Some(samples) = - result.get("samples").and_then(|s| s.as_array()) - { - println!(" Samples: {}", samples.len()); - } - } - - // Print performance metrics if available - if let Some(metrics) = - perf_metrics.get(device).filter(|m| m.sample_count > 0) - { - println!("\n Performance Metrics:"); - if let Some(mem) = &metrics.memory { - println!(" Memory:"); - println!(" Peak: {:.2} MB", mem.peak_mb); - println!(" Average: {:.2} MB", mem.average_mb); - } - if let Some(cpu) = &metrics.cpu { - println!(" CPU:"); - println!(" Peak: {:.1}%", cpu.peak_percent); - println!(" Average: {:.1}%", cpu.average_percent); - } - } - } - - println!("\n View full results: {}", dashboard_url); - run_summary.benchmark_results = Some(bench_results.into_iter().collect()); - run_summary.performance_metrics = Some(perf_metrics.into_iter().collect()); - } - Err(e) => { - println!("\nWarning: Failed to fetch results: {}", e); - println!("Build may still be accessible at: {}", dashboard_url); - } - } - - // Also save detailed artifacts to separate directory - let output_root = fetch_output_dir.join(build_id); - if let Err(e) = fetch_browserstack_artifacts( - &client, - run_summary.spec.target, - build_id, - &output_root, - false, // Don't wait again, we already did - fetch_poll_interval_secs, - fetch_timeout_secs, - ) { - println!("Warning: Failed to fetch detailed artifacts: {}", e); - } - } else if fetch { - println!("No BrowserStack run to fetch (devices not provided?)"); - } - - run_summary.summary = build_summary(&run_summary)?; - write_summary(&run_summary, &summary_paths, summary_csv)?; - } - Command::Init { output, target } => { - write_config_template(&output, target)?; - println!("Wrote starter config to {:?}", output); - } - Command::Plan { output } => { - write_device_matrix_template(&output)?; - println!("Wrote sample device matrix to {:?}", output); - } - Command::Fetch { - target, - build_id, - output_dir, - wait, - poll_interval_secs, - timeout_secs, - } => { - let creds = resolve_browserstack_credentials(None)?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username, - access_key: creds.access_key, - }, - creds.project, - )?; - let output_root = output_dir.join(&build_id); - fetch_browserstack_artifacts( - &client, - target, - &build_id, - &output_root, - wait, - poll_interval_secs, - timeout_secs, - )?; - } - Command::Compare { - baseline, - candidate, - output, - } => { - let report = compare_summaries(&baseline, &candidate)?; - write_compare_report(&report, output.as_deref())?; - } - Command::InitSdk { - target, - project_name, - output_dir, - examples, - } => { - cmd_init_sdk(target, project_name, output_dir, examples)?; - } - Command::Build { target, release } => { - cmd_build(target, release)?; - } - Command::PackageIpa { scheme, method } => { - cmd_package_ipa(&scheme, method)?; - } - Command::List => { - cmd_list()?; - } - } - - Ok(()) -} - -fn write_config_template(path: &Path, target: MobileTarget) -> Result<()> { - ensure_can_write(path)?; - - let ios_xcuitest = if target == MobileTarget::Ios { - Some(IosXcuitestArtifacts { - app: PathBuf::from("target/ios/BenchRunner.ipa"), - test_suite: PathBuf::from("target/ios/BenchRunnerUITests.zip"), - }) - } else { - None - }; - - let cfg = BenchConfig { - target, - function: "sample_fns::fibonacci".into(), - iterations: 100, - warmup: 10, - device_matrix: PathBuf::from("device-matrix.yaml"), - device_tags: Some(vec!["default".into()]), - browserstack: BrowserStackConfig { - app_automate_username: "${BROWSERSTACK_USERNAME}".into(), - app_automate_access_key: "${BROWSERSTACK_ACCESS_KEY}".into(), - project: Some("mobile-bench-rs".into()), - }, - ios_xcuitest, - }; - - let contents = toml::to_string_pretty(&cfg)?; - write_file(path, contents.as_bytes()) -} - -fn write_device_matrix_template(path: &Path) -> Result<()> { - ensure_can_write(path)?; - - let matrix = DeviceMatrix { - devices: vec![ - DeviceEntry { - name: "Pixel 7".into(), - os: "android".into(), - os_version: "13.0".into(), - tags: Some(vec!["default".into(), "pixel".into()]), - }, - DeviceEntry { - name: "iPhone 14".into(), - os: "ios".into(), - os_version: "16".into(), - tags: Some(vec!["default".into(), "iphone".into()]), - }, - ], - }; - - let contents = serde_yaml::to_string(&matrix)?; - write_file(path, contents.as_bytes()) -} - -fn fetch_browserstack_artifacts( - client: &BrowserStackClient, - target: MobileTarget, - build_id: &str, - output_root: &Path, - wait: bool, - poll_interval_secs: u64, - timeout_secs: u64, -) -> Result<()> { - fs::create_dir_all(output_root) - .with_context(|| format!("creating output dir {:?}", output_root))?; - - let base = browserstack_base_path(target); - let build_path = format!("{base}/builds/{build_id}"); - let sessions_path = format!("{base}/builds/{build_id}/sessions"); - - if wait { - wait_for_build(client, &build_path, poll_interval_secs, timeout_secs)?; - } - - let build_json = client.get_json(&build_path)?; - write_json(output_root.join("build.json"), &build_json)?; - - let mut session_ids = extract_session_ids(&build_json); - if session_ids.is_empty() { - match client.get_json(&sessions_path) { - Ok(value) => { - write_json(output_root.join("sessions.json"), &value)?; - session_ids = extract_session_ids(&value); - } - Err(err) => { - let msg = shorten_html_error(&err.to_string()); - println!("Sessions endpoint unavailable; falling back to build.json: {msg}"); - } - } - } - - if session_ids.is_empty() { - println!("No sessions found for build {}", build_id); - return Ok(()); - } - - for session_id in session_ids { - let session_path = format!("{base}/builds/{build_id}/sessions/{session_id}"); - let session_json = client.get_json(&session_path)?; - let session_dir = output_root.join(format!("session-{}", session_id)); - fs::create_dir_all(&session_dir) - .with_context(|| format!("creating session dir {:?}", session_dir))?; - write_json(session_dir.join("session.json"), &session_json)?; - - let mut bench_report: Option = None; - for (key, url) in extract_url_fields(&session_json) { - let file_name = filename_for_url(&key, &url); - let dest = session_dir.join(file_name); - if let Err(err) = client.download_url(&url, &dest) { - println!("Skipping download for {key}: {err}"); - continue; - } - if (key.contains("device_log") - || key.contains("instrumentation_log") - || key.contains("app_log")) - && let Ok(contents) = fs::read_to_string(&dest) - && let Some(parsed) = extract_bench_json(&contents) - { - bench_report = Some(parsed); - } - } - - if let Some(report) = bench_report { - write_json(session_dir.join("bench-report.json"), &report)?; - } - } - - println!("Fetched BrowserStack artifacts to {:?}", output_root); - Ok(()) -} - -fn browserstack_base_path(target: MobileTarget) -> &'static str { - match target { - MobileTarget::Android => "app-automate/espresso/v2", - MobileTarget::Ios => "app-automate/xcuitest/v2", - } -} - -fn wait_for_build( - client: &BrowserStackClient, - build_path: &str, - poll_interval_secs: u64, - timeout_secs: u64, -) -> Result<()> { - let deadline = Instant::now() + Duration::from_secs(timeout_secs); - loop { - let build_json = client.get_json(build_path)?; - if let Some(status) = build_json - .get("status") - .and_then(|val| val.as_str()) - .map(|val| val.to_lowercase()) - { - if status == "failed" || status == "error" { - println!("Build status: {status}"); - return Ok(()); - } - if status == "done" || status == "passed" || status == "completed" { - println!("Build status: {status}"); - return Ok(()); - } - println!("Build status: {status} (waiting)"); - } else { - println!("Build status missing; continuing without wait"); - return Ok(()); - } - - if Instant::now() >= deadline { - println!("Timed out waiting for build status"); - return Ok(()); - } - std::thread::sleep(Duration::from_secs(poll_interval_secs)); - } -} - -fn extract_session_ids(value: &Value) -> Vec { - let sessions = value - .get("sessions") - .and_then(|val| val.as_array()) - .or_else(|| value.as_array()); - let mut ids = Vec::new(); - if let Some(entries) = sessions { - for entry in entries { - let id = entry - .get("id") - .or_else(|| entry.get("session_id")) - .or_else(|| entry.get("sessionId")) - .and_then(|val| val.as_str()); - if let Some(id) = id { - ids.push(id.to_string()); - } - } - } - if ids.is_empty() - && let Some(devices) = value.get("devices").and_then(|val| val.as_array()) - { - for device in devices { - if let Some(sessions) = device.get("sessions").and_then(|val| val.as_array()) { - for entry in sessions { - if let Some(id) = entry.get("id").and_then(|val| val.as_str()) { - ids.push(id.to_string()); - } - } - } - } - } - ids -} - -fn extract_url_fields(value: &Value) -> Vec<(String, String)> { - let mut urls = Vec::new(); - extract_url_fields_recursive(value, "", &mut urls); - urls -} - -fn extract_url_fields_recursive(value: &Value, prefix: &str, out: &mut Vec<(String, String)>) { - match value { - Value::Object(map) => { - for (key, val) in map { - let next = if prefix.is_empty() { - key.clone() - } else { - format!("{}.{}", prefix, key) - }; - if let Value::String(url) = val - && (url.starts_with("http") || url.starts_with("bs://")) - { - out.push((next.clone(), url.clone())); - } - extract_url_fields_recursive(val, &next, out); - } - } - Value::Array(items) => { - for (idx, val) in items.iter().enumerate() { - let next = format!("{}[{}]", prefix, idx); - extract_url_fields_recursive(val, &next, out); - } - } - _ => {} - } -} - -fn filename_for_url(key: &str, url: &str) -> String { - let stripped = url.split('?').next().unwrap_or(url); - let ext = Path::new(stripped) - .extension() - .and_then(|val| val.to_str()) - .unwrap_or("log"); - let mut safe = String::with_capacity(key.len()); - for ch in key.chars() { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - safe.push(ch); - } else { - safe.push('_'); - } - } - format!("{}.{}", safe, ext) -} - -fn extract_bench_json(contents: &str) -> Option { - let marker = "BENCH_JSON "; - for line in contents.lines().rev() { - if let Some(idx) = line.find(marker) { - let json_part = &line[idx + marker.len()..]; - if let Ok(value) = serde_json::from_str::(json_part) { - return Some(value); - } - } - } - None -} - -fn write_json(path: PathBuf, value: &Value) -> Result<()> { - let contents = serde_json::to_string_pretty(value)?; - write_file(&path, contents.as_bytes()) -} - -fn shorten_html_error(message: &str) -> String { - if message.contains("") || message.contains(", - config: Option<&Path>, - ios_app: Option, - ios_test_suite: Option, - local_only: bool, -) -> Result { - if let Some(cfg_path) = config { - let cfg = load_config(cfg_path)?; - let matrix = load_device_matrix(&cfg.device_matrix)?; - let device_names = match &cfg.device_tags { - Some(tags) if !tags.is_empty() => filter_devices_by_tags(matrix.devices, tags)?, - _ => matrix.devices.into_iter().map(|d| d.name).collect(), - }; - return Ok(RunSpec { - target: cfg.target, - function: cfg.function, - iterations: cfg.iterations, - warmup: cfg.warmup, - devices: device_names, - browserstack: Some(cfg.browserstack), - ios_xcuitest: cfg.ios_xcuitest, - }); - } - - if function.trim().is_empty() { - bail!("function must not be empty"); - } - - let ios_xcuitest = match (ios_app, ios_test_suite) { - (Some(app), Some(test_suite)) => Some(IosXcuitestArtifacts { app, test_suite }), - (None, None) => None, - _ => bail!("both --ios-app and --ios-test-suite must be provided together"), - }; - - let ios_xcuitest = if target == MobileTarget::Ios - && !local_only - && !devices.is_empty() - && ios_xcuitest.is_none() - { - Some(package_ios_xcuitest_artifacts()?) - } else { - ios_xcuitest - }; - - Ok(RunSpec { - target, - function, - iterations, - warmup, - devices, - browserstack: None, - ios_xcuitest, - }) -} - -fn load_config(path: &Path) -> Result { - let contents = - fs::read_to_string(path).with_context(|| format!("reading config {:?}", path))?; - toml::from_str(&contents).with_context(|| format!("parsing config {:?}", path)) -} - -fn load_device_matrix(path: &Path) -> Result { - let contents = - fs::read_to_string(path).with_context(|| format!("reading device matrix {:?}", path))?; - serde_yaml::from_str(&contents).with_context(|| format!("parsing device matrix {:?}", path)) -} - -fn filter_devices_by_tags(devices: Vec, tags: &[String]) -> Result> { - let wanted: Vec = tags - .iter() - .map(|tag| tag.trim().to_lowercase()) - .filter(|tag| !tag.is_empty()) - .collect(); - if wanted.is_empty() { - return Ok(devices.into_iter().map(|d| d.name).collect()); - } - - let mut matched = Vec::new(); - for device in devices { - let Some(device_tags) = device.tags.as_ref() else { - continue; - }; - let has_match = device_tags.iter().any(|tag| { - let candidate = tag.trim().to_lowercase(); - wanted.iter().any(|wanted_tag| wanted_tag == &candidate) - }); - if has_match { - matched.push(device.name); - } - } - - if matched.is_empty() { - bail!( - "no devices matched tags [{}] in device matrix", - wanted.join(", ") - ); - } - Ok(matched) -} - -fn run_ios_build() -> Result<(PathBuf, PathBuf)> { - let root = repo_root()?; - let crate_name = - detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); - let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); - let cfg = mobench_sdk::BuildConfig { - target: mobench_sdk::Target::Ios, - profile: mobench_sdk::BuildProfile::Debug, - incremental: true, - }; - let result = builder.build(&cfg)?; - let header = root.join("target/ios/include").join(format!( - "{}.h", - result - .app_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("module") - )); - Ok((result.app_path, header)) -} - -fn package_ios_xcuitest_artifacts() -> Result { - let root = repo_root()?; - let crate_name = - detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); - let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); - let cfg = mobench_sdk::BuildConfig { - target: mobench_sdk::Target::Ios, - profile: mobench_sdk::BuildProfile::Debug, - incremental: true, - }; - builder - .build(&cfg) - .context("Failed to build iOS xcframework before packaging")?; - let app = builder - .package_ipa("BenchRunner", mobench_sdk::builders::SigningMethod::AdHoc) - .context("Failed to package iOS IPA for BrowserStack")?; - let test_suite = builder - .package_xcuitest("BenchRunner") - .context("Failed to package iOS XCUITest runner for BrowserStack")?; - Ok(IosXcuitestArtifacts { app, test_suite }) -} - -#[derive(Debug, Clone)] -struct ResolvedBrowserStack { - username: String, - access_key: String, - project: Option, -} - -fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path, test_apk: &Path) -> Result { - let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username.clone(), - access_key: creds.access_key.clone(), - }, - creds.project.clone(), - )?; - - // Upload the app-under-test APK. - let upload = client.upload_espresso_app(apk)?; - - // Upload the Espresso test-suite APK produced by Gradle. - let test_upload = client.upload_espresso_test_suite(test_apk)?; - - // Schedule the Espresso build with both app and testSuite, as required by BrowserStack. - let run = client.schedule_espresso_run( - &spec.devices, - &upload.app_url, - &test_upload.test_suite_url, - )?; - println!( - "Queued BrowserStack Espresso build {} for devices: {}", - run.build_id, - spec.devices.join(", ") - ); - - Ok(RemoteRun::Android { - app_url: upload.app_url, - build_id: run.build_id, - }) -} - -fn trigger_browserstack_xcuitest( - spec: &RunSpec, - artifacts: &IosXcuitestArtifacts, -) -> Result { - let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username.clone(), - access_key: creds.access_key.clone(), - }, - creds.project.clone(), - )?; - - if !artifacts.app.exists() { - bail!( - "iOS app artifact not found at {:?}; provide a .ipa or zipped .app", - artifacts.app - ); - } - if !artifacts.test_suite.exists() { - bail!( - "iOS XCUITest test suite artifact not found at {:?}; provide the zipped test runner bundle", - artifacts.test_suite - ); - } - - let app_upload = client.upload_xcuitest_app(&artifacts.app)?; - let test_upload = client.upload_xcuitest_test_suite(&artifacts.test_suite)?; - let run = client.schedule_xcuitest_run( - &spec.devices, - &app_upload.app_url, - &test_upload.test_suite_url, - )?; - println!( - "Queued BrowserStack XCUITest build {} for devices: {}", - run.build_id, - spec.devices.join(", ") - ); - - Ok(RemoteRun::Ios { - app_url: app_upload.app_url, - test_suite_url: test_upload.test_suite_url, - build_id: run.build_id, - }) -} - -fn resolve_browserstack_credentials( - config: Option<&BrowserStackConfig>, -) -> Result { - let mut username = None; - let mut access_key = None; - let mut project = None; - - if let Some(cfg) = config { - username = Some(expand_env_var(&cfg.app_automate_username)?); - access_key = Some(expand_env_var(&cfg.app_automate_access_key)?); - project = cfg - .project - .as_ref() - .map(|p| expand_env_var(p)) - .transpose()?; - } - - if username.as_deref().map(str::is_empty).unwrap_or(true) - && let Ok(val) = env::var("BROWSERSTACK_USERNAME") - && !val.is_empty() - { - username = Some(val); - } - if access_key.as_deref().map(str::is_empty).unwrap_or(true) - && let Ok(val) = env::var("BROWSERSTACK_ACCESS_KEY") - && !val.is_empty() - { - access_key = Some(val); - } - if project.is_none() - && let Ok(val) = env::var("BROWSERSTACK_PROJECT") - && !val.is_empty() - { - project = Some(val); - } - - let username = username.filter(|s| !s.is_empty()).ok_or_else(|| { - anyhow!("BrowserStack username missing; set BROWSERSTACK_USERNAME or provide in config") - })?; - let access_key = access_key.filter(|s| !s.is_empty()).ok_or_else(|| { - anyhow!("BrowserStack access key missing; set BROWSERSTACK_ACCESS_KEY or provide in config") - })?; - - Ok(ResolvedBrowserStack { - username, - access_key, - project, - }) -} - -fn expand_env_var(raw: &str) -> Result { - if let Some(stripped) = raw.strip_prefix("${").and_then(|s| s.strip_suffix('}')) { - let val = env::var(stripped) - .with_context(|| format!("resolving env var {stripped} for BrowserStack config"))?; - return Ok(val); - } - Ok(raw.to_string()) -} - -#[cfg(test)] -fn run_local_smoke(spec: &RunSpec) -> Result { - println!("Running local smoke test for {}...", spec.function); - - let bench_spec = mobench_sdk::BenchSpec { - name: spec.function.clone(), - iterations: spec.iterations, - warmup: spec.warmup, - }; - - let report = - mobench_sdk::run_benchmark(bench_spec).map_err(|e| anyhow!("benchmark failed: {:?}", e))?; - - serde_json::to_value(&report).context("serializing benchmark report") -} - -fn persist_mobile_spec(spec: &RunSpec) -> Result<()> { - let root = repo_root()?; - let payload = json!({ - "function": spec.function, - "iterations": spec.iterations, - "warmup": spec.warmup, - }); - let contents = serde_json::to_string_pretty(&payload)?; - let targets = [ - root.join("target/mobile-spec/android/bench_spec.json"), - root.join("target/mobile-spec/ios/bench_spec.json"), - ]; - for path in targets { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("creating directory {:?}", parent))?; - } - write_file(&path, contents.as_bytes())?; - } - Ok(()) -} - -#[derive(Debug)] -struct SummaryPaths { - json: PathBuf, - markdown: PathBuf, - csv: PathBuf, -} - -fn resolve_summary_paths(output: Option<&Path>) -> Result { - let json = output - .map(ToOwned::to_owned) - .unwrap_or_else(|| PathBuf::from("run-summary.json")); - let markdown = json.with_extension("md"); - let csv = json.with_extension("csv"); - Ok(SummaryPaths { - json, - markdown, - csv, - }) -} - -fn empty_summary(spec: &RunSpec) -> SummaryReport { - SummaryReport { - generated_at: "pending".to_string(), - generated_at_unix: 0, - target: spec.target, - function: spec.function.clone(), - iterations: spec.iterations, - warmup: spec.warmup, - devices: spec.devices.clone(), - device_summaries: Vec::new(), - } -} - -fn build_summary(run_summary: &RunSummary) -> Result { - let generated_at_unix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("generating timestamp")? - .as_secs(); - let generated_at = OffsetDateTime::now_utc() - .format(&Rfc3339) - .unwrap_or_else(|_| generated_at_unix.to_string()); - - let mut device_summaries = Vec::new(); - - if let Some(results) = &run_summary.benchmark_results { - for (device, entries) in results { - let mut benchmarks = Vec::new(); - for entry in entries { - let function = entry - .get("function") - .and_then(|f| f.as_str()) - .unwrap_or("unknown") - .to_string(); - let samples = extract_samples(entry); - let stats = compute_sample_stats(&samples); - let mean_ns = stats - .as_ref() - .map(|s| s.mean_ns) - .or_else(|| entry.get("mean_ns").and_then(|m| m.as_u64())); - - benchmarks.push(BenchmarkStats { - function, - samples: samples.len(), - mean_ns, - median_ns: stats.as_ref().map(|s| s.median_ns), - p95_ns: stats.as_ref().map(|s| s.p95_ns), - min_ns: stats.as_ref().map(|s| s.min_ns), - max_ns: stats.as_ref().map(|s| s.max_ns), - }); - } - - benchmarks.sort_by(|a, b| a.function.cmp(&b.function)); - device_summaries.push(DeviceSummary { - device: device.clone(), - benchmarks, - }); - } - } - - if device_summaries.is_empty() - && let Some(local_summary) = summarize_local_report(run_summary) - { - device_summaries.push(local_summary); - } - - Ok(SummaryReport { - generated_at, - generated_at_unix, - target: run_summary.spec.target, - function: run_summary.spec.function.clone(), - iterations: run_summary.spec.iterations, - warmup: run_summary.spec.warmup, - devices: run_summary.spec.devices.clone(), - device_summaries, - }) -} - -fn write_summary(summary: &RunSummary, paths: &SummaryPaths, summary_csv: bool) -> Result<()> { - let json = serde_json::to_string_pretty(summary)?; - ensure_parent_dir(&paths.json)?; - write_file(&paths.json, json.as_bytes())?; - println!("Wrote run summary to {:?}", paths.json); - - let markdown = render_markdown_summary(&summary.summary); - ensure_parent_dir(&paths.markdown)?; - write_file(&paths.markdown, markdown.as_bytes())?; - println!("Wrote markdown summary to {:?}", paths.markdown); - - if summary_csv { - let csv = render_csv_summary(&summary.summary); - ensure_parent_dir(&paths.csv)?; - write_file(&paths.csv, csv.as_bytes())?; - println!("Wrote CSV summary to {:?}", paths.csv); - } - Ok(()) -} - -fn ensure_parent_dir(path: &Path) -> Result<()> { - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent).with_context(|| format!("creating directory {:?}", parent))?; - } - Ok(()) -} - -#[derive(Debug)] -struct CompareReport { - baseline: PathBuf, - candidate: PathBuf, - rows: Vec, -} - -#[derive(Debug)] -struct CompareRow { - device: String, - function: String, - baseline_median_ns: Option, - candidate_median_ns: Option, - median_delta_pct: Option, - baseline_p95_ns: Option, - candidate_p95_ns: Option, - p95_delta_pct: Option, -} - -fn compare_summaries(baseline: &Path, candidate: &Path) -> Result { - let baseline_summary = load_run_summary(baseline)?; - let candidate_summary = load_run_summary(candidate)?; - - let baseline_map = summary_lookup(&baseline_summary.summary); - let candidate_map = summary_lookup(&candidate_summary.summary); - - let mut rows = Vec::new(); - let mut devices: BTreeMap = BTreeMap::new(); - devices.extend(baseline_map.keys().map(|k| (k.clone(), ()))); - devices.extend(candidate_map.keys().map(|k| (k.clone(), ()))); - - for device in devices.keys() { - let mut functions: BTreeMap = BTreeMap::new(); - if let Some(entry) = baseline_map.get(device) { - functions.extend(entry.keys().map(|k| (k.clone(), ()))); - } - if let Some(entry) = candidate_map.get(device) { - functions.extend(entry.keys().map(|k| (k.clone(), ()))); - } - - for function in functions.keys() { - let baseline_stats = baseline_map - .get(device) - .and_then(|entry| entry.get(function)); - let candidate_stats = candidate_map - .get(device) - .and_then(|entry| entry.get(function)); - - let baseline_median = baseline_stats.and_then(|s| s.median_ns); - let candidate_median = candidate_stats.and_then(|s| s.median_ns); - let median_delta = percent_delta(baseline_median, candidate_median); - - let baseline_p95 = baseline_stats.and_then(|s| s.p95_ns); - let candidate_p95 = candidate_stats.and_then(|s| s.p95_ns); - let p95_delta = percent_delta(baseline_p95, candidate_p95); - - rows.push(CompareRow { - device: device.clone(), - function: function.clone(), - baseline_median_ns: baseline_median, - candidate_median_ns: candidate_median, - median_delta_pct: median_delta, - baseline_p95_ns: baseline_p95, - candidate_p95_ns: candidate_p95, - p95_delta_pct: p95_delta, - }); - } - } - - Ok(CompareReport { - baseline: baseline.to_path_buf(), - candidate: candidate.to_path_buf(), - rows, - }) -} - -fn load_run_summary(path: &Path) -> Result { - let contents = fs::read_to_string(path).with_context(|| format!("reading {:?}", path))?; - serde_json::from_str(&contents).with_context(|| format!("parsing summary {:?}", path)) -} - -fn summary_lookup(summary: &SummaryReport) -> BTreeMap> { - let mut map = BTreeMap::new(); - for device in &summary.device_summaries { - let mut functions = BTreeMap::new(); - for bench in &device.benchmarks { - functions.insert(bench.function.clone(), bench.clone()); - } - map.insert(device.device.clone(), functions); - } - map -} - -fn percent_delta(baseline: Option, candidate: Option) -> Option { - let baseline = baseline? as f64; - let candidate = candidate? as f64; - if baseline == 0.0 { - return None; - } - Some(((candidate - baseline) / baseline) * 100.0) -} - -fn write_compare_report(report: &CompareReport, output: Option<&Path>) -> Result<()> { - let markdown = render_compare_markdown(report); - if let Some(path) = output { - ensure_parent_dir(path)?; - write_file(path, markdown.as_bytes())?; - println!("Wrote compare report to {:?}", path); - } else { - println!("{markdown}"); - } - Ok(()) -} - -fn render_compare_markdown(report: &CompareReport) -> String { - let mut output = String::new(); - let _ = writeln!(output, "# Benchmark Comparison"); - let _ = writeln!(output); - let _ = writeln!(output, "- Baseline: {}", report.baseline.display()); - let _ = writeln!(output, "- Candidate: {}", report.candidate.display()); - let _ = writeln!(output); - let _ = writeln!( - output, - "| Device | Function | Median (base ms) | Median (cand ms) | Median Δ% | P95 (base ms) | P95 (cand ms) | P95 Δ% |" - ); - let _ = writeln!( - output, - "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |" - ); - for row in &report.rows { - let _ = writeln!( - output, - "| {} | {} | {} | {} | {} | {} | {} | {} |", - row.device, - row.function, - format_ms(row.baseline_median_ns), - format_ms(row.candidate_median_ns), - format_delta(row.median_delta_pct), - format_ms(row.baseline_p95_ns), - format_ms(row.candidate_p95_ns), - format_delta(row.p95_delta_pct) - ); - } - output -} - -fn format_delta(value: Option) -> String { - value - .map(|delta| format!("{:+.2}%", delta)) - .unwrap_or_else(|| "-".to_string()) -} - -fn summarize_local_report(run_summary: &RunSummary) -> Option { - let samples = extract_samples(&run_summary.local_report); - if samples.is_empty() { - return None; - } - let stats = compute_sample_stats(&samples)?; - let function = run_summary - .local_report - .get("spec") - .and_then(|spec| spec.get("name")) - .and_then(|name| name.as_str()) - .unwrap_or(&run_summary.spec.function) - .to_string(); - - Some(DeviceSummary { - device: "local".to_string(), - benchmarks: vec![BenchmarkStats { - function, - samples: samples.len(), - mean_ns: Some(stats.mean_ns), - median_ns: Some(stats.median_ns), - p95_ns: Some(stats.p95_ns), - min_ns: Some(stats.min_ns), - max_ns: Some(stats.max_ns), - }], - }) -} - -#[derive(Clone, Debug)] -struct SampleStats { - mean_ns: u64, - median_ns: u64, - p95_ns: u64, - min_ns: u64, - max_ns: u64, -} - -fn compute_sample_stats(samples: &[u64]) -> Option { - if samples.is_empty() { - return None; - } - - let mut sorted = samples.to_vec(); - sorted.sort_unstable(); - let len = sorted.len(); - - let mean_ns = (sorted.iter().map(|v| *v as u128).sum::() / len as u128) as u64; - let median_ns = if len % 2 == 1 { - sorted[len / 2] - } else { - let lower = sorted[(len / 2) - 1]; - let upper = sorted[len / 2]; - (lower + upper) / 2 - }; - let p95_index = percentile_index(len, 0.95); - let p95_ns = sorted[p95_index]; - let min_ns = sorted[0]; - let max_ns = sorted[len - 1]; - - Some(SampleStats { - mean_ns, - median_ns, - p95_ns, - min_ns, - max_ns, - }) -} - -fn percentile_index(len: usize, percentile: f64) -> usize { - if len == 0 { - return 0; - } - let rank = (percentile * len as f64).ceil() as usize; - let index = rank.saturating_sub(1); - index.min(len - 1) -} - -fn extract_samples(value: &Value) -> Vec { - let Some(samples) = value.get("samples").and_then(|s| s.as_array()) else { - return Vec::new(); - }; - let mut durations = Vec::with_capacity(samples.len()); - for sample in samples { - if let Some(duration) = sample - .get("duration_ns") - .and_then(|duration| duration.as_u64()) - { - durations.push(duration); - } else if let Some(duration) = sample.as_u64() { - durations.push(duration); - } - } - durations -} - -fn render_markdown_summary(summary: &SummaryReport) -> String { - let mut output = String::new(); - let devices = if summary.devices.is_empty() { - "none".to_string() - } else { - summary.devices.join(", ") - }; - - let _ = writeln!(output, "# Benchmark Summary"); - let _ = writeln!(output); - let _ = writeln!(output, "- Generated: {}", summary.generated_at); - let _ = writeln!(output, "- Target: {:?}", summary.target); - let _ = writeln!(output, "- Function: {}", summary.function); - let _ = writeln!( - output, - "- Iterations/Warmup: {} / {}", - summary.iterations, summary.warmup - ); - let _ = writeln!(output, "- Devices: {}", devices); - let _ = writeln!(output); - - if summary.device_summaries.is_empty() { - let _ = writeln!(output, "No benchmark samples were collected."); - return output; - } - - for device in &summary.device_summaries { - let _ = writeln!(output, "## Device: {}", device.device); - let _ = writeln!(output); - let _ = writeln!( - output, - "| Function | Samples | Mean (ms) | Median (ms) | P95 (ms) | Min (ms) | Max (ms) |" - ); - let _ = writeln!(output, "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"); - for bench in &device.benchmarks { - let _ = writeln!( - output, - "| {} | {} | {} | {} | {} | {} | {} |", - bench.function, - bench.samples, - format_ms(bench.mean_ns), - format_ms(bench.median_ns), - format_ms(bench.p95_ns), - format_ms(bench.min_ns), - format_ms(bench.max_ns) - ); - } - let _ = writeln!(output); - } - - output -} - -fn render_csv_summary(summary: &SummaryReport) -> String { - let mut output = String::new(); - let _ = writeln!( - output, - "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns" - ); - for device in &summary.device_summaries { - for bench in &device.benchmarks { - let _ = writeln!( - output, - "{},{},{},{},{},{},{},{}", - device.device, - bench.function, - bench.samples, - bench.mean_ns.map_or(String::from(""), |v| v.to_string()), - bench.median_ns.map_or(String::from(""), |v| v.to_string()), - bench.p95_ns.map_or(String::from(""), |v| v.to_string()), - bench.min_ns.map_or(String::from(""), |v| v.to_string()), - bench.max_ns.map_or(String::from(""), |v| v.to_string()) - ); - } - } - output -} - -fn format_ms(value: Option) -> String { - value - .map(|ns| format!("{:.3}", ns as f64 / 1_000_000.0)) - .unwrap_or_else(|| "-".to_string()) -} - -fn run_android_build(_ndk_home: &str) -> Result { - let root = repo_root()?; - let crate_name = - detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); - - let cfg = mobench_sdk::BuildConfig { - target: mobench_sdk::Target::Android, - profile: mobench_sdk::BuildProfile::Debug, - incremental: true, - }; - let builder = mobench_sdk::builders::AndroidBuilder::new(&root, crate_name).verbose(true); - let result = builder.build(&cfg)?; - Ok(result) -} - -fn load_dotenv() { - if let Ok(root) = repo_root() { - let path = root.join(".env.local"); - let _ = dotenvy::from_path(path); - } -} - -fn repo_root() -> Result { - // Prefer the build-time repo root but fall back to the current directory for installed binaries. - let compiled = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(".."); - if let Ok(path) = compiled.canonicalize() { - return Ok(path); - } - std::env::current_dir().context("resolving repo root from current directory") -} - -fn ensure_can_write(path: &Path) -> Result<()> { - if path.exists() { - bail!("refusing to overwrite existing file: {:?}", path); - } - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent) - .with_context(|| format!("creating parent directory {:?}", parent))?; - } - Ok(()) -} - -fn write_file(path: &Path, contents: &[u8]) -> Result<()> { - fs::write(path, contents).with_context(|| format!("writing file {:?}", path)) -} - -/// Initialize a new benchmark project using mobench-sdk (Phase 1 MVP) -fn cmd_init_sdk( - target: SdkTarget, - project_name: String, - output_dir: PathBuf, - generate_examples: bool, -) -> Result<()> { - println!("Initializing benchmark project with mobench-sdk..."); - println!(" Project name: {}", project_name); - println!(" Target: {:?}", target); - println!(" Output directory: {:?}", output_dir); - - let config = mobench_sdk::InitConfig { - target: target.into(), - project_name: project_name.clone(), - output_dir: output_dir.clone(), - generate_examples, - }; - - mobench_sdk::codegen::generate_project(&config).context("Failed to generate project")?; - - println!("\n✓ Project initialized successfully!"); - println!("\nNext steps:"); - println!(" 1. Add benchmark functions to your code with #[benchmark]"); - println!(" 2. Run 'cargo build --target ' to build"); - println!(" 3. Run benchmarks with 'cargo mobench build --target '"); - - Ok(()) -} - -/// Build mobile artifacts using mobench-sdk (Phase 1 MVP) -fn cmd_build(target: SdkTarget, release: bool) -> Result<()> { - println!("Building mobile artifacts..."); - println!(" Target: {:?}", target); - println!(" Profile: {}", if release { "release" } else { "debug" }); - - let project_root = std::env::current_dir().context("Failed to get current directory")?; - let crate_name = detect_bench_mobile_crate_name(&project_root) - .unwrap_or_else(|_| "bench-mobile".to_string()); // Fallback for legacy layouts - - let build_config = mobench_sdk::BuildConfig { - target: target.into(), - profile: if release { - mobench_sdk::BuildProfile::Release - } else { - mobench_sdk::BuildProfile::Debug - }, - incremental: true, - }; - - match target { - SdkTarget::Android => { - let builder = - mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) - .verbose(true); - let result = builder.build(&build_config)?; - println!("\n✓ Android build completed!"); - println!(" APK: {:?}", result.app_path); - } - SdkTarget::Ios => { - let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name.clone()) - .verbose(true); - let result = builder.build(&build_config)?; - println!("\n✓ iOS build completed!"); - println!(" Framework: {:?}", result.app_path); - } - SdkTarget::Both => { - // Build Android - let android_builder = - mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) - .verbose(true); - let android_result = android_builder.build(&build_config)?; - println!("\n✓ Android build completed!"); - println!(" APK: {:?}", android_result.app_path); - - // Build iOS - let ios_builder = - mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); - let ios_result = ios_builder.build(&build_config)?; - println!("\n✓ iOS build completed!"); - println!(" Framework: {:?}", ios_result.app_path); - } - } - - Ok(()) -} - -fn detect_bench_mobile_crate_name(root: &Path) -> Result { - // Try bench-mobile/ first (SDK projects) - let bench_mobile_path = root.join("bench-mobile").join("Cargo.toml"); - if bench_mobile_path.exists() { - let contents = fs::read_to_string(&bench_mobile_path) - .with_context(|| format!("reading bench-mobile manifest at {:?}", bench_mobile_path))?; - let value: toml::Value = toml::from_str(&contents) - .with_context(|| format!("parsing bench-mobile manifest {:?}", bench_mobile_path))?; - let name = value - .get("package") - .and_then(|pkg| pkg.get("name")) - .and_then(|n| n.as_str()) - .ok_or_else(|| { - anyhow!( - "bench-mobile package.name missing in {:?}", - bench_mobile_path - ) - })?; - return Ok(name.to_string()); - } - - // Fallback: Try crates/sample-fns (repository testing) - let sample_fns_path = root.join("crates").join("sample-fns").join("Cargo.toml"); - if sample_fns_path.exists() { - let contents = fs::read_to_string(&sample_fns_path) - .with_context(|| format!("reading sample-fns manifest at {:?}", sample_fns_path))?; - let value: toml::Value = toml::from_str(&contents) - .with_context(|| format!("parsing sample-fns manifest {:?}", sample_fns_path))?; - let name = value - .get("package") - .and_then(|pkg| pkg.get("name")) - .and_then(|n| n.as_str()) - .ok_or_else(|| anyhow!("sample-fns package.name missing in {:?}", sample_fns_path))?; - return Ok(name.to_string()); - } - - bail!("No benchmark crate found. Expected 'bench-mobile/' or 'crates/sample-fns/'") -} - -/// List all discovered benchmark functions (Phase 1 MVP) -fn cmd_list() -> Result<()> { - println!("Discovering benchmark functions...\n"); - - let benchmarks = mobench_sdk::discover_benchmarks(); - - if benchmarks.is_empty() { - println!("No benchmarks found."); - println!("\nTo add benchmarks:"); - println!(" 1. Add #[benchmark] attribute to functions"); - println!(" 2. Make sure mobench-sdk is in your dependencies"); - println!(" 3. Rebuild your project"); - } else { - println!("Found {} benchmark(s):", benchmarks.len()); - for bench in benchmarks { - println!(" - {}", bench.name); - } - } - - Ok(()) -} - -/// Package iOS app as IPA for distribution or testing -fn cmd_package_ipa(scheme: &str, method: IosSigningMethodArg) -> Result<()> { - println!("Packaging iOS app as IPA..."); - println!(" Scheme: {}", scheme); - println!(" Method: {:?}", method); - - let project_root = repo_root()?; - let crate_name = detect_bench_mobile_crate_name(&project_root) - .unwrap_or_else(|_| "bench-mobile".to_string()); - - let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); - - let signing_method: mobench_sdk::builders::SigningMethod = method.into(); - let ipa_path = builder - .package_ipa(scheme, signing_method) - .context("Failed to package IPA")?; - - println!("\n✓ IPA packaged successfully!"); - println!(" Path: {:?}", ipa_path); - println!("\nYou can now:"); - println!(" - Install on device: Use Xcode or ios-deploy"); - println!( - " - Test on BrowserStack: cargo mobench run --target ios --ios-app {:?}", - ipa_path - ); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // Register a lightweight benchmark for tests so the inventory contains at least one entry. - #[mobench_sdk::benchmark] - fn noop_benchmark() { - std::hint::black_box(1u8); - } - - #[test] - fn resolves_cli_spec() { - let spec = resolve_run_spec( - MobileTarget::Android, - "sample_fns::fibonacci".into(), - 5, - 1, - vec!["pixel".into()], - None, - None, - None, - false, - ) - .unwrap(); - assert_eq!(spec.function, "sample_fns::fibonacci"); - assert_eq!(spec.iterations, 5); - assert_eq!(spec.warmup, 1); - assert_eq!(spec.devices, vec!["pixel".to_string()]); - assert!(spec.browserstack.is_none()); - assert!(spec.ios_xcuitest.is_none()); - } - - #[test] - fn local_smoke_produces_samples() { - let spec = RunSpec { - target: MobileTarget::Android, - function: "noop_benchmark".into(), - iterations: 3, - warmup: 1, - devices: vec![], - browserstack: None, - ios_xcuitest: None, - }; - let report = run_local_smoke(&spec).expect("local harness"); - assert!(report["samples"].is_array()); - assert_eq!(report["spec"]["name"], "noop_benchmark"); - } - - #[test] - fn ios_requires_artifacts_for_browserstack() { - let spec = resolve_run_spec( - MobileTarget::Ios, - "sample_fns::fibonacci".into(), - 1, - 0, - vec!["iphone".into()], - None, - None, - None, - false, - ) - .expect("should auto-package iOS artifacts when missing"); - let ios_artifacts = spec - .ios_xcuitest - .expect("iOS artifacts should be populated"); - assert!(ios_artifacts.app.exists(), "iOS app artifact missing"); - assert!( - ios_artifacts.test_suite.exists(), - "iOS test suite artifact missing" - ); +fn main() { + if let Err(err) = mobench::run() { + eprintln!("{err:#}"); + std::process::exit(1); } } From f5bde13e901e916a6b96ee298c22ca80c7edfef6 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 16 Jan 2026 15:30:21 +0100 Subject: [PATCH 010/196] Remove legacy bench-cli crates and update docs Drop the unused bench-cli and bench-runner crates, update docs to reflect the current mobench-runner workflow, and adjust legacy scripts/comments to reference cargo mobench. --- PROJECT_PLAN.md | 4 +- README.md | 2 - crates/bench-cli/Cargo.toml | 20 - crates/bench-cli/src/browserstack.rs | 509 ------------- crates/bench-cli/src/main.rs | 1052 -------------------------- crates/bench-runner/Cargo.toml | 9 - crates/bench-runner/src/lib.rs | 92 --- crates/mobench-sdk/src/runner.rs | 2 +- crates/sample-fns/src/lib.rs | 2 +- scripts/build-android-app.sh | 2 +- scripts/build-android.sh | 2 +- scripts/build-ios.sh | 4 +- scripts/generate-bindings.sh | 2 +- scripts/sync-android-libs.sh | 2 +- 14 files changed, 10 insertions(+), 1694 deletions(-) delete mode 100644 crates/bench-cli/Cargo.toml delete mode 100644 crates/bench-cli/src/browserstack.rs delete mode 100644 crates/bench-cli/src/main.rs delete mode 100644 crates/bench-runner/Cargo.toml delete mode 100644 crates/bench-runner/src/lib.rs diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 0f56510..968102e 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -16,7 +16,7 @@ ## Architecture Outline - `mobench`: Orchestrates builds, packaging, upload, AppAutomate sessions, and result collation. -- `bench-runner`: Minimal Rust harness compiled into mobile libs; exposes FFI entrypoints for target functions and collects timings. +- `mobench-runner`: Minimal Rust harness compiled into mobile libs; exposes FFI entrypoints for target functions and collects timings. - Mobile bindings: - Android: Kotlin wrapper + APK test harness embedding Rust lib (cargo-ndk); uses Espresso/Appium-style entrypoints for AppAutomate. - iOS: Swift wrapper + test host app/xcframework; invokes Rust via C-ABI bindings. @@ -31,7 +31,7 @@ ## Task Backlog -- [x] Repo bootstrap: Cargo workspace, `mobench` binary crate, `bench-runner` library crate, example `sample-fns` crate. +- [x] Repo bootstrap: Cargo workspace, `mobench` binary crate, `mobench-runner` library crate, example `sample-fns` crate. - [x] Define FFI boundary: macro/attribute to mark benchmarkable Rust functions; export through C ABI; basic timing harness. - [x] Android packaging: cargo-ndk config, Kotlin wrapper module, minimal test/activity to trigger Rust bench entrypoint. - [x] iOS packaging: xcframework build script (cargo lipo or cargo-apple), C header generation (cbindgen), Swift wrapper, test host. diff --git a/README.md b/README.md index ba19197..f3bb3e6 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,6 @@ mobench provides a Rust API and a CLI for running benchmarks on real mobile devi - `crates/mobench-sdk` ([mobench-sdk](https://crates.io/crates/mobench-sdk)): core SDK (builders, registry, codegen) - `crates/mobench-macros` ([mobench-macros](https://crates.io/crates/mobench-macros)): `#[benchmark]` proc macro - `crates/mobench-runner` ([mobench-runner](https://crates.io/crates/mobench-runner)): lightweight timing harness -- `crates/bench-cli`: BrowserStack and CLI support utilities -- `crates/bench-runner`: host-side harness utilities - `crates/sample-fns`: sample benchmarks and UniFFI bindings - `examples/basic-benchmark`: example SDK integration crate diff --git a/crates/bench-cli/Cargo.toml b/crates/bench-cli/Cargo.toml deleted file mode 100644 index 9d50b04..0000000 --- a/crates/bench-cli/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "bench-cli" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -anyhow.workspace = true -bench-runner = { path = "../bench-runner" } -sample-fns = { path = "../sample-fns" } -clap.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_yaml.workspace = true -toml.workspace = true -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking", "json", "multipart"] } -dotenvy = "0.15" - -[dev-dependencies] -tempfile = "3" diff --git a/crates/bench-cli/src/browserstack.rs b/crates/bench-cli/src/browserstack.rs deleted file mode 100644 index 7e78c1a..0000000 --- a/crates/bench-cli/src/browserstack.rs +++ /dev/null @@ -1,509 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use reqwest::blocking::multipart::Form; -use reqwest::blocking::{Client, Response}; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use serde_json::Value; -use std::path::Path; - -const DEFAULT_BASE_URL: &str = "https://api-cloud.browserstack.com"; -const USER_AGENT: &str = "mobile-bench-rs/0.1"; - -#[derive(Debug, Clone)] -pub struct BrowserStackAuth { - pub username: String, - pub access_key: String, -} - -/// BrowserStack App Automate (Espresso) client. -#[derive(Debug, Clone)] -pub struct BrowserStackClient { - http: Client, - auth: BrowserStackAuth, - base_url: String, - project: Option, -} - -impl BrowserStackClient { - pub fn new(auth: BrowserStackAuth, project: Option) -> Result { - let http = Client::builder() - .user_agent(USER_AGENT) - .build() - .context("building HTTP client")?; - - Ok(Self { - http, - auth, - base_url: DEFAULT_BASE_URL.to_string(), - project, - }) - } - - #[cfg(test)] - #[allow(dead_code)] // Used in tests to verify URL construction - pub fn with_base_url(mut self, base_url: impl Into) -> Self { - self.base_url = base_url.into(); - self - } - - /// Upload an Espresso app-under-test APK to BrowserStack. - pub fn upload_espresso_app(&self, artifact: &Path) -> Result { - if !artifact.exists() { - return Err(anyhow!("app artifact not found at {:?}", artifact)); - } - - let form = Form::new().file("file", artifact)?; - let resp = self - .http - .post(self.api("app-automate/espresso/v2/app")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .multipart(form) - .send() - .context("uploading app to BrowserStack")?; - - parse_response(resp, "app upload") - } - - /// Upload an Espresso test-suite APK to BrowserStack. - pub fn upload_espresso_test_suite(&self, artifact: &Path) -> Result { - if !artifact.exists() { - return Err(anyhow!("test suite artifact not found at {:?}", artifact)); - } - - let form = Form::new().file("file", artifact)?; - let resp = self - .http - .post(self.api("app-automate/espresso/v2/test-suite")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .multipart(form) - .send() - .context("uploading test suite to BrowserStack")?; - - parse_response(resp, "test suite upload") - } - - pub fn upload_xcuitest_app(&self, artifact: &Path) -> Result { - if !artifact.exists() { - return Err(anyhow!("iOS app artifact not found at {:?}", artifact)); - } - - let form = Form::new().file("file", artifact)?; - let resp = self - .http - .post(self.api("app-automate/xcuitest/v2/app")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .multipart(form) - .send() - .context("uploading iOS app to BrowserStack")?; - - parse_response(resp, "iOS app upload") - } - - pub fn upload_xcuitest_test_suite(&self, artifact: &Path) -> Result { - if !artifact.exists() { - return Err(anyhow!( - "iOS XCUITest suite artifact not found at {:?}", - artifact - )); - } - - let form = Form::new().file("file", artifact)?; - let resp = self - .http - .post(self.api("app-automate/xcuitest/v2/test-suite")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .multipart(form) - .send() - .context("uploading iOS XCUITest suite to BrowserStack")?; - - parse_response(resp, "iOS XCUITest suite upload") - } - - pub fn schedule_espresso_run( - &self, - devices: &[String], - app_url: &str, - test_suite_url: &str, - ) -> Result { - if devices.is_empty() { - return Err(anyhow!("device list is empty; provide at least one target")); - } - if app_url.is_empty() { - return Err(anyhow!("app_url is empty")); - } - if test_suite_url.is_empty() { - return Err(anyhow!("test_suite_url is empty")); - } - - let body = BuildRequest { - app: app_url.to_string(), - test_suite: test_suite_url.to_string(), - devices: devices.to_vec(), - device_logs: true, - disable_animations: true, - build_name: self.project.clone(), - }; - - let resp = self - .http - .post(self.api("app-automate/espresso/v2/build")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .json(&body) - .send() - .context("scheduling BrowserStack Espresso run")?; - - let build: BuildResponse = parse_response(resp, "schedule run")?; - Ok(ScheduledRun { - build_id: build.build_id, - }) - } - - pub fn schedule_xcuitest_run( - &self, - devices: &[String], - app_url: &str, - test_suite_url: &str, - ) -> Result { - if devices.is_empty() { - return Err(anyhow!("device list is empty; provide at least one target")); - } - if app_url.is_empty() { - return Err(anyhow!("app_url is empty")); - } - if test_suite_url.is_empty() { - return Err(anyhow!("test_suite_url is empty")); - } - - let body = XcuitestBuildRequest { - app: app_url.to_string(), - test_suite: test_suite_url.to_string(), - devices: devices.to_vec(), - device_logs: true, - build_name: self.project.clone(), - }; - - let resp = self - .http - .post(self.api("app-automate/xcuitest/v2/build")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .json(&body) - .send() - .context("scheduling BrowserStack XCUITest run")?; - - let build: BuildResponse = parse_response(resp, "schedule run")?; - Ok(ScheduledRun { - build_id: build.build_id, - }) - } - - fn api(&self, path: &str) -> String { - format!( - "{}/{}", - self.base_url.trim_end_matches('/'), - path.trim_start_matches('/') - ) - } - - pub fn get_json(&self, path: &str) -> Result { - let resp = self - .http - .get(self.api(path)) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .send() - .with_context(|| format!("requesting BrowserStack API {}", path))?; - - parse_response(resp, path) - } - - pub fn download_url(&self, url: &str, dest: &Path) -> Result<()> { - let resp = self - .http - .get(url) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .send() - .with_context(|| format!("downloading BrowserStack asset {}", url))?; - let status = resp.status(); - let bytes = resp - .bytes() - .with_context(|| format!("reading BrowserStack asset body {}", url))?; - if !status.is_success() { - return Err(anyhow!( - "BrowserStack asset download failed (status {}): {}", - status, - String::from_utf8_lossy(&bytes) - )); - } - std::fs::write(dest, bytes) - .with_context(|| format!("writing BrowserStack asset to {:?}", dest))?; - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AppUpload { - #[serde(alias = "appUrl")] - pub app_url: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TestSuiteUpload { - #[serde(alias = "test_suite_url", alias = "testSuiteUrl")] - pub test_suite_url: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ScheduledRun { - pub build_id: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct BuildRequest { - app: String, - test_suite: String, - devices: Vec, - device_logs: bool, - disable_animations: bool, - #[serde(skip_serializing_if = "Option::is_none")] - build_name: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct XcuitestBuildRequest { - app: String, - test_suite: String, - devices: Vec, - device_logs: bool, - #[serde(skip_serializing_if = "Option::is_none")] - build_name: Option, -} - -#[derive(Debug, Deserialize)] -struct BuildResponse { - #[serde(alias = "build_id", alias = "buildId")] - build_id: String, -} - -fn parse_response(resp: Response, context: &str) -> Result { - let status = resp.status(); - let text = resp - .text() - .with_context(|| format!("reading BrowserStack API response body for {}", context))?; - - if !status.is_success() { - return Err(anyhow!( - "BrowserStack API {} failed (status {}): {}", - context, - status, - text - )); - } - - serde_json::from_str(&text) - .with_context(|| format!("parsing BrowserStack API response for {}", context)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_missing_artifact() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - let missing = Path::new("/tmp/definitely-missing-file"); - assert!(client.upload_espresso_app(missing).is_err()); - } - - #[test] - fn suppresses_dead_code_warning_for_test_helper() { - // This test uses with_base_url to verify it works and suppress the warning - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap() - .with_base_url("https://test.example.com"); - - assert_eq!(client.base_url, "https://test.example.com"); - } - - #[test] - fn new_client_uses_default_base_url() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "testuser".into(), - access_key: "testkey".into(), - }, - Some("test-project".into()), - ) - .unwrap(); - - assert_eq!(client.base_url, DEFAULT_BASE_URL); - assert_eq!(client.project, Some("test-project".to_string())); - } - - #[test] - fn api_constructs_url_correctly() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let url = client.api("app-automate/espresso/v2/app"); - assert_eq!( - url, - "https://api-cloud.browserstack.com/app-automate/espresso/v2/app" - ); - } - - #[test] - fn api_handles_leading_slash() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let url = client.api("/app-automate/builds"); - assert_eq!( - url, - "https://api-cloud.browserstack.com/app-automate/builds" - ); - } - - #[test] - fn api_handles_trailing_slash_in_base_url() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap() - .with_base_url("https://test.example.com/"); - - let url = client.api("endpoint"); - assert_eq!(url, "https://test.example.com/endpoint"); - } - - #[test] - fn schedule_espresso_run_rejects_empty_devices() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let result = client.schedule_espresso_run(&[], "bs://app123", "bs://test456"); - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("empty")); - } - - #[test] - fn schedule_espresso_run_rejects_empty_app_url() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let result = - client.schedule_espresso_run(&["Google Pixel 7-13.0".to_string()], "", "bs://test456"); - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("app_url")); - } - - #[test] - fn schedule_espresso_run_rejects_empty_test_suite_url() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let result = client.schedule_espresso_run( - &["Google Pixel 7-13.0".to_string()], - "bs://app123", - "", - ); - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("test_suite_url")); - } - - #[test] - fn schedule_xcuitest_run_rejects_empty_devices() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let result = client.schedule_xcuitest_run(&[], "bs://app123", "bs://test456"); - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("empty")); - } - - #[test] - fn upload_xcuitest_app_rejects_missing_artifact() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let missing = Path::new("/tmp/nonexistent-ios-app.ipa"); - assert!(client.upload_xcuitest_app(missing).is_err()); - } - - #[test] - fn upload_xcuitest_test_suite_rejects_missing_artifact() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let missing = Path::new("/tmp/nonexistent-test-suite.zip"); - assert!(client.upload_xcuitest_test_suite(missing).is_err()); - } -} diff --git a/crates/bench-cli/src/main.rs b/crates/bench-cli/src/main.rs deleted file mode 100644 index 43310c9..0000000 --- a/crates/bench-cli/src/main.rs +++ /dev/null @@ -1,1052 +0,0 @@ -use anyhow::{Context, Result, anyhow, bail}; -use bench_runner::{BenchSpec, run_closure}; -use clap::{Parser, Subcommand, ValueEnum}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command as ProcessCommand; -use std::time::{Duration, Instant}; - -use browserstack::{BrowserStackAuth, BrowserStackClient}; - -mod browserstack; - -/// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. -#[derive(Parser, Debug)] -#[command(name = "bench-cli", author, version, about = "Mobile Rust benchmarking orchestrator", long_about = None)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// Run a benchmark against a target platform (mobile integration stub for now). - Run { - #[arg(long, value_enum)] - target: MobileTarget, - #[arg(long, help = "Fully-qualified Rust function to benchmark")] - function: String, - #[arg(long, default_value_t = 100)] - iterations: u32, - #[arg(long, default_value_t = 10)] - warmup: u32, - #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] - devices: Vec, - #[arg(long, help = "Optional path to config file")] - config: Option, - #[arg(long, help = "Optional output path for JSON report")] - output: Option, - #[arg(long, help = "Skip mobile builds and only run the host harness")] - local_only: bool, - #[arg( - long, - help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" - )] - ios_app: Option, - #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] - ios_test_suite: Option, - #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] - fetch: bool, - #[arg(long, default_value = "target/browserstack")] - fetch_output_dir: PathBuf, - #[arg(long, default_value_t = 10)] - fetch_poll_interval_secs: u64, - #[arg(long, default_value_t = 1800)] - fetch_timeout_secs: u64, - }, - /// Run a local demo against bundled sample functions to validate the harness. - Demo { - #[arg(long, default_value_t = 50)] - iterations: u32, - #[arg(long, default_value_t = 5)] - warmup: u32, - }, - /// Scaffold a base config file for the CLI. - Init { - #[arg(long, default_value = "bench-config.toml")] - output: PathBuf, - #[arg(long, value_enum, default_value_t = MobileTarget::Android)] - target: MobileTarget, - }, - /// Generate a sample device matrix file. - Plan { - #[arg(long, default_value = "device-matrix.yaml")] - output: PathBuf, - }, - /// Fetch BrowserStack build artifacts (logs, session JSON) for CI. - Fetch { - #[arg(long, value_enum)] - target: MobileTarget, - #[arg(long)] - build_id: String, - #[arg(long, default_value = "target/browserstack")] - output_dir: PathBuf, - #[arg(long, default_value_t = true)] - wait: bool, - #[arg(long, default_value_t = 10)] - poll_interval_secs: u64, - #[arg(long, default_value_t = 1800)] - timeout_secs: u64, - }, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -enum MobileTarget { - Android, - Ios, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct BrowserStackConfig { - app_automate_username: String, - app_automate_access_key: String, - project: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct IosXcuitestArtifacts { - app: PathBuf, - test_suite: PathBuf, -} - -#[derive(Debug, Serialize, Deserialize)] -struct BenchConfig { - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, - device_matrix: PathBuf, - browserstack: BrowserStackConfig, - #[serde(skip_serializing_if = "Option::is_none", default)] - ios_xcuitest: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct DeviceEntry { - name: String, - os: String, - os_version: String, - tags: Option>, -} - -#[derive(Debug, Serialize, Deserialize)] -struct DeviceMatrix { - devices: Vec, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct RunSpec { - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, - devices: Vec, - #[serde(skip_serializing, skip_deserializing, default)] - browserstack: Option, - #[serde(skip_serializing_if = "Option::is_none", default)] - ios_xcuitest: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "platform", rename_all = "lowercase")] -enum MobileArtifacts { - Android { - apk: PathBuf, - }, - Ios { - xcframework: PathBuf, - header: PathBuf, - #[serde(skip_serializing_if = "Option::is_none")] - app: Option, - #[serde(skip_serializing_if = "Option::is_none")] - test_suite: Option, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -struct RunSummary { - spec: RunSpec, - artifacts: Option, - local_report: Value, - remote_run: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "platform", rename_all = "lowercase")] -enum RemoteRun { - Android { - app_url: String, - build_id: String, - }, - Ios { - app_url: String, - test_suite_url: String, - build_id: String, - }, -} - -fn main() -> Result<()> { - load_dotenv(); - let cli = Cli::parse(); - match cli.command { - Command::Run { - target, - function, - iterations, - warmup, - devices, - config, - output, - local_only, - ios_app, - ios_test_suite, - fetch, - fetch_output_dir, - fetch_poll_interval_secs, - fetch_timeout_secs, - } => { - let spec = resolve_run_spec( - target, - function, - iterations, - warmup, - devices, - config.as_deref(), - ios_app, - ios_test_suite, - )?; - println!( - "Preparing benchmark run for {:?}: {} (iterations={}, warmup={})", - spec.target, spec.function, spec.iterations, spec.warmup - ); - persist_mobile_spec(&spec)?; - if !spec.devices.is_empty() { - println!("Devices: {}", spec.devices.join(", ")); - } - if let Some(path) = &output { - println!("JSON summary will be written to {:?}", path); - } - - let local_report = run_local_smoke(&spec)?; - let mut remote_run = None; - let artifacts = if local_only { - println!("Skipping mobile build: --local-only set"); - None - } else { - match spec.target { - MobileTarget::Android => { - let ndk = std::env::var("ANDROID_NDK_HOME") - .context("ANDROID_NDK_HOME must be set for Android builds")?; - let apk = run_android_build(&ndk)?; - println!("Built Android APK at {:?}", apk); - if spec.devices.is_empty() { - println!("Skipping BrowserStack upload/run: no devices provided"); - Some(MobileArtifacts::Android { apk }) - } else { - let run = trigger_browserstack_espresso(&spec, &apk)?; - remote_run = Some(run); - Some(MobileArtifacts::Android { apk }) - } - } - MobileTarget::Ios => { - let (xcframework, header) = run_ios_build()?; - println!("Built iOS xcframework at {:?}", xcframework); - let ios_xcuitest = spec.ios_xcuitest.clone(); - - if spec.devices.is_empty() { - println!("Skipping BrowserStack upload/run: no devices provided"); - } else { - let xcui = spec.ios_xcuitest.as_ref().context( - "iOS XCUITest artifacts required when targeting BrowserStack devices; provide --ios-app and --ios-test-suite or set ios_xcuitest in the config", - )?; - let run = trigger_browserstack_xcuitest(&spec, xcui)?; - remote_run = Some(run); - } - - Some(MobileArtifacts::Ios { - xcframework, - header, - app: ios_xcuitest.as_ref().map(|a| a.app.clone()), - test_suite: ios_xcuitest.map(|a| a.test_suite), - }) - } - } - }; - - let summary = RunSummary { - spec, - artifacts, - local_report, - remote_run, - }; - write_summary(&summary, output.as_deref())?; - - if fetch { - if let Some(remote) = &summary.remote_run { - let build_id = match remote { - RemoteRun::Android { build_id, .. } => build_id, - RemoteRun::Ios { build_id, .. } => build_id, - }; - let creds = resolve_browserstack_credentials(summary.spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username, - access_key: creds.access_key, - }, - creds.project, - )?; - let output_root = fetch_output_dir.join(build_id); - fetch_browserstack_artifacts( - &client, - summary.spec.target, - build_id, - &output_root, - true, - fetch_poll_interval_secs, - fetch_timeout_secs, - )?; - } else { - println!("No BrowserStack run to fetch (devices not provided?)"); - } - } - } - Command::Demo { iterations, warmup } => { - let spec = BenchSpec::new("sample_fns::fibonacci", iterations, warmup)?; - let report = run_closure(spec, || { - // This is the shape of the closure that will be invoked on-device; - // for now we reuse it locally. - let _ = sample_fns::fibonacci(24); - Ok(()) - })?; - - let json = serde_json::to_string_pretty(&report)?; - println!("{json}"); - } - Command::Init { output, target } => { - write_config_template(&output, target)?; - println!("Wrote starter config to {:?}", output); - } - Command::Plan { output } => { - write_device_matrix_template(&output)?; - println!("Wrote sample device matrix to {:?}", output); - } - Command::Fetch { - target, - build_id, - output_dir, - wait, - poll_interval_secs, - timeout_secs, - } => { - let creds = resolve_browserstack_credentials(None)?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username, - access_key: creds.access_key, - }, - creds.project, - )?; - let output_root = output_dir.join(&build_id); - fetch_browserstack_artifacts( - &client, - target, - &build_id, - &output_root, - wait, - poll_interval_secs, - timeout_secs, - )?; - } - } - - Ok(()) -} - -fn write_config_template(path: &Path, target: MobileTarget) -> Result<()> { - ensure_can_write(path)?; - - let ios_xcuitest = if target == MobileTarget::Ios { - Some(IosXcuitestArtifacts { - app: PathBuf::from("target/ios/BenchRunner.ipa"), - test_suite: PathBuf::from("target/ios/BenchRunnerUITests.zip"), - }) - } else { - None - }; - - let cfg = BenchConfig { - target, - function: "sample_fns::fibonacci".into(), - iterations: 100, - warmup: 10, - device_matrix: PathBuf::from("device-matrix.yaml"), - browserstack: BrowserStackConfig { - app_automate_username: "${BROWSERSTACK_USERNAME}".into(), - app_automate_access_key: "${BROWSERSTACK_ACCESS_KEY}".into(), - project: Some("mobile-bench-rs".into()), - }, - ios_xcuitest, - }; - - let contents = toml::to_string_pretty(&cfg)?; - write_file(path, contents.as_bytes()) -} - -fn write_device_matrix_template(path: &Path) -> Result<()> { - ensure_can_write(path)?; - - let matrix = DeviceMatrix { - devices: vec![ - DeviceEntry { - name: "Pixel 7".into(), - os: "android".into(), - os_version: "13.0".into(), - tags: Some(vec!["default".into(), "pixel".into()]), - }, - DeviceEntry { - name: "iPhone 14".into(), - os: "ios".into(), - os_version: "16".into(), - tags: Some(vec!["default".into(), "iphone".into()]), - }, - ], - }; - - let contents = serde_yaml::to_string(&matrix)?; - write_file(path, contents.as_bytes()) -} - -fn fetch_browserstack_artifacts( - client: &BrowserStackClient, - target: MobileTarget, - build_id: &str, - output_root: &Path, - wait: bool, - poll_interval_secs: u64, - timeout_secs: u64, -) -> Result<()> { - fs::create_dir_all(output_root) - .with_context(|| format!("creating output dir {:?}", output_root))?; - - let base = browserstack_base_path(target); - let build_path = format!("{base}/builds/{build_id}"); - let sessions_path = format!("{base}/builds/{build_id}/sessions"); - - if wait { - wait_for_build(client, &build_path, poll_interval_secs, timeout_secs)?; - } - - let build_json = client.get_json(&build_path)?; - write_json(output_root.join("build.json"), &build_json)?; - - let sessions_json = match client.get_json(&sessions_path) { - Ok(value) => { - write_json(output_root.join("sessions.json"), &value)?; - Some(value) - } - Err(err) => { - let msg = shorten_html_error(&err.to_string()); - println!("Sessions endpoint unavailable; falling back to build.json: {msg}"); - None - } - }; - - let session_ids = extract_session_ids(sessions_json.as_ref().unwrap_or(&build_json)); - if session_ids.is_empty() { - println!("No sessions found for build {}", build_id); - return Ok(()); - } - - for session_id in session_ids { - let session_path = format!("{base}/builds/{build_id}/sessions/{session_id}"); - let session_json = client.get_json(&session_path)?; - let session_dir = output_root.join(format!("session-{}", session_id)); - fs::create_dir_all(&session_dir) - .with_context(|| format!("creating session dir {:?}", session_dir))?; - write_json(session_dir.join("session.json"), &session_json)?; - - let mut bench_report: Option = None; - for (key, url) in extract_url_fields(&session_json) { - let file_name = filename_for_url(&key, &url); - let dest = session_dir.join(file_name); - if let Err(err) = client.download_url(&url, &dest) { - println!("Skipping download for {key}: {err}"); - continue; - } - if key.contains("device_log") || key.contains("instrumentation_log") || key.contains("app_log") { - if let Ok(contents) = fs::read_to_string(&dest) { - if let Some(parsed) = extract_bench_json(&contents) { - bench_report = Some(parsed); - } - } - } - } - - if let Some(report) = bench_report { - write_json(session_dir.join("bench-report.json"), &report)?; - } - } - - println!("Fetched BrowserStack artifacts to {:?}", output_root); - Ok(()) -} - -fn browserstack_base_path(target: MobileTarget) -> &'static str { - match target { - MobileTarget::Android => "app-automate/espresso/v2", - MobileTarget::Ios => "app-automate/xcuitest/v2", - } -} - -fn wait_for_build( - client: &BrowserStackClient, - build_path: &str, - poll_interval_secs: u64, - timeout_secs: u64, -) -> Result<()> { - let deadline = Instant::now() + Duration::from_secs(timeout_secs); - loop { - let build_json = client.get_json(build_path)?; - if let Some(status) = build_json - .get("status") - .and_then(|val| val.as_str()) - .map(|val| val.to_lowercase()) - { - if status == "failed" || status == "error" { - println!("Build status: {status}"); - return Ok(()); - } - if status == "done" || status == "passed" || status == "completed" { - println!("Build status: {status}"); - return Ok(()); - } - println!("Build status: {status} (waiting)"); - } else { - println!("Build status missing; continuing without wait"); - return Ok(()); - } - - if Instant::now() >= deadline { - println!("Timed out waiting for build status"); - return Ok(()); - } - std::thread::sleep(Duration::from_secs(poll_interval_secs)); - } -} - -fn extract_session_ids(value: &Value) -> Vec { - let sessions = value - .get("sessions") - .and_then(|val| val.as_array()) - .or_else(|| value.as_array()); - let mut ids = Vec::new(); - if let Some(entries) = sessions { - for entry in entries { - let id = entry - .get("id") - .or_else(|| entry.get("session_id")) - .or_else(|| entry.get("sessionId")) - .and_then(|val| val.as_str()); - if let Some(id) = id { - ids.push(id.to_string()); - } - } - } - if ids.is_empty() { - if let Some(devices) = value.get("devices").and_then(|val| val.as_array()) { - for device in devices { - if let Some(sessions) = device.get("sessions").and_then(|val| val.as_array()) { - for entry in sessions { - if let Some(id) = entry.get("id").and_then(|val| val.as_str()) { - ids.push(id.to_string()); - } - } - } - } - } - } - ids -} - -fn extract_url_fields(value: &Value) -> Vec<(String, String)> { - let mut urls = Vec::new(); - extract_url_fields_recursive(value, "", &mut urls); - urls -} - -fn extract_url_fields_recursive(value: &Value, prefix: &str, out: &mut Vec<(String, String)>) { - match value { - Value::Object(map) => { - for (key, val) in map { - let next = if prefix.is_empty() { - key.clone() - } else { - format!("{}.{}", prefix, key) - }; - if let Value::String(url) = val { - if url.starts_with("http") || url.starts_with("bs://") { - out.push((next.clone(), url.clone())); - } - } - extract_url_fields_recursive(val, &next, out); - } - } - Value::Array(items) => { - for (idx, val) in items.iter().enumerate() { - let next = format!("{}[{}]", prefix, idx); - extract_url_fields_recursive(val, &next, out); - } - } - _ => {} - } -} - -fn filename_for_url(key: &str, url: &str) -> String { - let stripped = url.split('?').next().unwrap_or(url); - let ext = Path::new(stripped) - .extension() - .and_then(|val| val.to_str()) - .unwrap_or("log"); - let mut safe = String::with_capacity(key.len()); - for ch in key.chars() { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - safe.push(ch); - } else { - safe.push('_'); - } - } - format!("{}.{}", safe, ext) -} - -fn extract_bench_json(contents: &str) -> Option { - let marker = "BENCH_JSON "; - for line in contents.lines().rev() { - if let Some(idx) = line.find(marker) { - let json_part = &line[idx + marker.len()..]; - if let Ok(value) = serde_json::from_str::(json_part) { - return Some(value); - } - } - } - None -} - -fn write_json(path: PathBuf, value: &Value) -> Result<()> { - let contents = serde_json::to_string_pretty(value)?; - write_file(&path, contents.as_bytes()) -} - -fn shorten_html_error(message: &str) -> String { - if message.contains("") || message.contains(", - config: Option<&Path>, - ios_app: Option, - ios_test_suite: Option, -) -> Result { - if let Some(cfg_path) = config { - let cfg = load_config(cfg_path)?; - let matrix = load_device_matrix(&cfg.device_matrix)?; - let device_names = matrix.devices.into_iter().map(|d| d.name).collect(); - return Ok(RunSpec { - target: cfg.target, - function: cfg.function, - iterations: cfg.iterations, - warmup: cfg.warmup, - devices: device_names, - browserstack: Some(cfg.browserstack), - ios_xcuitest: cfg.ios_xcuitest, - }); - } - - if function.trim().is_empty() { - bail!("function must not be empty"); - } - - let ios_xcuitest = match (ios_app, ios_test_suite) { - (Some(app), Some(test_suite)) => Some(IosXcuitestArtifacts { app, test_suite }), - (None, None) => None, - _ => bail!("both --ios-app and --ios-test-suite must be provided together"), - }; - - if target == MobileTarget::Ios && !devices.is_empty() && ios_xcuitest.is_none() { - bail!( - "iOS BrowserStack runs require --ios-app and --ios-test-suite or an ios_xcuitest config block" - ); - } - - Ok(RunSpec { - target, - function, - iterations, - warmup, - devices, - browserstack: None, - ios_xcuitest, - }) -} - -fn load_config(path: &Path) -> Result { - let contents = - fs::read_to_string(path).with_context(|| format!("reading config {:?}", path))?; - toml::from_str(&contents).with_context(|| format!("parsing config {:?}", path)) -} - -fn load_device_matrix(path: &Path) -> Result { - let contents = - fs::read_to_string(path).with_context(|| format!("reading device matrix {:?}", path))?; - serde_yaml::from_str(&contents).with_context(|| format!("parsing device matrix {:?}", path)) -} - -fn run_ios_build() -> Result<(PathBuf, PathBuf)> { - let root = repo_root()?; - run_cmd(ProcessCommand::new(root.join("scripts/build-ios.sh")).current_dir(&root))?; - - Ok(( - root.join("target/ios/sample_fns.xcframework"), - root.join("target/ios/include/sample_fns.h"), - )) -} - -#[derive(Debug, Clone)] -struct ResolvedBrowserStack { - username: String, - access_key: String, - project: Option, -} - -fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path) -> Result { - let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username.clone(), - access_key: creds.access_key.clone(), - }, - creds.project.clone(), - )?; - - // Upload the app-under-test APK. - let upload = client.upload_espresso_app(apk)?; - - // Upload the Espresso test-suite APK produced by Gradle. - // We rely on the standard androidTest debug output path. - let root = repo_root()?; - let test_apk = - root.join("android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk"); - let test_upload = client.upload_espresso_test_suite(&test_apk)?; - - // Schedule the Espresso build with both app and testSuite, as required by BrowserStack. - let run = client.schedule_espresso_run( - &spec.devices, - &upload.app_url, - &test_upload.test_suite_url, - )?; - println!( - "Queued BrowserStack Espresso build {} for devices: {}", - run.build_id, - spec.devices.join(", ") - ); - - Ok(RemoteRun::Android { - app_url: upload.app_url, - build_id: run.build_id, - }) -} - -fn trigger_browserstack_xcuitest( - spec: &RunSpec, - artifacts: &IosXcuitestArtifacts, -) -> Result { - let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username.clone(), - access_key: creds.access_key.clone(), - }, - creds.project.clone(), - )?; - - if !artifacts.app.exists() { - bail!( - "iOS app artifact not found at {:?}; provide a .ipa or zipped .app", - artifacts.app - ); - } - if !artifacts.test_suite.exists() { - bail!( - "iOS XCUITest test suite artifact not found at {:?}; provide the zipped test runner bundle", - artifacts.test_suite - ); - } - - let app_upload = client.upload_xcuitest_app(&artifacts.app)?; - let test_upload = client.upload_xcuitest_test_suite(&artifacts.test_suite)?; - let run = client.schedule_xcuitest_run( - &spec.devices, - &app_upload.app_url, - &test_upload.test_suite_url, - )?; - println!( - "Queued BrowserStack XCUITest build {} for devices: {}", - run.build_id, - spec.devices.join(", ") - ); - - Ok(RemoteRun::Ios { - app_url: app_upload.app_url, - test_suite_url: test_upload.test_suite_url, - build_id: run.build_id, - }) -} - -fn resolve_browserstack_credentials( - config: Option<&BrowserStackConfig>, -) -> Result { - let mut username = None; - let mut access_key = None; - let mut project = None; - - if let Some(cfg) = config { - username = Some(expand_env_var(&cfg.app_automate_username)?); - access_key = Some(expand_env_var(&cfg.app_automate_access_key)?); - project = cfg - .project - .as_ref() - .map(|p| expand_env_var(p)) - .transpose()?; - } - - if username.as_deref().map(str::is_empty).unwrap_or(true) { - if let Ok(val) = env::var("BROWSERSTACK_USERNAME") { - if !val.is_empty() { - username = Some(val); - } - } - } - if access_key.as_deref().map(str::is_empty).unwrap_or(true) { - if let Ok(val) = env::var("BROWSERSTACK_ACCESS_KEY") { - if !val.is_empty() { - access_key = Some(val); - } - } - } - if project.is_none() { - if let Ok(val) = env::var("BROWSERSTACK_PROJECT") { - if !val.is_empty() { - project = Some(val); - } - } - } - - let username = username.filter(|s| !s.is_empty()).ok_or_else(|| { - anyhow!("BrowserStack username missing; set BROWSERSTACK_USERNAME or provide in config") - })?; - let access_key = access_key.filter(|s| !s.is_empty()).ok_or_else(|| { - anyhow!("BrowserStack access key missing; set BROWSERSTACK_ACCESS_KEY or provide in config") - })?; - - Ok(ResolvedBrowserStack { - username, - access_key, - project, - }) -} - -fn expand_env_var(raw: &str) -> Result { - if let Some(stripped) = raw.strip_prefix("${").and_then(|s| s.strip_suffix('}')) { - let val = env::var(stripped) - .with_context(|| format!("resolving env var {stripped} for BrowserStack config"))?; - return Ok(val); - } - Ok(raw.to_string()) -} - -fn run_local_smoke(spec: &RunSpec) -> Result { - let bench_spec = sample_fns::BenchSpec { - name: spec.function.clone(), - iterations: spec.iterations, - warmup: spec.warmup, - }; - - let report = sample_fns::run_benchmark(bench_spec) - .map_err(|e| anyhow!("benchmark failed: {:?}", e))?; - - serde_json::to_value(&report).context("serializing benchmark report") -} - -fn persist_mobile_spec(spec: &RunSpec) -> Result<()> { - let root = repo_root()?; - let payload = json!({ - "function": spec.function, - "iterations": spec.iterations, - "warmup": spec.warmup, - }); - let contents = serde_json::to_string_pretty(&payload)?; - let targets = [ - root.join("target/mobile-spec/android/bench_spec.json"), - root.join("target/mobile-spec/ios/bench_spec.json"), - ]; - for path in targets { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("creating directory {:?}", parent))?; - } - write_file(&path, contents.as_bytes())?; - } - Ok(()) -} - -fn write_summary(summary: &RunSummary, output: Option<&Path>) -> Result<()> { - let json = serde_json::to_string_pretty(summary)?; - if let Some(path) = output { - write_file(path, json.as_bytes())?; - println!("Wrote run summary to {:?}", path); - } else { - println!("{json}"); - } - Ok(()) -} - -fn run_android_build(ndk_home: &str) -> Result { - let root = repo_root()?; - run_cmd( - ProcessCommand::new(root.join("scripts/build-android.sh")) - .env("ANDROID_NDK_HOME", ndk_home) - .current_dir(&root), - )?; - run_cmd( - ProcessCommand::new(root.join("scripts/sync-android-libs.sh")) - .env("ANDROID_NDK_HOME", ndk_home) - .current_dir(&root), - )?; - run_cmd( - ProcessCommand::new(root.join("android/gradlew")) - .arg(":app:assembleDebug") - .current_dir(root.join("android")), - )?; - - // Also build the androidTest (Espresso) test APK so it can be uploaded as the test suite. - run_cmd( - ProcessCommand::new(root.join("android/gradlew")) - .arg(":app:assembleAndroidTest") - .current_dir(root.join("android")), - )?; - - Ok(root.join("android/app/build/outputs/apk/debug/app-debug.apk")) -} - -fn load_dotenv() { - if let Ok(root) = repo_root() { - let path = root.join(".env.local"); - let _ = dotenvy::from_path(path); - } -} - -fn run_cmd(cmd: &mut ProcessCommand) -> Result<()> { - let desc = format!("{:?}", cmd); - let status = cmd.status().with_context(|| format!("running {desc}"))?; - if !status.success() { - bail!("command failed: {desc}"); - } - Ok(()) -} - -fn repo_root() -> Result { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .canonicalize() - .context("resolving repo root")?; - Ok(path) -} - -fn ensure_can_write(path: &Path) -> Result<()> { - if path.exists() { - bail!("refusing to overwrite existing file: {:?}", path); - } - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent) - .with_context(|| format!("creating parent directory {:?}", parent))?; - } - Ok(()) -} - -fn write_file(path: &Path, contents: &[u8]) -> Result<()> { - fs::write(path, contents).with_context(|| format!("writing file {:?}", path)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolves_cli_spec() { - let spec = resolve_run_spec( - MobileTarget::Android, - "sample_fns::fibonacci".into(), - 5, - 1, - vec!["pixel".into()], - None, - None, - None, - ) - .unwrap(); - assert_eq!(spec.function, "sample_fns::fibonacci"); - assert_eq!(spec.iterations, 5); - assert_eq!(spec.warmup, 1); - assert_eq!(spec.devices, vec!["pixel".to_string()]); - assert!(spec.browserstack.is_none()); - assert!(spec.ios_xcuitest.is_none()); - } - - #[test] - fn local_smoke_produces_samples() { - let spec = RunSpec { - target: MobileTarget::Android, - function: "sample_fns::fibonacci".into(), - iterations: 3, - warmup: 1, - devices: vec![], - browserstack: None, - ios_xcuitest: None, - }; - let report = run_local_smoke(&spec).expect("local harness"); - assert!(report["samples"].is_array()); - assert_eq!(report["spec"]["name"], "sample_fns::fibonacci"); - } - - #[test] - fn ios_requires_artifacts_for_browserstack() { - let err = resolve_run_spec( - MobileTarget::Ios, - "sample_fns::fibonacci".into(), - 1, - 0, - vec!["iphone".into()], - None, - None, - None, - ) - .unwrap_err(); - assert!( - err.to_string() - .contains("iOS BrowserStack runs require --ios-app and --ios-test-suite") - ); - } -} diff --git a/crates/bench-runner/Cargo.toml b/crates/bench-runner/Cargo.toml deleted file mode 100644 index ba4428a..0000000 --- a/crates/bench-runner/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "bench-runner" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -serde.workspace = true -thiserror.workspace = true diff --git a/crates/bench-runner/src/lib.rs b/crates/bench-runner/src/lib.rs deleted file mode 100644 index 915109b..0000000 --- a/crates/bench-runner/src/lib.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Shared benchmarking harness that will be compiled into mobile targets. -//! For now this runs on the host and provides the same API surface we will -//! expose over FFI to Kotlin/Swift. - -use serde::{Deserialize, Serialize}; -use std::time::{Duration, Instant}; -use thiserror::Error; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BenchSpec { - pub name: String, - pub iterations: u32, - pub warmup: u32, -} - -impl BenchSpec { - pub fn new(name: impl Into, iterations: u32, warmup: u32) -> Result { - if iterations == 0 { - return Err(BenchError::NoIterations); - } - - Ok(Self { - name: name.into(), - iterations, - warmup, - }) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BenchSample { - pub duration_ns: u64, -} - -impl BenchSample { - fn from_duration(duration: Duration) -> Self { - Self { - duration_ns: duration.as_nanos() as u64, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BenchReport { - pub spec: BenchSpec, - pub samples: Vec, -} - -#[derive(Debug, Error)] -pub enum BenchError { - #[error("iterations must be greater than zero")] - NoIterations, - #[error("benchmark function failed: {0}")] - Execution(String), -} - -pub fn run_closure(spec: BenchSpec, mut f: F) -> Result -where - F: FnMut() -> Result<(), BenchError>, -{ - if spec.iterations == 0 { - return Err(BenchError::NoIterations); - } - - for _ in 0..spec.warmup { - f()?; - } - - let mut samples = Vec::with_capacity(spec.iterations as usize); - for _ in 0..spec.iterations { - let start = Instant::now(); - f()?; - samples.push(BenchSample::from_duration(start.elapsed())); - } - - Ok(BenchReport { spec, samples }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn runs_benchmark() { - let spec = BenchSpec::new("noop", 3, 1).unwrap(); - let report = run_closure(spec, || Ok(())).unwrap(); - - assert_eq!(report.samples.len(), 3); - let non_zero = report.samples.iter().filter(|s| s.duration_ns > 0).count(); - assert!(non_zero >= 1); - } -} diff --git a/crates/mobench-sdk/src/runner.rs b/crates/mobench-sdk/src/runner.rs index 26b7dba..3c43d20 100644 --- a/crates/mobench-sdk/src/runner.rs +++ b/crates/mobench-sdk/src/runner.rs @@ -44,7 +44,7 @@ pub fn run_benchmark(spec: BenchSpec) -> Result { let closure = || (bench_fn.invoke)(&[]).map_err(|e| mobench_runner::BenchError::Execution(e.to_string())); - // Run the benchmark using bench-runner's timing infrastructure + // Run the benchmark using mobench-runner's timing infrastructure let report = run_closure(spec, closure)?; Ok(report) diff --git a/crates/sample-fns/src/lib.rs b/crates/sample-fns/src/lib.rs index e1c128d..67c1335 100644 --- a/crates/sample-fns/src/lib.rs +++ b/crates/sample-fns/src/lib.rs @@ -42,7 +42,7 @@ pub enum BenchError { // Generate UniFFI scaffolding from proc macros uniffi::setup_scaffolding!(); -// Conversion from bench-runner types +// Conversion from mobench-runner types impl From for BenchSpec { fn from(spec: mobench_runner::BenchSpec) -> Self { Self { diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index 4b8100d..63a922a 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -5,7 +5,7 @@ set -euo pipefail # This script is legacy tooling for developing this repository. # # For SDK integrators, use instead: -# cargo run -p bench-cli -- build --target android +# cargo mobench build --target android # # This command does everything this script does, but in pure Rust with no dependencies # on having this repo's scripts/ directory locally. diff --git a/scripts/build-android.sh b/scripts/build-android.sh index b00bbeb..c065bd7 100755 --- a/scripts/build-android.sh +++ b/scripts/build-android.sh @@ -5,7 +5,7 @@ set -euo pipefail # This script is legacy tooling for developing this repository. # # For SDK integrators, use instead: -# cargo run -p bench-cli -- build --target android +# cargo mobench build --target android # # The CLI command handles all build steps automatically. diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh index 1fb3fcb..5a6ef18 100755 --- a/scripts/build-ios.sh +++ b/scripts/build-ios.sh @@ -5,7 +5,7 @@ set -euo pipefail # This script is legacy tooling for developing this repository. # # For SDK integrators, use instead: -# cargo run -p bench-cli -- build --target ios +# cargo mobench build --target ios # # The CLI command handles all build steps automatically including xcframework # creation, binding generation, and code signing. @@ -182,7 +182,7 @@ EOF echo "✓ iOS build complete. XCFramework created at: ${XCFRAMEWORK_PATH}" -# Copy public header for CLI consumers (matches bench-cli expectation) +# Copy public header for CLI consumers (matches mobench output layout) INCLUDE_DIR="${OUTPUT_DIR}/include" mkdir -p "${INCLUDE_DIR}" if [[ -f "${UNIFFI_HEADER}" ]]; then diff --git a/scripts/generate-bindings.sh b/scripts/generate-bindings.sh index 94ee5a3..4d2087e 100755 --- a/scripts/generate-bindings.sh +++ b/scripts/generate-bindings.sh @@ -5,7 +5,7 @@ set -euo pipefail # This script is legacy tooling for developing this repository. # # For SDK integrators, bindings are automatically generated during: -# cargo run -p bench-cli -- build --target +# cargo mobench build --target # # You don't need to call this script separately. diff --git a/scripts/sync-android-libs.sh b/scripts/sync-android-libs.sh index 4079b1f..2809f5e 100755 --- a/scripts/sync-android-libs.sh +++ b/scripts/sync-android-libs.sh @@ -5,7 +5,7 @@ set -euo pipefail # This script is legacy tooling for developing this repository. # # For SDK integrators, use instead: -# cargo run -p bench-cli -- build --target android +# cargo mobench build --target android # # The CLI command automatically handles library copying. From 625ab167e4e57e3d98134a93c3f3c59482f1c4b7 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 16 Jan 2026 16:56:16 +0100 Subject: [PATCH 011/196] Split examples into minimal and FFI variants Simplify basic-benchmark to only show #[benchmark] usage and add a new ffi-benchmark example that contains the UniFFI types and entrypoint. Update workspace metadata and docs to reflect the new example layout. --- Cargo.lock | 16 +- Cargo.toml | 1 + README.md | 3 +- examples/basic-benchmark/Cargo.toml | 7 - examples/basic-benchmark/build.rs | 3 +- examples/basic-benchmark/src/lib.rs | 152 ++---------------- examples/ffi-benchmark/Cargo.toml | 20 +++ examples/ffi-benchmark/src/lib.rs | 231 ++++++++++++++++++++++++++++ 8 files changed, 277 insertions(+), 156 deletions(-) create mode 100644 examples/ffi-benchmark/Cargo.toml create mode 100644 examples/ffi-benchmark/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 79eaeb1..f6d1662 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,10 +123,6 @@ version = "0.1.0" dependencies = [ "inventory", "mobench-sdk", - "serde", - "serde_json", - "thiserror 1.0.69", - "uniffi", ] [[package]] @@ -313,6 +309,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ffi-benchmark" +version = "0.1.0" +dependencies = [ + "inventory", + "mobench-sdk", + "serde", + "serde_json", + "thiserror 1.0.69", + "uniffi", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index 054991f..c125948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/mobench-sdk", "crates/sample-fns", "examples/basic-benchmark", + "examples/ffi-benchmark", ] resolver = "2" diff --git a/README.md b/README.md index f3bb3e6..f354fa4 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ mobench provides a Rust API and a CLI for running benchmarks on real mobile devi - `crates/mobench-macros` ([mobench-macros](https://crates.io/crates/mobench-macros)): `#[benchmark]` proc macro - `crates/mobench-runner` ([mobench-runner](https://crates.io/crates/mobench-runner)): lightweight timing harness - `crates/sample-fns`: sample benchmarks and UniFFI bindings -- `examples/basic-benchmark`: example SDK integration crate +- `examples/basic-benchmark`: minimal SDK integration example +- `examples/ffi-benchmark`: full UniFFI/FFI surface example ## Quick start diff --git a/examples/basic-benchmark/Cargo.toml b/examples/basic-benchmark/Cargo.toml index d69a4b0..a89e5fe 100644 --- a/examples/basic-benchmark/Cargo.toml +++ b/examples/basic-benchmark/Cargo.toml @@ -12,10 +12,3 @@ crate-type = ["lib", "cdylib", "staticlib"] # Use mobench-sdk for the #[benchmark] macro and registry mobench-sdk = { path = "../../crates/mobench-sdk" } inventory.workspace = true -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uniffi = { workspace = true, features = ["cli"] } -thiserror.workspace = true - -[build-dependencies] -uniffi = { workspace = true, features = ["build"] } diff --git a/examples/basic-benchmark/build.rs b/examples/basic-benchmark/build.rs index 8ac5b0a..c003aab 100644 --- a/examples/basic-benchmark/build.rs +++ b/examples/basic-benchmark/build.rs @@ -1,4 +1,3 @@ fn main() { - // Scaffolding is now generated via proc macros (uniffi::setup_scaffolding! in lib.rs) - // No UDL file processing needed + // No build-time steps required for the minimal example. } diff --git a/examples/basic-benchmark/src/lib.rs b/examples/basic-benchmark/src/lib.rs index 71f5292..2aad37e 100644 --- a/examples/basic-benchmark/src/lib.rs +++ b/examples/basic-benchmark/src/lib.rs @@ -1,113 +1,13 @@ -//! Basic benchmark examples demonstrating mobench-sdk usage +//! Basic benchmark examples demonstrating mobench-sdk usage. //! -//! This example crate shows how to write benchmarks using the mobench-sdk -//! with the #[benchmark] attribute macro. +//! This example keeps things minimal: register functions with #[benchmark] and +//! let the SDK handle discovery and execution. See `examples/ffi-benchmark` for +//! a full UniFFI-based FFI surface. use mobench_sdk::benchmark; const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; -/// Specification for a benchmark run. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] -pub struct BenchSpec { - pub name: String, - pub iterations: u32, - pub warmup: u32, -} - -/// A single benchmark sample with timing information. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] -pub struct BenchSample { - pub duration_ns: u64, -} - -/// Complete benchmark report with spec and timing samples. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] -pub struct BenchReport { - pub spec: BenchSpec, - pub samples: Vec, -} - -/// Error types for benchmark operations. -#[derive(Debug, thiserror::Error, uniffi::Error)] -#[uniffi(flat_error)] -pub enum BenchError { - #[error("iterations must be greater than zero")] - InvalidIterations, - - #[error("unknown benchmark function: {name}")] - UnknownFunction { name: String }, - - #[error("benchmark execution failed: {reason}")] - ExecutionFailed { reason: String }, -} - -// Generate UniFFI scaffolding from proc macros -uniffi::setup_scaffolding!(); - -// Conversion from mobench-sdk types -impl From for BenchSpec { - fn from(spec: mobench_sdk::BenchSpec) -> Self { - Self { - name: spec.name, - iterations: spec.iterations, - warmup: spec.warmup, - } - } -} - -impl From for mobench_sdk::BenchSpec { - fn from(spec: BenchSpec) -> Self { - Self { - name: spec.name, - iterations: spec.iterations, - warmup: spec.warmup, - } - } -} - -impl From for BenchSample { - fn from(sample: mobench_sdk::BenchSample) -> Self { - Self { - duration_ns: sample.duration_ns, - } - } -} - -impl From for BenchReport { - fn from(report: mobench_sdk::RunnerReport) -> Self { - Self { - spec: report.spec.into(), - samples: report.samples.into_iter().map(Into::into).collect(), - } - } -} - -impl From for BenchError { - fn from(err: mobench_sdk::BenchError) -> Self { - match err { - mobench_sdk::BenchError::Runner(runner_err) => BenchError::ExecutionFailed { - reason: runner_err.to_string(), - }, - mobench_sdk::BenchError::UnknownFunction(name) => BenchError::UnknownFunction { name }, - _ => BenchError::ExecutionFailed { - reason: err.to_string(), - }, - } - } -} - -/// Run a benchmark by name with the given specification -/// -/// This is the main FFI entry point called from mobile platforms. -/// It uses mobench-sdk's registry to discover and execute benchmarks. -#[uniffi::export] -pub fn run_benchmark(spec: BenchSpec) -> Result { - let sdk_spec: mobench_sdk::BenchSpec = spec.into(); - let report = mobench_sdk::run_benchmark(sdk_spec)?; - Ok(report.into()) -} - /// Compute fibonacci number iteratively. pub fn fibonacci(n: u32) -> u64 { match n { @@ -181,51 +81,19 @@ mod tests { } #[test] - fn test_run_benchmark_via_registry() { - // Test that benchmarks can be discovered via the registry + fn test_discover_benchmarks() { let benchmarks = mobench_sdk::discover_benchmarks(); assert!(benchmarks.len() >= 2, "Should find at least 2 benchmarks"); + } - // Test execution via FFI using registry name - let spec = BenchSpec { + #[test] + fn test_run_benchmark_via_sdk() { + let spec = mobench_sdk::BenchSpec { name: "basic_benchmark::bench_fibonacci".to_string(), iterations: 3, warmup: 1, }; - let report = run_benchmark(spec).unwrap(); + let report = mobench_sdk::run_benchmark(spec).unwrap(); assert_eq!(report.samples.len(), 3); } - - #[test] - fn test_run_benchmark_checksum() { - let spec = BenchSpec { - name: "basic_benchmark::bench_checksum".to_string(), - iterations: 2, - warmup: 0, - }; - let report = run_benchmark(spec).unwrap(); - assert_eq!(report.samples.len(), 2); - } - - #[test] - fn test_unknown_function_error() { - let spec = BenchSpec { - name: "unknown".to_string(), - iterations: 1, - warmup: 0, - }; - let result = run_benchmark(spec); - assert!(matches!(result, Err(BenchError::UnknownFunction { .. }))); - } - - #[test] - fn test_invalid_iterations() { - let spec = BenchSpec { - name: "basic_benchmark::bench_fibonacci".to_string(), - iterations: 0, - warmup: 0, - }; - let result = run_benchmark(spec); - assert!(matches!(result, Err(BenchError::ExecutionFailed { .. }))); - } } diff --git a/examples/ffi-benchmark/Cargo.toml b/examples/ffi-benchmark/Cargo.toml new file mode 100644 index 0000000..ec42edd --- /dev/null +++ b/examples/ffi-benchmark/Cargo.toml @@ -0,0 +1,20 @@ + [package] + name = "ffi-benchmark" + version = "0.1.0" + edition = "2021" + license.workspace = true + + [lib] + name = "ffi_benchmark" + crate-type = ["lib", "cdylib", "staticlib"] + + [dependencies] + mobench-sdk = { path = "../../crates/mobench-sdk" } + inventory.workspace = true + serde = { version = "1", features = ["derive"] } + serde_json = "1" + uniffi = { workspace = true, features = ["cli"] } + thiserror.workspace = true + + [build-dependencies] + uniffi = { workspace = true, features = ["build"] } diff --git a/examples/ffi-benchmark/src/lib.rs b/examples/ffi-benchmark/src/lib.rs new file mode 100644 index 0000000..84e524d --- /dev/null +++ b/examples/ffi-benchmark/src/lib.rs @@ -0,0 +1,231 @@ +//! FFI benchmark example demonstrating UniFFI integration. +//! +//! This example shows how to define a full FFI surface (types, errors, and +//! `run_benchmark`) for Kotlin/Swift bindings. For the minimal SDK-only usage, +//! see `examples/basic-benchmark`. + +use mobench_sdk::benchmark; + +const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; + +/// Specification for a benchmark run. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} + +/// A single benchmark sample with timing information. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchSample { + pub duration_ns: u64, +} + +/// Complete benchmark report with spec and timing samples. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchReport { + pub spec: BenchSpec, + pub samples: Vec, +} + +/// Error types for benchmark operations. +#[derive(Debug, thiserror::Error, uniffi::Error)] +#[uniffi(flat_error)] +pub enum BenchError { + #[error("iterations must be greater than zero")] + InvalidIterations, + + #[error("unknown benchmark function: {name}")] + UnknownFunction { name: String }, + + #[error("benchmark execution failed: {reason}")] + ExecutionFailed { reason: String }, +} + +// Generate UniFFI scaffolding from proc macros +uniffi::setup_scaffolding!(); + +// Conversion from mobench-sdk types +impl From for BenchSpec { + fn from(spec: mobench_sdk::BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for mobench_sdk::BenchSpec { + fn from(spec: BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for BenchSample { + fn from(sample: mobench_sdk::BenchSample) -> Self { + Self { + duration_ns: sample.duration_ns, + } + } +} + +impl From for BenchReport { + fn from(report: mobench_sdk::RunnerReport) -> Self { + Self { + spec: report.spec.into(), + samples: report.samples.into_iter().map(Into::into).collect(), + } + } +} + +impl From for BenchError { + fn from(err: mobench_sdk::BenchError) -> Self { + match err { + mobench_sdk::BenchError::Runner(runner_err) => BenchError::ExecutionFailed { + reason: runner_err.to_string(), + }, + mobench_sdk::BenchError::UnknownFunction(name) => BenchError::UnknownFunction { name }, + _ => BenchError::ExecutionFailed { + reason: err.to_string(), + }, + } + } +} + +/// Run a benchmark by name with the given specification. +/// +/// This is the main FFI entry point called from mobile platforms. +#[uniffi::export] +pub fn run_benchmark(spec: BenchSpec) -> Result { + let sdk_spec: mobench_sdk::BenchSpec = spec.into(); + let report = mobench_sdk::run_benchmark(sdk_spec)?; + Ok(report.into()) +} + +/// Compute fibonacci number iteratively. +pub fn fibonacci(n: u32) -> u64 { + match n { + 0 => 0, + 1 => 1, + _ => { + let mut a = 0u64; + let mut b = 1u64; + for _ in 2..=n { + let next = a.wrapping_add(b); + a = b; + b = next; + } + b + } + } +} + +/// Compute fibonacci in a more measurable way by doing it multiple times. +pub fn fibonacci_batch(n: u32, iterations: u32) -> u64 { + let mut result = 0u64; + for _ in 0..iterations { + result = result.wrapping_add(fibonacci(n)); + } + result +} + +/// Compute checksum by summing all bytes. +pub fn checksum(bytes: &[u8]) -> u64 { + bytes.iter().map(|&b| b as u64).sum() +} + +// ============================================================================ +// Benchmark Functions +// ============================================================================ +// These functions are marked with #[benchmark] and automatically registered +// with mobench-sdk's registry system. + +/// Benchmark: Fibonacci calculation (30th number, 1000 iterations) +#[benchmark] +pub fn bench_fibonacci() { + let result = fibonacci_batch(30, 1000); + std::hint::black_box(result); +} + +/// Benchmark: Checksum calculation on 1KB data (10000 iterations) +#[benchmark] +pub fn bench_checksum() { + let mut sum = 0u64; + for _ in 0..10000 { + sum = sum.wrapping_add(checksum(&CHECKSUM_INPUT)); + } + std::hint::black_box(sum); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fib_sequence() { + assert_eq!(fibonacci(0), 0); + assert_eq!(fibonacci(1), 1); + assert_eq!(fibonacci(10), 55); + assert_eq!(fibonacci(24), 46368); + } + + #[test] + fn checksum_matches() { + assert_eq!(checksum(&CHECKSUM_INPUT), 1024); + } + + #[test] + fn test_run_benchmark_via_registry() { + // Test that benchmarks can be discovered via the registry + let benchmarks = mobench_sdk::discover_benchmarks(); + assert!(benchmarks.len() >= 2, "Should find at least 2 benchmarks"); + + // Test execution via FFI using registry name + let spec = BenchSpec { + name: "ffi_benchmark::bench_fibonacci".to_string(), + iterations: 3, + warmup: 1, + }; + let report = run_benchmark(spec).unwrap(); + assert_eq!(report.samples.len(), 3); + } + + #[test] + fn test_run_benchmark_checksum() { + let spec = BenchSpec { + name: "ffi_benchmark::bench_checksum".to_string(), + iterations: 2, + warmup: 0, + }; + let report = run_benchmark(spec).unwrap(); + assert_eq!(report.samples.len(), 2); + } + + #[test] + fn test_unknown_function_error() { + let spec = BenchSpec { + name: "unknown".to_string(), + iterations: 1, + warmup: 0, + }; + let result = run_benchmark(spec); + assert!(matches!(result, Err(BenchError::UnknownFunction { .. }))); + } + + #[test] + fn test_invalid_iterations() { + let spec = BenchSpec { + name: "ffi_benchmark::bench_fibonacci".to_string(), + iterations: 0, + warmup: 0, + }; + let result = run_benchmark(spec); + assert!(matches!(result, Err(BenchError::ExecutionFailed { .. }))); + } +} From ca258c261d831aadc8e1928d6ccc2c0895f54f4a Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 16 Jan 2026 17:18:29 +0100 Subject: [PATCH 012/196] Remove legacy sample_fns UDL Drop the unused UniFFI UDL file for sample-fns and update build script notes to reflect the proc-macro binding generation flow. --- crates/sample-fns/src/sample_fns.udl | 26 -------------------------- scripts/build-android.sh | 2 +- scripts/build-ios.sh | 2 +- 3 files changed, 2 insertions(+), 28 deletions(-) delete mode 100644 crates/sample-fns/src/sample_fns.udl diff --git a/crates/sample-fns/src/sample_fns.udl b/crates/sample-fns/src/sample_fns.udl deleted file mode 100644 index 44ce090..0000000 --- a/crates/sample-fns/src/sample_fns.udl +++ /dev/null @@ -1,26 +0,0 @@ -namespace sample_fns { - [Throws=BenchError] - BenchReport run_benchmark(BenchSpec spec); -}; - -dictionary BenchSpec { - string name; - u32 iterations; - u32 warmup; -}; - -dictionary BenchSample { - u64 duration_ns; -}; - -dictionary BenchReport { - BenchSpec spec; - sequence samples; -}; - -[Error] -enum BenchError { - "InvalidIterations", - "UnknownFunction", - "ExecutionFailed", -}; diff --git a/scripts/build-android.sh b/scripts/build-android.sh index c065bd7..5ee42ed 100755 --- a/scripts/build-android.sh +++ b/scripts/build-android.sh @@ -11,7 +11,7 @@ set -euo pipefail # Build Rust shared libraries for Android targets using cargo-ndk. # -# NOTE: If you modify the Rust API (sample_fns.udl), run: +# NOTE: If you modify the Rust API, run: # cargo run --bin generate-bindings --features bindgen # before running this script to regenerate Kotlin bindings. # diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh index 5a6ef18..216ab1a 100755 --- a/scripts/build-ios.sh +++ b/scripts/build-ios.sh @@ -13,7 +13,7 @@ set -euo pipefail # Build the Rust library for iOS targets and package as xcframework. # UniFFI-generated headers (sample_fnsFFI.h) are used for the C ABI. # -# NOTE: If you modify the Rust API (sample_fns.udl), run: +# NOTE: If you modify the Rust API, run: # cargo run --bin generate-bindings --features bindgen # before running this script to regenerate Swift bindings and headers. # From 77da71279fa889c7e515297e6955d2885488061e Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 16 Jan 2026 17:32:42 +0100 Subject: [PATCH 013/196] Remove legacy scripts and update docs Delete deprecated build scripts, switch CI and docs to cargo mobench commands, and refresh integration guidance for the new flow. --- .github/workflows/mobile-bench.yml | 14 +- BENCH_SDK_INTEGRATION.md | 9 +- BUILD.md | 48 +++---- CLAUDE.md | 78 +++-------- PROJECT_PLAN.md | 2 +- TESTING.md | 41 +++--- android/README.md | 8 +- examples/basic-benchmark/src/lib.rs | 2 +- scripts/bindgen.rs | 56 -------- scripts/build-android-app.sh | 60 --------- scripts/build-android.sh | 43 ------ scripts/build-ios.sh | 202 ---------------------------- scripts/generate-bindings.sh | 26 ---- scripts/sync-android-libs.sh | 50 ------- 14 files changed, 69 insertions(+), 570 deletions(-) delete mode 100644 scripts/bindgen.rs delete mode 100755 scripts/build-android-app.sh delete mode 100755 scripts/build-android.sh delete mode 100755 scripts/build-ios.sh delete mode 100755 scripts/generate-bindings.sh delete mode 100755 scripts/sync-android-libs.sh diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index e6e27c2..2d55817 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -77,11 +77,8 @@ jobs: build-tools;34.0.0 ndk;26.1.10909125 - - name: Build Rust shared libs for Android - run: scripts/build-android.sh - - - name: Sync .so into Android app - run: scripts/sync-android-libs.sh + - name: Build Android artifacts + run: cargo mobench build --target android - name: Setup Gradle uses: gradle/gradle-build-action@v3 @@ -112,13 +109,8 @@ jobs: with: targets: aarch64-apple-ios,aarch64-apple-ios-sim - - name: Install cargo-apple and cbindgen - run: | - cargo install cargo-apple - cargo install cbindgen - - name: Build iOS xcframework + header - run: scripts/build-ios.sh + run: cargo mobench build --target ios - name: Upload iOS artifacts uses: actions/upload-artifact@v4 diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index 55ab67c..c831c42 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -4,7 +4,6 @@ This guide shows how to integrate `mobench-sdk` into an existing Rust project, r mobile benchmarks, and then run them on BrowserStack. > **Important**: This guide is for integrators importing `mobench-sdk` as a library. -> You do **NOT** need the `scripts/` directory from this repository. > All build functionality is available via `cargo mobench` commands. ## 1) Prerequisites @@ -50,6 +49,7 @@ fn checksum_bench() { ``` Benchmarks are identified by name at runtime. You can call them by: + - Fully-qualified path (e.g., `my_crate::checksum_bench`) - Or suffix match (e.g., `checksum_bench`) @@ -62,6 +62,7 @@ cargo mobench init-sdk --target both --project-name my-bench --output-dir . ``` This generates: + - `bench-mobile/` (FFI bridge that links your crate) - `android/` and `ios/` app templates - `bench-config.toml` configuration @@ -75,6 +76,7 @@ cargo mobench build --target android ``` This automatically: + - Builds Rust libraries for all Android ABIs (arm64-v8a, armeabi-v7a, x86_64) - Generates UniFFI Kotlin bindings - Copies .so files to jniLibs @@ -105,6 +107,7 @@ cargo mobench build --target ios ``` This automatically: + - Builds Rust libraries for iOS device + simulator - Generates UniFFI Swift bindings and C headers - Creates properly structured xcframework @@ -142,6 +145,7 @@ cargo mobench run \ ``` The CLI will automatically: + - Upload APK and test APK to BrowserStack - Schedule the test run - Wait for completion @@ -176,6 +180,7 @@ cargo mobench run \ ``` **IPA Signing Methods:** + - `adhoc`: No Apple ID required, works for BrowserStack device testing - `development`: Requires Apple Developer account, for physical device testing @@ -185,4 +190,4 @@ cargo mobench run \ - If you change FFI types, the build process automatically regenerates bindings - Android emulator ABI is typically `x86_64` in Android Studio - BrowserStack credentials must be set via `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` -- For developing this repo (not integrating the SDK), legacy `scripts/` are available but deprecated +- For repository development, use the same `cargo mobench` workflow diff --git a/BUILD.md b/BUILD.md index 1ad9925..c0749ff 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,11 +2,10 @@ Complete build instructions for Android and iOS targets. -> **For SDK Integrators**: If you're importing `mobench-sdk` into your project, use the CLI commands: -> - `cargo mobench build --target android` for Android -> - `cargo mobench build --target ios` for iOS +> **For SDK Integrators**: Use the CLI commands: +> - `cargo mobench build --target android` +> - `cargo mobench build --target ios` > -> The scripts shown below are legacy tooling for developing this repository. > See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide. ## Table of Contents @@ -77,8 +76,7 @@ xcodebuild -version ### Quick Start (Recommended) ```bash # Build everything and create APK in one command -# For Android Studio emulators, use UNIFFI_ANDROID_ABI=x86_64 -UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh +cargo mobench build --target android # Install on connected device or emulator adb install -r android/app/build/outputs/apk/debug/app-debug.apk @@ -91,8 +89,8 @@ adb shell am start -n dev.world.bench/.MainActivity #### Step 1: Build Rust Libraries + Bindings ```bash -# ABI-aware binding generation to avoid UniFFI checksum mismatches. -UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh +# Build Rust libraries, generate bindings, and sync JNI libs. +cargo mobench build --target android ``` This compiles Rust code for three Android ABIs: @@ -131,8 +129,7 @@ adb shell am start -n dev.world.bench/.MainActivity \ ### Using Android Studio 1. Build Rust libraries first: ```bash - ./scripts/build-android.sh - ./scripts/sync-android-libs.sh + cargo mobench build --target android ``` 2. Open the `android/` directory in Android Studio @@ -146,15 +143,14 @@ adb shell am start -n dev.world.bench/.MainActivity \ ### Rebuild After Code Changes ```bash # If Rust code changed -./scripts/build-android.sh -./scripts/sync-android-libs.sh +cargo mobench build --target android # If only Kotlin/Java changed cd android && ./gradlew :app:assembleDebug # Full clean rebuild cargo clean -./scripts/build-android-app.sh +cargo mobench build --target android ``` ## iOS Build @@ -162,7 +158,7 @@ cargo clean ### Quick Start (Recommended) ```bash # Build Rust xcframework (includes automatic code signing) -./scripts/build-ios.sh +cargo mobench build --target ios # Generate Xcode project cd ios/BenchRunner @@ -180,10 +176,10 @@ Then in Xcode: #### Step 1: Build Rust XCFramework ```bash -./scripts/build-ios.sh +cargo mobench build --target ios ``` -This script: +This build step: 1. Compiles Rust for iOS targets: - `aarch64-apple-ios` (physical devices) - `aarch64-apple-ios-sim` (M1+ Mac simulators) @@ -216,7 +212,7 @@ This script: Output: `target/ios/sample_fns.xcframework` (signed) -**Note**: The build script now includes automatic code signing. If signing fails for any reason, you can sign manually: +**Note**: The build step includes automatic code signing. If signing fails for any reason, you can sign manually: ```bash codesign --force --deep --sign - target/ios/sample_fns.xcframework ``` @@ -276,7 +272,7 @@ xcrun simctl launch booted dev.world.bench \ ### Rebuild After Code Changes ```bash # If Rust code changed (includes automatic signing) -./scripts/build-ios.sh +cargo mobench build --target ios # If Swift code changed, just rebuild in Xcode (⌘+B) @@ -287,7 +283,7 @@ open BenchRunner.xcodeproj # Full clean rebuild cargo clean -./scripts/build-ios.sh +cargo mobench build --target ios cd ios/BenchRunner xcodegen generate # Clean in Xcode (⌘+Shift+K) then build (⌘+B) @@ -334,7 +330,7 @@ cargo install cargo-ndk **Issue**: App crashes with `UnsatisfiedLinkError` ```bash # Ensure .so files are in the APK -./scripts/sync-android-libs.sh +cargo mobench build --target android cd android && ./gradlew clean assembleDebug # Verify .so files are in APK @@ -361,7 +357,7 @@ codesign --force --deep --sign - target/ios/sample_fns.xcframework ```bash # Rebuild with correct structure rm -rf target/ios/sample_fns.xcframework -./scripts/build-ios.sh +cargo mobench build --target ios codesign --force --deep --sign - target/ios/sample_fns.xcframework # Clean Xcode build @@ -388,12 +384,12 @@ xcodegen generate **Issue**: "framework 'ios-simulator-arm64' not found" - The framework binary or directory structure is incorrect -- Rebuild: `./scripts/build-ios.sh` +- Rebuild: `cargo mobench build --target ios` - Verify structure: Each framework should be named `sample_fns.framework`, not the platform identifier **Issue**: "Framework had an invalid CFBundleIdentifier" - Framework bundle ID conflicts with app bundle ID -- Check `scripts/build-ios.sh` uses `dev.world.sample-fns` for framework +- Check the iOS builder uses `dev.world.sample-fns` for the framework - App uses `dev.world.bench` ## UniFFI Bindings (Proc Macros) @@ -407,7 +403,7 @@ If you modify FFI types in Rust (`crates/sample-fns/src/lib.rs`): cargo build -p sample-fns # Regenerate bindings from proc macros -./scripts/generate-bindings.sh +cargo mobench build --target android # This updates: # - android/app/src/main/java/uniffi/sample_fns/sample_fns.kt (Kotlin) @@ -415,8 +411,8 @@ cargo build -p sample-fns # - ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h (C header) # Then rebuild mobile apps -UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh # Android -./scripts/build-ios.sh # iOS (includes automatic code signing) +cargo mobench build --target android +cargo mobench build --target ios ``` **Example**: Adding a new FFI type: diff --git a/CLAUDE.md b/CLAUDE.md index 1c08a2f..2b622e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,8 @@ The repository is organized as a Cargo workspace: - **`crates/mobench-sdk`**: Core SDK library with registry system, builders (AndroidBuilder, IosBuilder), template generation, and BrowserStack integration. - **`crates/mobench-macros`**: Proc macro crate providing the `#[benchmark]` attribute for marking functions. - **`crates/mobench-runner`**: Lightweight timing harness library that gets embedded in mobile binaries. Provides timing infrastructure for benchmarks. -- **`examples/basic-benchmark`**: Example benchmark functions with UniFFI bindings for mobile platforms. Demonstrates the SDK usage pattern. +- **`examples/basic-benchmark`**: Minimal SDK usage example with `#[benchmark]`. +- **`examples/ffi-benchmark`**: Full UniFFI surface example (types + `run_benchmark`). ### Mobile Integration Flow @@ -91,9 +92,6 @@ cargo mobench build --target ios # List discovered benchmarks cargo mobench list -# Legacy: Direct script usage (for repository development only) -scripts/build-android-app.sh -scripts/build-ios.sh ``` ## Common Commands @@ -130,47 +128,10 @@ cargo mobench package-ipa --method adhoc - Builds APK (Android) or xcframework (iOS) - No manual script execution needed -### Legacy Script-Based Building (Repository Development) +### Repository Development Builds -**Note:** The `scripts/` directory contains legacy tooling used for developing this repository. SDK users should use `cargo mobench build` instead. - -#### Android (Legacy) -```bash -# Build Rust shared libraries for Android (requires Android NDK) -scripts/build-android.sh - -# Sync .so files into Android project structure -scripts/sync-android-libs.sh - -# Build complete APK with Gradle -cd android && gradle :app:assembleDebug - -# Or use the all-in-one script -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh -``` - -Requirements: -- `ANDROID_NDK_HOME` environment variable set -- `cargo-ndk` installed: `cargo install cargo-ndk` -- Android SDK/NDK available (API level 24+) -- Set `UNIFFI_ANDROID_ABI=x86_64` for default Android Studio emulators - -#### iOS (Legacy) -```bash -# Build Rust xcframework for iOS (includes UniFFI headers and automatic signing) -scripts/build-ios.sh - -# Generate Xcode project from project.yml (if using repository's iOS app) -cd ios/BenchRunner && xcodegen generate - -# Open in Xcode -open BenchRunner.xcodeproj -``` - -Requirements: -- Xcode command-line tools -- Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim` -- `xcodegen` installed: `brew install xcodegen` (only for repository development) +Use `cargo mobench build --target ` for local or CI builds. The CLI handles +library builds, binding generation, and app packaging without extra scripts. **Important iOS Build Details:** @@ -293,7 +254,7 @@ fn my_expensive_operation() { The macro automatically registers functions at compile time via the `inventory` crate. -### FFI Boundary (`examples/basic-benchmark`) +### FFI Boundary (`examples/ffi-benchmark`) The example crate uses **UniFFI proc macros** to generate type-safe bindings for Kotlin and Swift. The API is defined directly in Rust code with attributes: @@ -323,10 +284,10 @@ uniffi::setup_scaffolding!(); // Auto-uses crate name as namespace Regenerate bindings after modifying FFI types (for repository development): ```bash # Build library to generate metadata -cargo build -p basic-benchmark +cargo build -p ffi-benchmark # Generate Kotlin + Swift bindings -./scripts/generate-bindings.sh +cargo mobench build --target android ``` Generated files (committed to git for the example app): @@ -400,11 +361,11 @@ cargo mobench run --target android --function my_function ### Adding New Benchmark Functions to Repository Example -1. Add function to `examples/basic-benchmark/src/lib.rs` +1. Add function to `crates/sample-fns/src/lib.rs` 2. Add function dispatch to `run_benchmark()` match statement (e.g., `"my_func" => run_closure(spec, || my_func())`) 3. If adding new FFI types, add proc macro attributes (`#[derive(uniffi::Record)]`, `#[uniffi::export]`, etc.) -4. Regenerate bindings: `./scripts/generate-bindings.sh` -5. Rebuild native libraries: `cargo mobench build --target ` or use legacy scripts +4. Regenerate bindings: `cargo mobench build --target android` +5. Rebuild native libraries: `cargo mobench build --target ` 6. Mobile apps will automatically use the updated bindings **Note**: No UDL file needed! Proc macros automatically detect FFI types from Rust code. @@ -416,7 +377,7 @@ cargo mobench run --target android --function my_function ### XCFramework Structure -`scripts/build-ios.sh` manually constructs an xcframework (not using `xcodebuild -create-xcframework`) by creating framework slices for each target with proper Info.plist and module.modulemap files. +The mobench iOS builder manually constructs an xcframework (not using `xcodebuild -create-xcframework`) by creating framework slices for each target with proper Info.plist and module.modulemap files. **Critical Implementation Details:** 1. **Directory Structure**: Each framework must be in `{LibraryIdentifier}/{FrameworkName}.framework/`, not directly at the root. For example: `ios-simulator-arm64/sample_fns.framework/`, not `ios-simulator-arm64.framework/`. @@ -540,9 +501,8 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) - `src/lib.rs`: Core timing and reporting logic ### Example & Testing -- **`examples/basic-benchmark/`**: Example benchmark crate demonstrating SDK usage - - `src/lib.rs`: Sample benchmark functions with UniFFI bindings - - `src/bin/generate-bindings.rs`: Binding generation for Kotlin/Swift +- **`examples/basic-benchmark/`**: Minimal SDK usage example +- **`examples/ffi-benchmark/`**: Full UniFFI surface example - **`android/`**: Android test app (for repository development) - `app/src/main/java/dev/world/bench/MainActivity.kt`: Android app entry point - `app/src/main/java/uniffi/sample_fns/sample_fns.kt`: Generated Kotlin bindings @@ -559,10 +519,6 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) - **`PROJECT_PLAN.md`**: Goals, architecture, task backlog - **`CLAUDE.md`**: This file - developer guide for the codebase -### Legacy Build Scripts (Repository Development Only) -- **`scripts/build-android.sh`**: Builds Rust libs with cargo-ndk for Android targets -- **`scripts/build-ios.sh`**: Builds iOS xcframework with correct structure and code signing -- **`scripts/sync-android-libs.sh`**: Copies .so files into Android jniLibs structure -- **`scripts/generate-bindings.sh`**: Regenerates UniFFI bindings for Kotlin/Swift - -**Note**: SDK users should use `cargo mobench build` instead of calling scripts directly. +### Build Tooling +Use `cargo mobench build --target ` for repository development and CI. The CLI +handles native builds, binding generation, and packaging. diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 968102e..2463f71 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -52,6 +52,6 @@ ## In-Repo Placeholders (current) -- Scripts: `scripts/build-android.sh`, `scripts/build-ios.sh` for manual/CI builds (require Android NDK / cargo-apple). +- CLI: `cargo mobench build --target ` for manual/CI builds (requires Android NDK/Xcode as appropriate). - Android demo app: `android/` Gradle project that loads the Rust demo cdylib (`sample-fns`) and displays results. - Workflow: `.github/workflows/mobile-bench.yml` manual build for Android; extend with BrowserStack upload/run and iOS job. diff --git a/TESTING.md b/TESTING.md index 6f823f2..9e8a87b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -85,8 +85,7 @@ The `Mobile Bench (manual)` workflow uploads summary artifacts: ```bash # Build everything and create APK -# For Android Studio emulators, use UNIFFI_ANDROID_ABI=x86_64 -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh +cargo mobench build --target android # Install on connected device/emulator adb install -r android/app/build/outputs/apk/debug/app-debug.apk @@ -99,7 +98,7 @@ adb shell am start -n dev.world.bench/.MainActivity ```bash # Step 1: Build Rust libraries + bindings (ABI-aware) -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh +cargo mobench build --target android # Step 2: Build APK cd android @@ -115,8 +114,7 @@ adb shell am start -n dev.world.bench/.MainActivity 1. Build Rust libraries first: ```bash - scripts/build-android.sh - scripts/sync-android-libs.sh + cargo mobench build --target android ``` 2. Open `android/` directory in Android Studio @@ -182,7 +180,7 @@ Statistics: ```bash # Step 1: Build Rust xcframework (includes automatic code signing) -scripts/build-ios.sh +cargo mobench build --target ios # This script: # - Compiles Rust for aarch64-apple-ios (device) and aarch64-apple-ios-sim (simulator) @@ -315,13 +313,13 @@ cargo install cargo-ndk ```bash # Solution: Clean and rebuild cargo clean -scripts/build-android.sh +cargo mobench build --target android ``` **Problem**: App crashes on launch with "UnsatisfiedLinkError" ```bash # Solution: Ensure .so files are in the APK -scripts/sync-android-libs.sh +cargo mobench build --target android cd android && ./gradlew clean assembleDebug ``` @@ -342,8 +340,8 @@ brew install xcodegen # Solution: Code-sign the xcframework codesign --force --deep --sign - target/ios/sample_fns.xcframework -# The build script now includes signing, but if you built manually: -scripts/build-ios.sh +# The build step includes signing, but if you built manually: +cargo mobench build --target ios cd ios/BenchRunner xcodegen generate # Clean build in Xcode (⌘+Shift+K) then build (⌘+B) @@ -383,7 +381,7 @@ xcodegen generate ```bash # Solution: Ensure xcframework was built correctly with proper structure rm -rf target/ios/sample_fns.xcframework -scripts/build-ios.sh +cargo mobench build --target ios codesign --force --deep --sign - target/ios/sample_fns.xcframework # Verify structure: @@ -398,7 +396,7 @@ ls -la target/ios/sample_fns.xcframework/ ```bash # Solution: Rebuild the xcframework - the structure may be incorrect rm -rf target/ios/sample_fns.xcframework -scripts/build-ios.sh +cargo mobench build --target ios codesign --force --deep --sign - target/ios/sample_fns.xcframework # Clean Xcode build folder @@ -410,9 +408,9 @@ xcodebuild clean -project BenchRunner.xcodeproj -scheme BenchRunner **Problem**: "Framework had an invalid CFBundleIdentifier in its Info.plist" ```bash # Solution: The framework bundle ID should not conflict with the app -# Check scripts/build-ios.sh has correct bundle ID (dev.world.sample-fns) +# Check the iOS builder uses `dev.world.sample-fns` for the framework # Rebuild: -scripts/build-ios.sh +cargo mobench build --target ios codesign --force --deep --sign - target/ios/sample_fns.xcframework ``` @@ -420,7 +418,7 @@ codesign --force --deep --sign - target/ios/sample_fns.xcframework ```bash # Solution: Clean and rebuild for simulator architecture cargo clean -scripts/build-ios.sh +cargo mobench build --target ios codesign --force --deep --sign - target/ios/sample_fns.xcframework # In Xcode, clean (⌘+Shift+K) then build (⌘+B) @@ -437,12 +435,11 @@ codesign --force --deep --sign - target/ios/sample_fns.xcframework **Problem**: Changes to FFI types in `crates/sample-fns/src/lib.rs` not reflected in mobile apps ```bash # Solution: Rebuild library and regenerate bindings -cargo build -p sample-fns -./scripts/generate-bindings.sh +cargo mobench build --target android # Then rebuild mobile apps -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh # For Android -scripts/build-ios.sh # For iOS (includes signing) +cargo mobench build --target android +cargo mobench build --target ios ``` **Problem**: "error: cannot find type `BenchSpec` in the crate root" @@ -465,12 +462,6 @@ cargo test --all # - Test assertions need updating ``` -**Problem**: "permission denied" when running scripts -```bash -# Solution: Make scripts executable -chmod +x scripts/*.sh -``` - ## Advanced Testing ### BrowserStack Integration Testing diff --git a/android/README.md b/android/README.md index 061db9a..81346c1 100644 --- a/android/README.md +++ b/android/README.md @@ -5,13 +5,9 @@ Minimal Android app that loads the Rust `sample-fns` cdylib and calls exported f ## Build steps 1. Build Rust libs for Android: ```bash - scripts/build-android.sh + cargo mobench build --target android ``` -2. Copy `.so` outputs into the app: - ```bash - scripts/sync-android-libs.sh - ``` -3. Assemble the APK (requires Java + Gradle + Android SDK/NDK on PATH): +2. Assemble the APK (requires Java + Gradle + Android SDK/NDK on PATH): ```bash cd android gradle :app:assembleDebug diff --git a/examples/basic-benchmark/src/lib.rs b/examples/basic-benchmark/src/lib.rs index 2aad37e..b756ae6 100644 --- a/examples/basic-benchmark/src/lib.rs +++ b/examples/basic-benchmark/src/lib.rs @@ -82,7 +82,7 @@ mod tests { #[test] fn test_discover_benchmarks() { - let benchmarks = mobench_sdk::discover_benchmarks(); + let benchmarks: Vec<&mobench_sdk::BenchFunction> = mobench_sdk::discover_benchmarks(); assert!(benchmarks.len() >= 2, "Should find at least 2 benchmarks"); } diff --git a/scripts/bindgen.rs b/scripts/bindgen.rs deleted file mode 100644 index bb721d9..0000000 --- a/scripts/bindgen.rs +++ /dev/null @@ -1,56 +0,0 @@ -// Standalone script to generate UniFFI bindings -// Usage: cargo script scripts/bindgen.rs - -use std::env; -use std::path::PathBuf; - -fn main() -> Result<(), Box> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let root_dir = manifest_dir.parent().unwrap(); - let lib_path = root_dir.join("target/debug/libsample_fns.dylib"); - - if !lib_path.exists() { - let lib_path_so = root_dir.join("target/debug/libsample_fns.so"); - if lib_path_so.exists() { - generate_bindings(&lib_path_so, root_dir)?; - } else { - eprintln!("Error: Library not found. Run 'cargo build -p sample-fns' first"); - std::process::exit(1); - } - } else { - generate_bindings(&lib_path, root_dir)?; - } - - Ok(()) -} - -fn generate_bindings(lib_path: &Path, root_dir: &Path) -> Result<(), Box> { - use uniffi_bindgen::{bindings, library_mode}; - - // Generate Kotlin bindings - let kotlin_out = root_dir.join("android/app/src/main/java"); - library_mode::generate_bindings( - lib_path, - None, - &bindings::TargetLanguage::Kotlin, - &kotlin_out, - false, - )?; - - println!("✓ Kotlin bindings generated: {:?}", kotlin_out); - - // Generate Swift bindings - let swift_out = root_dir.join("ios/BenchRunner/BenchRunner/Generated"); - std::fs::create_dir_all(&swift_out)?; - library_mode::generate_bindings( - lib_path, - None, - &bindings::TargetLanguage::Swift, - &swift_out, - false, - )?; - - println!("✓ Swift bindings generated: {:?}", swift_out); - - Ok(()) -} diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh deleted file mode 100755 index 63a922a..0000000 --- a/scripts/build-android-app.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ⚠️ DEPRECATION WARNING ⚠️ -# This script is legacy tooling for developing this repository. -# -# For SDK integrators, use instead: -# cargo mobench build --target android -# -# This command does everything this script does, but in pure Rust with no dependencies -# on having this repo's scripts/ directory locally. - -# Convenience wrapper: build Rust libs for all Android ABIs, sync them into the app, -# then assemble the Android APK. -# -# Requires: -# - cargo-ndk installed -# - Android SDK/Gradle available - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -# Resolve ANDROID_NDK_HOME if not provided. -if [[ -z "${ANDROID_NDK_HOME:-}" ]]; then - DEFAULT_NDK="${HOME}/Library/Android/sdk/ndk/29.0.14206865" - if [[ -d "${DEFAULT_NDK}" ]]; then - export ANDROID_NDK_HOME="${DEFAULT_NDK}" - echo "ANDROID_NDK_HOME not set; defaulting to ${ANDROID_NDK_HOME}" - else - echo "ANDROID_NDK_HOME is not set and default NDK path not found; please export it before running." >&2 - exit 1 - fi -fi - -pushd "${ROOT_DIR}" >/dev/null -./scripts/build-android.sh -ABI="${UNIFFI_ANDROID_ABI:-arm64-v8a}" -case "${ABI}" in - arm64-v8a) - LIB_PATH="${ROOT_DIR}/target/android/aarch64-linux-android/arm64-v8a/libsample_fns.so" - ;; - x86_64) - LIB_PATH="${ROOT_DIR}/target/android/x86_64-linux-android/x86_64/libsample_fns.so" - ;; - armeabi-v7a) - LIB_PATH="${ROOT_DIR}/target/android/armv7-linux-androideabi/armeabi-v7a/libsample_fns.so" - ;; - *) - echo "Unknown UNIFFI_ANDROID_ABI=${ABI}; expected arm64-v8a, x86_64, or armeabi-v7a" >&2 - exit 1 - ;; -esac -UNIFFI_LIBRARY_PATH="${LIB_PATH}" ./scripts/generate-bindings.sh -./scripts/sync-android-libs.sh -popd >/dev/null - -pushd "${ROOT_DIR}/android" >/dev/null -./gradlew :app:assembleDebug -popd >/dev/null - -echo "Android build complete." diff --git a/scripts/build-android.sh b/scripts/build-android.sh deleted file mode 100755 index 5ee42ed..0000000 --- a/scripts/build-android.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ⚠️ DEPRECATION WARNING ⚠️ -# This script is legacy tooling for developing this repository. -# -# For SDK integrators, use instead: -# cargo mobench build --target android -# -# The CLI command handles all build steps automatically. - -# Build Rust shared libraries for Android targets using cargo-ndk. -# -# NOTE: If you modify the Rust API, run: -# cargo run --bin generate-bindings --features bindgen -# before running this script to regenerate Kotlin bindings. -# -# Prereqs (install manually in CI/local before running): -# - Android NDK and toolchains available on PATH -# - cargo-ndk installed (`cargo install cargo-ndk`) -# -# By default builds sample-fns as a cdylib, producing libsample_fns.so per ABI. - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -CRATES=(${CRATES:-sample-fns}) -# Add x86_64 for emulator support; keep ARM for devices. -TARGET_ABIS=("aarch64-linux-android" "armv7-linux-androideabi" "x86_64-linux-android") -API_LEVEL=24 - -for CRATE in "${CRATES[@]}"; do - echo "Building Rust library for Android (crates/${CRATE})" - for ABI in "${TARGET_ABIS[@]}"; do - echo " -> ${ABI}" - cargo ndk \ - -t "${ABI}" \ - -o "${ROOT_DIR}/target/android/${ABI}" \ - --platform "${API_LEVEL}" \ - build -p "${CRATE}" --release - done -done - -echo "Finished. Outputs are under target/android//release." diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh deleted file mode 100755 index 216ab1a..0000000 --- a/scripts/build-ios.sh +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ⚠️ DEPRECATION WARNING ⚠️ -# This script is legacy tooling for developing this repository. -# -# For SDK integrators, use instead: -# cargo mobench build --target ios -# -# The CLI command handles all build steps automatically including xcframework -# creation, binding generation, and code signing. - -# Build the Rust library for iOS targets and package as xcframework. -# UniFFI-generated headers (sample_fnsFFI.h) are used for the C ABI. -# -# NOTE: If you modify the Rust API, run: -# cargo run --bin generate-bindings --features bindgen -# before running this script to regenerate Swift bindings and headers. -# -# Prereqs (install manually in CI/local before running): -# - Xcode command line tools -# - rustup targets: aarch64-apple-ios, aarch64-apple-ios-sim - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CRATE="sample-fns" -OUTPUT_DIR="${ROOT_DIR}/target/ios" -XCFRAMEWORK_PATH="${OUTPUT_DIR}/sample_fns.xcframework" - -# iOS targets to build -IOS_TARGETS=( - "aarch64-apple-ios" # iOS device (ARM64) - "aarch64-apple-ios-sim" # iOS simulator (ARM64, M1+ Macs) -) - -# Check for required iOS targets -for target in "${IOS_TARGETS[@]}"; do - if ! rustup target list --installed | grep -q "^${target}$"; then - echo "Installing Rust target: ${target}" - rustup target add "${target}" - fi -done - -echo "Building Rust libraries for iOS targets" -for target in "${IOS_TARGETS[@]}"; do - echo " -> Building for ${target}" - cargo build --release --target "${target}" -p "${CRATE}" -done - -echo "Creating xcframework structure" -rm -rf "${XCFRAMEWORK_PATH}" -mkdir -p "${XCFRAMEWORK_PATH}" - -# Create framework for each target -for target in "${IOS_TARGETS[@]}"; do - # Static library name: lib.a (crate name with underscores) - LIB_NAME="libsample_fns.a" - LIB_PATH="${ROOT_DIR}/target/${target}/release/${LIB_NAME}" - - if [[ ! -f "${LIB_PATH}" ]]; then - echo "Error: ${LIB_PATH} not found after build" >&2 - exit 1 - fi - - # Determine platform and architecture - case "${target}" in - aarch64-apple-ios) - PLATFORM="iPhoneOS" - XCFRAMEWORK_PLATFORM="ios" - ARCH="arm64" - FRAMEWORK_NAME="ios-arm64" - ;; - aarch64-apple-ios-sim) - PLATFORM="iPhoneSimulator" - XCFRAMEWORK_PLATFORM="ios-simulator" - ARCH="arm64" - FRAMEWORK_NAME="ios-simulator-arm64" - ;; - *) - echo "Unknown target: ${target}" >&2 - exit 1 - ;; - esac - - FRAMEWORK_DIR="${XCFRAMEWORK_PATH}/${FRAMEWORK_NAME}/sample_fns.framework" - mkdir -p "${FRAMEWORK_DIR}/Headers" - - # Copy library (framework binary should match module name) - cp "${LIB_PATH}" "${FRAMEWORK_DIR}/sample_fns" - - # Copy UniFFI-generated C header - UNIFFI_HEADER="${ROOT_DIR}/ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h" - if [[ ! -f "${UNIFFI_HEADER}" ]]; then - echo "Error: UniFFI header not found at ${UNIFFI_HEADER}" >&2 - echo "Run: cargo run --bin generate-bindings --features bindgen" >&2 - exit 1 - fi - cp "${UNIFFI_HEADER}" "${FRAMEWORK_DIR}/Headers/" - - # Create Info.plist for this framework slice - cat > "${FRAMEWORK_DIR}/Info.plist" < - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - sample_fns - CFBundleIdentifier - dev.world.sample-fns - CFBundleInfoDictionaryVersion - 6.0 - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - MinimumOSVersion - 13.0 - CFBundleSupportedPlatforms - - ${PLATFORM} - - - -EOF - - # Create module map for UniFFI C bindings - cat > "${FRAMEWORK_DIR}/Headers/module.modulemap" < "${XCFRAMEWORK_PATH}/Info.plist" < - - - - AvailableLibraries - - - LibraryIdentifier - ios-arm64 - LibraryPath - sample_fns.framework - SupportedArchitectures - - arm64 - - SupportedPlatform - ios - SupportedPlatformVariant - - - - LibraryIdentifier - ios-simulator-arm64 - LibraryPath - sample_fns.framework - SupportedArchitectures - - arm64 - - SupportedPlatform - ios - SupportedPlatformVariant - simulator - - - CFBundlePackageType - XFWK - XCFrameworkFormatVersion - 1.0 - - -EOF - -echo "✓ iOS build complete. XCFramework created at: ${XCFRAMEWORK_PATH}" - -# Copy public header for CLI consumers (matches mobench output layout) -INCLUDE_DIR="${OUTPUT_DIR}/include" -mkdir -p "${INCLUDE_DIR}" -if [[ -f "${UNIFFI_HEADER}" ]]; then - cp "${UNIFFI_HEADER}" "${INCLUDE_DIR}/sample_fns.h" -else - echo "Error: UniFFI header still missing at ${UNIFFI_HEADER}" >&2 - exit 1 -fi - -# Code-sign the xcframework (required for Xcode) -echo "Signing xcframework..." -codesign --force --deep --sign - "${XCFRAMEWORK_PATH}" 2>/dev/null || { - echo "⚠️ Warning: Failed to sign xcframework. You may need to sign manually:" - echo " codesign --force --deep --sign - ${XCFRAMEWORK_PATH}" -} - -echo "✓ Build and signing complete" diff --git a/scripts/generate-bindings.sh b/scripts/generate-bindings.sh deleted file mode 100755 index 4d2087e..0000000 --- a/scripts/generate-bindings.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ⚠️ DEPRECATION WARNING ⚠️ -# This script is legacy tooling for developing this repository. -# -# For SDK integrators, bindings are automatically generated during: -# cargo mobench build --target -# -# You don't need to call this script separately. - -# Generate Kotlin and Swift bindings using UniFFI - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CRATE_DIR="${ROOT_DIR}/crates/sample-fns" - -if [[ -n "${UNIFFI_LIBRARY_PATH:-}" ]]; then - echo "Using UNIFFI_LIBRARY_PATH=${UNIFFI_LIBRARY_PATH}" -else - echo "Building sample-fns (release)..." - cargo build -p sample-fns --release - export UNIFFI_PROFILE=release -fi - -echo "Generating Kotlin + Swift bindings via sample-fns helper..." -cargo run -p sample-fns --bin generate-bindings --features bindgen diff --git a/scripts/sync-android-libs.sh b/scripts/sync-android-libs.sh deleted file mode 100755 index 2809f5e..0000000 --- a/scripts/sync-android-libs.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ⚠️ DEPRECATION WARNING ⚠️ -# This script is legacy tooling for developing this repository. -# -# For SDK integrators, use instead: -# cargo mobench build --target android -# -# The CLI command automatically handles library copying. - -# Copy built Rust .so files into the Android app's jniLibs structure. -# Run scripts/build-android.sh first. - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -APP_JNILIBS="${ROOT_DIR}/android/app/src/main/jniLibs" -LIB_NAME="${LIB_NAME:-sample_fns}" -TARGET_LIB_NAME="${TARGET_LIB_NAME:-sample_fns}" - -declare -A ABI_MAP=( - ["aarch64-linux-android"]="arm64-v8a" - ["armv7-linux-androideabi"]="armeabi-v7a" - ["x86_64-linux-android"]="x86_64" -) - -for TRIPLE in "${!ABI_MAP[@]}"; do - # Cargo NDK may place outputs under /release or directly under the ABI folder. - SRC="${ROOT_DIR}/target/android/${TRIPLE}/release/lib${LIB_NAME}.so" - if [[ ! -f "${SRC}" ]]; then - ALT="${ROOT_DIR}/target/android/${TRIPLE}/${ABI_MAP[$TRIPLE]}/lib${LIB_NAME}.so" - if [[ -f "${ALT}" ]]; then - SRC="${ALT}" - fi - fi - DEST_DIR="${APP_JNILIBS}/${ABI_MAP[$TRIPLE]}" - DEST="${DEST_DIR}/lib${TARGET_LIB_NAME}.so" - if [[ ! -f "${SRC}" ]]; then - echo "Missing ${SRC}; build first with scripts/build-android.sh" >&2 - exit 1 - fi - mkdir -p "${DEST_DIR}" - cp "${SRC}" "${DEST}" - # Keep a compat copy for older loaders that expect uniffi_ prefix. - if [[ "${TARGET_LIB_NAME}" == "sample_fns" ]]; then - cp "${SRC}" "${DEST_DIR}/libuniffi_sample_fns.so" - fi - echo "Copied ${SRC} -> ${DEST}" -done - -echo "JNI libs synced." From 68eaaa897b16ede9d25f145c53dab6df93a808c2 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 16 Jan 2026 17:43:05 +0100 Subject: [PATCH 014/196] Refresh docs for mobench build flow Update CLAUDE, BUILD, and mobench-sdk README to remove script references, clarify the builder flow, and document the new examples. --- BUILD.md | 2 +- CLAUDE.md | 50 +++++++++++++++++++++++++++++++++--- crates/mobench-sdk/README.md | 15 ++++++++--- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/BUILD.md b/BUILD.md index c0749ff..096089b 100644 --- a/BUILD.md +++ b/BUILD.md @@ -304,7 +304,7 @@ xcodegen generate This makes C types (`RustBuffer`, `RustCallStatus`, etc.) available to Swift without explicit imports. -**Code Signing**: The build script (`build-ios.sh`) automatically signs the xcframework. If you build manually or signing fails, sign with: +**Code Signing**: The build step automatically signs the xcframework. If signing fails, sign with: ```bash codesign --force --deep --sign - target/ios/sample_fns.xcframework ``` diff --git a/CLAUDE.md b/CLAUDE.md index 2b622e5..da6d267 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. **Published on crates.io as the mobench ecosystem (v0.1.5):** + - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with build automation - **[mobench-macros](https://crates.io/crates/mobench-macros)** - `#[benchmark]` attribute proc macro @@ -66,10 +67,12 @@ The CLI supports both Espresso (Android) and XCUITest (iOS) test automation fram ## Build and Testing Documentation **Primary Documentation:** + - **`BUILD.md`**: Complete build reference with prerequisites, step-by-step instructions, and troubleshooting for both Android and iOS - **`TESTING.md`**: Comprehensive testing guide with advanced scenarios and detailed troubleshooting For comprehensive testing instructions, see **`TESTING.md`** which includes: + - Prerequisites and setup - Host testing (cargo test) - Android testing (emulator, device, Android Studio; use `UNIFFI_ANDROID_ABI=x86_64` for default emulators) @@ -78,6 +81,7 @@ For comprehensive testing instructions, see **`TESTING.md`** which includes: - Advanced testing scenarios Quick test commands: + ```bash # Run all Rust tests cargo test --all @@ -122,6 +126,7 @@ cargo mobench package-ipa --method adhoc ``` **What the CLI does:** + - Automatically builds Rust libraries with correct targets - Generates or updates mobile app projects from embedded templates - Syncs native libraries into platform-specific directories @@ -135,7 +140,8 @@ library builds, binding generation, and app packaging without extra scripts. **Important iOS Build Details:** -The `build-ios.sh` script creates an xcframework with the following structure: +The mobench iOS builder creates an xcframework with the following structure: + ``` target/ios/sample_fns.xcframework/ ├── Info.plist # XCFramework manifest @@ -156,6 +162,7 @@ target/ios/sample_fns.xcframework/ ``` **Key Configuration Details:** + - Framework binary must be named `sample_fns` (the module name), not the platform identifier - Each framework slice must be in `{LibraryIdentifier}/sample_fns.framework/` directory structure - Module map defines the C module as `sample_fnsFFI` (matches what UniFFI-generated Swift code imports) @@ -164,7 +171,8 @@ target/ios/sample_fns.xcframework/ - The Xcode project uses a bridging header (`BenchRunner-Bridging-Header.h`) to expose C FFI types to Swift - UniFFI-generated Swift bindings are compiled directly into the app (no `import sample_fns` needed) -**Automatic Code Signing**: The build script automatically signs the xcframework with: +**Automatic Code Signing**: The build step automatically signs the xcframework with: + ```bash codesign --force --deep --sign - target/ios/sample_fns.xcframework ``` @@ -176,6 +184,7 @@ Note: UniFFI C headers are generated automatically during the build process and ### Running Benchmarks #### Local Testing (No BrowserStack) + ```bash # Build artifacts and write bench_spec.json (launch the app manually) cargo mobench run \ @@ -187,6 +196,7 @@ cargo mobench run \ ``` #### BrowserStack Run (Android) + ```bash # Set credentials export BROWSERSTACK_USERNAME="your_username" @@ -203,6 +213,7 @@ cargo mobench run \ ``` #### BrowserStack Run (iOS) + ```bash cargo mobench run \ --target ios \ @@ -216,6 +227,7 @@ cargo mobench run \ ``` #### Using Config Files + ```bash # Generate starter config cargo mobench init --output bench-config.toml --target android @@ -228,6 +240,7 @@ cargo mobench run --config bench-config.toml ``` #### Fetch BrowserStack Results + ```bash # Download results from previous run cargo mobench fetch \ @@ -282,6 +295,7 @@ uniffi::setup_scaffolding!(); // Auto-uses crate name as namespace ``` Regenerate bindings after modifying FFI types (for repository development): + ```bash # Build library to generate metadata cargo build -p ffi-benchmark @@ -291,6 +305,7 @@ cargo mobench build --target android ``` Generated files (committed to git for the example app): + - Kotlin: `android/app/src/main/java/uniffi/sample_fns/sample_fns.kt` - Swift: `ios/BenchRunner/BenchRunner/Generated/sample_fns.swift` - C header: `ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h` @@ -298,6 +313,7 @@ Generated files (committed to git for the example app): ### Template System The SDK embeds Android and iOS app templates using the `include_dir!` macro: + - Templates located in `crates/mobench-sdk/templates/` - Embedded at compile time (no runtime file access needed) - Generated projects are created in user's workspace via `cargo mobench init` @@ -307,6 +323,7 @@ The SDK embeds Android and iOS app templates using the `include_dir!` macro: The CLI writes benchmark parameters to `target/mobile-spec/{android,ios}/bench_spec.json` during build. Mobile apps read this at runtime to know which function to benchmark. When using SDK-generated projects: + - Templates include spec reading logic - Apps automatically parse `bench_spec.json` from assets/bundle - Supports runtime parameter override via Intent extras (Android) or environment variables (iOS) @@ -314,6 +331,7 @@ When using SDK-generated projects: ### BrowserStack Credentials Credentials are resolved in this order: + 1. Config file (supports `${ENV_VAR}` expansion) 2. Environment variables: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `BROWSERSTACK_PROJECT` 3. `.env.local` file (loaded automatically via `dotenvy`) @@ -321,6 +339,7 @@ Credentials are resolved in this order: ### CI/CD (`.github/workflows/mobile-bench.yml`) The workflow supports manual dispatch with platform selection: + - Runs host tests first - Builds Android APK and/or iOS xcframework - Uploads artifacts @@ -331,6 +350,7 @@ The workflow supports manual dispatch with platform selection: ### Using mobench-sdk in Your Project 1. Add dependencies to your `Cargo.toml`: + ```toml [dependencies] mobench-sdk = "0.1" @@ -338,6 +358,7 @@ inventory = "0.3" ``` 1. Mark functions with `#[benchmark]`: + ```rust use mobench_sdk::benchmark; @@ -349,12 +370,14 @@ fn my_function() { ``` 1. Build for mobile: + ```bash cargo mobench build --target android cargo mobench build --target ios ``` 1. Run benchmarks: + ```bash cargo mobench run --target android --function my_function ``` @@ -380,6 +403,7 @@ cargo mobench run --target android --function my_function The mobench iOS builder manually constructs an xcframework (not using `xcodebuild -create-xcframework`) by creating framework slices for each target with proper Info.plist and module.modulemap files. **Critical Implementation Details:** + 1. **Directory Structure**: Each framework must be in `{LibraryIdentifier}/{FrameworkName}.framework/`, not directly at the root. For example: `ios-simulator-arm64/sample_fns.framework/`, not `ios-simulator-arm64.framework/`. 2. **Framework Binary Naming**: The binary inside each framework slice must be named after the module (`sample_fns`), not the platform identifier (`ios-simulator-arm64`). This is what Xcode's linker expects. @@ -387,6 +411,7 @@ The mobench iOS builder manually constructs an xcframework (not using `xcodebuil 3. **Module Map**: The C module in `module.modulemap` must be named `sample_fnsFFI` to match what UniFFI-generated Swift code tries to import via `#if canImport(sample_fnsFFI)`. 4. **Platform Identifiers**: The framework Info.plist uses Apple's official platform names: + - Device: `CFBundleSupportedPlatforms = ["iPhoneOS"]` - Simulator: `CFBundleSupportedPlatforms = ["iPhoneSimulator"]` @@ -400,11 +425,12 @@ The mobench iOS builder manually constructs an xcframework (not using `xcodebuil ### Gradle Integration (Android) -The Android app expects `.so` files under `android/app/src/main/jniLibs/{abi}/libsample_fns.so`. The `sync-android-libs.sh` script copies them from `target/android/{abi}/release/` to the correct locations. +The Android app expects `.so` files under `android/app/src/main/jniLibs/{abi}/libsample_fns.so`. The mobench Android builder copies them from `target/android/{abi}/release/` to the correct locations. ## Configuration Files ### `bench-config.toml` (generated by `init` command) + ```toml target = "android" function = "sample_fns::fibonacci" @@ -424,6 +450,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" ``` ### `device-matrix.yaml` (generated by `plan` command) + ```yaml devices: - name: Google Pixel 7-13.0 @@ -439,51 +466,63 @@ devices: ## Common iOS Build Issues and Solutions ### Issue: "The Framework 'sample_fns.xcframework' is unsigned" + **Solution**: Code-sign the xcframework after building: + ```bash codesign --force --deep --sign - target/ios/sample_fns.xcframework ``` ### Issue: "While building for iOS Simulator, no library for this platform was found" + **Root Cause**: Incorrect xcframework structure (frameworks at wrong path or incorrectly named). -**Solution**: Ensure `build-ios.sh` creates the correct structure with frameworks in subdirectories: +**Solution**: Ensure the iOS builder creates the correct structure with frameworks in subdirectories: + ``` ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) ``` ### Issue: "framework 'ios-simulator-arm64' not found" (linker error) + **Root Cause**: Framework LibraryPath in xcframework Info.plist points to wrong name. **Solution**: Verify xcframework Info.plist has: + ```xml LibraryPath sample_fns.framework ``` ### Issue: "Unable to find module dependency: 'sample_fns'" in Swift + **Root Cause**: Trying to import the module when it should be compiled directly into the app. **Solution**: Remove `import sample_fns` from Swift files. The UniFFI-generated Swift bindings are compiled into the app target, and C types are exposed via the bridging header. ### Issue: "Cannot find type 'RustBuffer' in scope" + **Root Cause**: Bridging header missing or not configured. **Solution**: + 1. Ensure `BenchRunner-Bridging-Header.h` exists with `#import "sample_fnsFFI.h"` 2. Verify `project.yml` has `SWIFT_OBJC_BRIDGING_HEADER` set 3. Regenerate Xcode project: `xcodegen generate` ### Issue: "Framework had an invalid CFBundleIdentifier" + **Root Cause**: Framework bundle ID conflicts with app bundle ID. **Solution**: Use different bundle IDs: + - Framework: `dev.world.sample-fns` - App: `dev.world.bench` ## Important Files ### Core SDK Crates + - **`crates/mobench/`**: CLI tool (published to crates.io) - `src/main.rs`: CLI entry point with commands (init, build, run, fetch, etc.) - `src/browserstack.rs`: BrowserStack REST API client @@ -501,6 +540,7 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) - `src/lib.rs`: Core timing and reporting logic ### Example & Testing + - **`examples/basic-benchmark/`**: Minimal SDK usage example - **`examples/ffi-benchmark/`**: Full UniFFI surface example - **`android/`**: Android test app (for repository development) @@ -513,6 +553,7 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) - `project.yml`: XcodeGen project specification ### Documentation + - **`BUILD.md`**: Complete build reference with prerequisites and troubleshooting - **`TESTING.md`**: Comprehensive testing guide with detailed troubleshooting - **`BENCH_SDK_INTEGRATION.md`**: Integration guide for SDK users @@ -520,5 +561,6 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) - **`CLAUDE.md`**: This file - developer guide for the codebase ### Build Tooling + Use `cargo mobench build --target ` for repository development and CI. The CLI handles native builds, binding generation, and packaging. diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index 8cc07a1..248f5fa 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -78,6 +78,7 @@ cargo mobench init --target android # or ios, or both ``` This creates: + - `bench-mobile/` - FFI wrapper crate - `android/` or `ios/` - Mobile app projects - `bench-config.toml` - Configuration file @@ -102,11 +103,13 @@ cargo mobench build --target android ### 4. Run on Devices Local device workflow (builds artifacts and writes the run spec; launch the app manually): + ```bash cargo mobench run --target android --function my_benchmark ``` BrowserStack: + ```bash export BROWSERSTACK_USERNAME=your_username export BROWSERSTACK_ACCESS_KEY=your_key @@ -114,6 +117,12 @@ export BROWSERSTACK_ACCESS_KEY=your_key cargo mobench run --target android --function my_benchmark --devices "Google Pixel 7-13.0" ``` +## Examples (Repository) + +- `examples/basic-benchmark`: minimal SDK usage with `#[benchmark]` +- `examples/ffi-benchmark`: full UniFFI surface with `run_benchmark` and FFI types +- `crates/sample-fns`: repository demo library used by Android/iOS test apps + ## API Documentation ### Core Functions @@ -326,9 +335,9 @@ fn btreemap_insert_1000() { │ ┌───────┴───────┐ ↓ ↓ -┌─────────────┐ ┌─────────────┐ -│ Android APK │ │ iOS IPA │ -└──────┬──────┘ └──────┬──────┘ +┌─────────────┐ ┌───────────────────────┐ +│ Android APK │ │ iOS xcframework / IPA │ +└──────┬──────┘ └──────┬────────────────┘ │ │ └───────┬───────┘ ↓ From 56f8ef44b221d0e5d2008eaa1a6127d6a4aede6c Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 11:06:49 +0100 Subject: [PATCH 015/196] Improve repo root detection with ancestor search The repo_root() function now walks up the directory tree looking for marker files instead of relying on the compile-time manifest path. This fixes cases where the CLI is run from subdirectories. Co-Authored-By: Claude Opus 4.5 --- crates/mobench/src/lib.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 0c7a816..d9306b6 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -1685,12 +1685,36 @@ fn load_dotenv() { } fn repo_root() -> Result { - // Prefer the build-time repo root but fall back to the current directory for installed binaries. + let cwd = std::env::current_dir().context("resolving repo root from current directory")?; + if let Some(root) = find_repo_root(&cwd) { + return Ok(root); + } + let compiled = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(".."); if let Ok(path) = compiled.canonicalize() { + if let Some(root) = find_repo_root(&path) { + return Ok(root); + } return Ok(path); } - std::env::current_dir().context("resolving repo root from current directory") + + Ok(cwd) +} + +fn find_repo_root(start: &Path) -> Option { + start + .ancestors() + .find(|candidate| is_repo_root(candidate)) + .map(|root| root.to_path_buf()) +} + +fn is_repo_root(candidate: &Path) -> bool { + candidate.join("bench-mobile").join("Cargo.toml").is_file() + || candidate + .join("crates") + .join("sample-fns") + .join("Cargo.toml") + .is_file() } fn ensure_can_write(path: &Path) -> Result<()> { From 60fb5d319b26e44cd5fdf35b23a0649c1ae71d91 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 11:15:39 +0100 Subject: [PATCH 016/196] Bump version to 0.1.7 for release Update workspace version and inter-crate dependencies. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 +- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- crates/mobench-sdk/Cargo.toml | 4 ++-- crates/mobench/Cargo.toml | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index da6d267..9b2fbdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.5):** +**Published on crates.io as the mobench ecosystem (v0.1.7):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with build automation diff --git a/Cargo.lock b/Cargo.lock index f6d1662..38d3edf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "clap", @@ -821,7 +821,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.6" +version = "0.1.7" dependencies = [ "proc-macro2", "quote", @@ -830,7 +830,7 @@ dependencies = [ [[package]] name = "mobench-runner" -version = "0.1.6" +version = "0.1.7" dependencies = [ "serde", "thiserror 1.0.69", @@ -838,7 +838,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "include_dir", @@ -1164,7 +1164,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.6" +version = "0.1.7" dependencies = [ "camino", "mobench-runner", diff --git a/Cargo.toml b/Cargo.toml index c125948..e0782c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.6" +version = "0.1.7" [workspace.dependencies] anyhow = "1" diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 4ffac50..3fd8d01 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -21,8 +21,8 @@ crate-type = ["lib"] [dependencies] # Core dependencies -mobench-runner = { version = "0.1.6", path = "../mobench-runner" } -mobench-macros = { version = "0.1.6", path = "../mobench-macros" } +mobench-runner = { version = "0.1.7", path = "../mobench-runner" } +mobench-macros = { version = "0.1.7", path = "../mobench-macros" } # Registry inventory.workspace = true diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index eed6932..5839275 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -26,8 +26,8 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.6", path = "../mobench-sdk" } -mobench-runner = { version = "0.1.6", path = "../mobench-runner" } +mobench-sdk = { version = "0.1.7", path = "../mobench-sdk" } +mobench-runner = { version = "0.1.7", path = "../mobench-runner" } clap.workspace = true serde.workspace = true serde_json.workspace = true From 8687215b0bd74a09905c57f0280506043a49afdb Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 11:16:07 +0100 Subject: [PATCH 017/196] Add commit guidelines to CLAUDE.md --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 9b2fbdc..e3b833f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,10 @@ mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that ena All packages are licensed under MIT (World Foundation, 2026). +### Commit Guidelines + +Do not add "Co-Authored-By" lines to commit messages. + ### Quick Start (SDK Users) ```bash From 70e7fa0e351704ba7e7c58c136110b28a718e9cc Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 12:05:56 +0100 Subject: [PATCH 018/196] Output mobile artifacts to target/mobench/ by default - Add output_dir field to AndroidBuilder and IosBuilder - Default to {project_root}/target/mobench/ for all mobile artifacts - Add output_dir() builder method for customization - Add --output-dir CLI argument to `cargo mobench build` - Update all path references in builders to use output_dir This keeps generated mobile artifacts inside target/, following Rust conventions and preventing accidental commits of Android/iOS project files. --- crates/mobench-sdk/src/builders/android.rs | 36 +++++++++++++--- crates/mobench-sdk/src/builders/ios.rs | 50 ++++++++++++++++------ crates/mobench/src/lib.rs | 39 +++++++++++++---- 3 files changed, 98 insertions(+), 27 deletions(-) diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index ec3e0e7..2469861 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -12,6 +12,8 @@ use std::process::Command; pub struct AndroidBuilder { /// Root directory of the project project_root: PathBuf, + /// Output directory for mobile artifacts (defaults to target/mobench) + output_dir: PathBuf, /// Name of the bench-mobile crate crate_name: String, /// Whether to use verbose output @@ -26,13 +28,24 @@ impl AndroidBuilder { /// * `project_root` - Root directory containing the bench-mobile crate /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile") pub fn new(project_root: impl Into, crate_name: impl Into) -> Self { + let root = project_root.into(); Self { - project_root: project_root.into(), + output_dir: root.join("target/mobench"), + project_root: root, crate_name: crate_name.into(), verbose: false, } } + /// Sets the output directory for mobile artifacts + /// + /// By default, artifacts are written to `{project_root}/target/mobench/`. + /// Use this to customize the output location. + pub fn output_dir(mut self, dir: impl Into) -> Self { + self.output_dir = dir.into(); + self + } + /// Enables verbose output pub fn verbose(mut self, verbose: bool) -> Self { self.verbose = verbose; @@ -166,7 +179,7 @@ impl AndroidBuilder { // Check if bindings already exist (for repository testing with pre-generated bindings) let bindings_path = self - .project_root + .output_dir .join("android") .join("app") .join("src") @@ -207,7 +220,7 @@ impl AndroidBuilder { let lib_path = host_lib_path(&crate_dir, &self.crate_name)?; let out_dir = self - .project_root + .output_dir .join("android") .join("app") .join("src") @@ -238,7 +251,7 @@ impl AndroidBuilder { }; let target_dir = self.project_root.join("target"); - let jni_libs_dir = self.project_root.join("android/app/src/main/jniLibs"); + let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs"); // Create jniLibs directories if they don't exist std::fs::create_dir_all(&jni_libs_dir) @@ -282,7 +295,7 @@ impl AndroidBuilder { /// Builds the Android APK using Gradle fn build_apk(&self, config: &BuildConfig) -> Result { - let android_dir = self.project_root.join("android"); + let android_dir = self.output_dir.join("android"); if !android_dir.exists() { return Err(BenchError::Build(format!( @@ -340,7 +353,7 @@ impl AndroidBuilder { /// Builds the Android test APK using Gradle fn build_test_apk(&self, config: &BuildConfig) -> Result { - let android_dir = self.project_root.join("android"); + let android_dir = self.output_dir.join("android"); if !android_dir.exists() { return Err(BenchError::Build(format!( @@ -448,6 +461,10 @@ mod tests { fn test_android_builder_creation() { let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); assert!(!builder.verbose); + assert_eq!( + builder.output_dir, + PathBuf::from("/tmp/test-project/target/mobench") + ); } #[test] @@ -455,4 +472,11 @@ mod tests { let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true); assert!(builder.verbose); } + + #[test] + fn test_android_builder_custom_output_dir() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile") + .output_dir("/custom/output"); + assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); + } } diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index a757ae8..458aeb5 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -13,6 +13,8 @@ use std::process::Command; pub struct IosBuilder { /// Root directory of the project project_root: PathBuf, + /// Output directory for mobile artifacts (defaults to target/mobench) + output_dir: PathBuf, /// Name of the bench-mobile crate crate_name: String, /// Whether to use verbose output @@ -27,13 +29,24 @@ impl IosBuilder { /// * `project_root` - Root directory containing the bench-mobile crate /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile") pub fn new(project_root: impl Into, crate_name: impl Into) -> Self { + let root = project_root.into(); Self { - project_root: project_root.into(), + output_dir: root.join("target/mobench"), + project_root: root, crate_name: crate_name.into(), verbose: false, } } + /// Sets the output directory for mobile artifacts + /// + /// By default, artifacts are written to `{project_root}/target/mobench/`. + /// Use this to customize the output location. + pub fn output_dir(mut self, dir: impl Into) -> Self { + self.output_dir = dir.into(); + self + } + /// Enables verbose output pub fn verbose(mut self, verbose: bool) -> Self { self.verbose = verbose; @@ -80,7 +93,7 @@ impl IosBuilder { framework_name )) })?; - let include_dir = self.project_root.join("target/ios/include"); + let include_dir = self.output_dir.join("ios/include"); fs::create_dir_all(&include_dir) .map_err(|e| BenchError::Build(format!("Failed to create include dir: {}", e)))?; let header_dest = include_dir.join(format!("{}.h", framework_name)); @@ -197,7 +210,7 @@ impl IosBuilder { // Check if bindings already exist (for repository testing with pre-generated bindings) let bindings_path = self - .project_root + .output_dir .join("ios") .join("BenchRunner") .join("BenchRunner") @@ -235,7 +248,7 @@ impl IosBuilder { let lib_path = host_lib_path(&crate_dir, &self.crate_name)?; let out_dir = self - .project_root + .output_dir .join("ios") .join("BenchRunner") .join("BenchRunner") @@ -269,7 +282,7 @@ impl IosBuilder { }; let target_dir = self.project_root.join("target"); - let xcframework_dir = target_dir.join("ios"); + let xcframework_dir = self.output_dir.join("ios"); let framework_name = &self.crate_name.replace("-", "_"); let xcframework_path = xcframework_dir.join(format!("{}.xcframework", framework_name)); @@ -504,7 +517,7 @@ impl IosBuilder { /// Generates Xcode project using xcodegen if project.yml exists fn generate_xcode_project(&self) -> Result<(), BenchError> { - let ios_dir = self.project_root.join("ios"); + let ios_dir = self.output_dir.join("ios"); let project_yml = ios_dir.join("BenchRunner/project.yml"); if !project_yml.exists() { @@ -541,7 +554,7 @@ impl IosBuilder { fn find_uniffi_header(&self, header_name: &str) -> Option { // Check generated Swift bindings directory first let swift_dir = self - .project_root + .output_dir .join("ios/BenchRunner/BenchRunner/Generated"); let candidate_swift = swift_dir.join(header_name); if candidate_swift.exists() { @@ -753,7 +766,7 @@ impl IosBuilder { pub fn package_ipa(&self, scheme: &str, method: SigningMethod) -> Result { // For repository structure: ios/BenchRunner/BenchRunner.xcodeproj // The directory and scheme happen to have the same name - let ios_dir = self.project_root.join("ios").join(scheme); + let ios_dir = self.output_dir.join("ios").join(scheme); let project_path = ios_dir.join(format!("{}.xcodeproj", scheme)); // Verify Xcode project exists @@ -764,7 +777,7 @@ impl IosBuilder { ))); } - let export_path = self.project_root.join("target/ios"); + let export_path = self.output_dir.join("ios"); let ipa_path = export_path.join(format!("{}.ipa", scheme)); // Create target/ios directory if it doesn't exist @@ -774,7 +787,7 @@ impl IosBuilder { println!("Building {} for device...", scheme); // Step 1: Build the app for device (simpler than archiving) - let build_dir = self.project_root.join("target/ios/build"); + let build_dir = self.output_dir.join("ios/build"); let build_configuration = "Debug"; let mut cmd = Command::new("xcodebuild"); cmd.args([ @@ -920,7 +933,7 @@ impl IosBuilder { /// This requires the app project to be generated first with `build()`. /// The resulting zip can be supplied to BrowserStack as the test suite. pub fn package_xcuitest(&self, scheme: &str) -> Result { - let ios_dir = self.project_root.join("ios").join(scheme); + let ios_dir = self.output_dir.join("ios").join(scheme); let project_path = ios_dir.join(format!("{}.xcodeproj", scheme)); if !project_path.exists() { @@ -930,11 +943,11 @@ impl IosBuilder { ))); } - let export_path = self.project_root.join("target/ios"); + let export_path = self.output_dir.join("ios"); fs::create_dir_all(&export_path) .map_err(|e| BenchError::Build(format!("Failed to create export directory: {}", e)))?; - let build_dir = self.project_root.join("target/ios/build"); + let build_dir = self.output_dir.join("ios/build"); println!("Building XCUITest runner for {}...", scheme); let mut cmd = Command::new("xcodebuild"); @@ -1085,6 +1098,10 @@ mod tests { fn test_ios_builder_creation() { let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile"); assert!(!builder.verbose); + assert_eq!( + builder.output_dir, + PathBuf::from("/tmp/test-project/target/mobench") + ); } #[test] @@ -1092,4 +1109,11 @@ mod tests { let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true); assert!(builder.verbose); } + + #[test] + fn test_ios_builder_custom_output_dir() { + let builder = + IosBuilder::new("/tmp/test-project", "test-bench-mobile").output_dir("/custom/output"); + assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); + } } diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index d9306b6..8a15392 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -114,6 +114,8 @@ enum Command { target: SdkTarget, #[arg(long, help = "Build in release mode")] release: bool, + #[arg(long, help = "Output directory for mobile artifacts (default: target/mobench)")] + output_dir: Option, }, /// Package iOS app as IPA for distribution or testing. PackageIpa { @@ -573,8 +575,12 @@ pub fn run() -> Result<()> { } => { cmd_init_sdk(target, project_name, output_dir, examples)?; } - Command::Build { target, release } => { - cmd_build(target, release)?; + Command::Build { + target, + release, + output_dir, + } => { + cmd_build(target, release, output_dir)?; } Command::PackageIpa { scheme, method } => { cmd_package_ipa(&scheme, method)?; @@ -1765,7 +1771,7 @@ fn cmd_init_sdk( } /// Build mobile artifacts using mobench-sdk (Phase 1 MVP) -fn cmd_build(target: SdkTarget, release: bool) -> Result<()> { +fn cmd_build(target: SdkTarget, release: bool, output_dir: Option) -> Result<()> { println!("Building mobile artifacts..."); println!(" Target: {:?}", target); println!(" Profile: {}", if release { "release" } else { "debug" }); @@ -1774,6 +1780,10 @@ fn cmd_build(target: SdkTarget, release: bool) -> Result<()> { let crate_name = detect_bench_mobile_crate_name(&project_root) .unwrap_or_else(|_| "bench-mobile".to_string()); // Fallback for legacy layouts + if let Some(ref dir) = output_dir { + println!(" Output: {:?}", dir); + } + let build_config = mobench_sdk::BuildConfig { target: target.into(), profile: if release { @@ -1786,32 +1796,45 @@ fn cmd_build(target: SdkTarget, release: bool) -> Result<()> { match target { SdkTarget::Android => { - let builder = + let mut builder = mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) .verbose(true); + if let Some(ref dir) = output_dir { + builder = builder.output_dir(dir); + } let result = builder.build(&build_config)?; println!("\n✓ Android build completed!"); println!(" APK: {:?}", result.app_path); } SdkTarget::Ios => { - let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name.clone()) - .verbose(true); + let mut builder = + mobench_sdk::builders::IosBuilder::new(&project_root, crate_name.clone()) + .verbose(true); + if let Some(ref dir) = output_dir { + builder = builder.output_dir(dir); + } let result = builder.build(&build_config)?; println!("\n✓ iOS build completed!"); println!(" Framework: {:?}", result.app_path); } SdkTarget::Both => { // Build Android - let android_builder = + let mut android_builder = mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) .verbose(true); + if let Some(ref dir) = output_dir { + android_builder = android_builder.output_dir(dir); + } let android_result = android_builder.build(&build_config)?; println!("\n✓ Android build completed!"); println!(" APK: {:?}", android_result.app_path); // Build iOS - let ios_builder = + let mut ios_builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + if let Some(ref dir) = output_dir { + ios_builder = ios_builder.output_dir(dir); + } let ios_result = ios_builder.build(&build_config)?; println!("\n✓ iOS build completed!"); println!(" Framework: {:?}", ios_result.app_path); From 64c51966952e6f928872a063ff0ecd9e37b41fc2 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 12:08:37 +0100 Subject: [PATCH 019/196] Update docs for target/mobench/ output directory - Update all path references from target/ios/ to target/mobench/ios/ - Update Android paths to target/mobench/android/ - Document --output-dir CLI flag - Update troubleshooting sections with correct paths --- BUILD.md | 42 +++++++++++++++++++++--------------------- CLAUDE.md | 25 +++++++++++++++---------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/BUILD.md b/BUILD.md index 096089b..df05be3 100644 --- a/BUILD.md +++ b/BUILD.md @@ -98,23 +98,23 @@ This compiles Rust code for three Android ABIs: - `armv7-linux-androideabi` → `armeabi-v7a` (32-bit ARM devices) - `x86_64-linux-android` → `x86_64` (x86 emulators) -Output: `target/android/{abi}/release/libsample_fns.so` +Output: `target/{target-triple}/release/libsample_fns.so` -This copies `.so` files to `android/app/src/main/jniLibs/{abi}/libsample_fns.so` where Android's build system expects them. +The mobench builder copies `.so` files to `target/mobench/android/app/src/main/jniLibs/{abi}/libsample_fns.so` where Android's build system expects them. #### Step 2: Build APK with Gradle ```bash -cd android +cd target/mobench/android ./gradlew :app:assembleDebug -cd .. +cd ../../.. ``` -Output: `android/app/build/outputs/apk/debug/app-debug.apk` +Output: `target/mobench/android/app/build/outputs/apk/debug/app-debug.apk` #### Step 3: Install and Run ```bash # Install -adb install -r android/app/build/outputs/apk/debug/app-debug.apk +adb install -r target/mobench/android/app/build/outputs/apk/debug/app-debug.apk # Launch with default parameters adb shell am start -n dev.world.bench/.MainActivity @@ -132,7 +132,7 @@ adb shell am start -n dev.world.bench/.MainActivity \ cargo mobench build --target android ``` -2. Open the `android/` directory in Android Studio +2. Open the `target/mobench/android/` directory in Android Studio 3. Wait for Gradle sync to complete @@ -186,7 +186,7 @@ This build step: 2. Creates xcframework with structure: ``` - target/ios/sample_fns.xcframework/ + target/mobench/ios/sample_fns.xcframework/ ├── Info.plist ├── ios-arm64/ │ └── sample_fns.framework/ @@ -210,18 +210,18 @@ This build step: 5. **Automatically code-signs the xcframework** (required for Xcode) -Output: `target/ios/sample_fns.xcframework` (signed) +Output: `target/mobench/ios/sample_fns.xcframework` (signed) **Note**: The build step includes automatic code signing. If signing fails for any reason, you can sign manually: ```bash -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework ``` Code signing is **required** for Xcode to accept and link the framework. Without signing, you'll see "The Framework 'sample_fns.xcframework' is unsigned" errors. #### Step 2: Generate Xcode Project ```bash -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate ``` @@ -229,7 +229,7 @@ This generates `BenchRunner.xcodeproj` from `project.yml` specification. The gen - Source files from `BenchRunner/` directory - Generated Swift bindings (`BenchRunner/Generated/sample_fns.swift`) - Bridging header (`BenchRunner/BenchRunner-Bridging-Header.h`) -- Framework dependency on `../../target/ios/sample_fns.xcframework` +- Framework dependency on `../sample_fns.xcframework` #### Step 3: Build and Run in Xcode ```bash @@ -306,7 +306,7 @@ This makes C types (`RustBuffer`, `RustCallStatus`, etc.) available to Swift wit **Code Signing**: The build step automatically signs the xcframework. If signing fails, sign with: ```bash -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework ``` ## Common Issues @@ -331,10 +331,10 @@ cargo install cargo-ndk ```bash # Ensure .so files are in the APK cargo mobench build --target android -cd android && ./gradlew clean assembleDebug +cd target/mobench/android && ./gradlew clean assembleDebug # Verify .so files are in APK -unzip -l android/app/build/outputs/apk/debug/app-debug.apk | grep libsample_fns.so +unzip -l target/mobench/android/app/build/outputs/apk/debug/app-debug.apk | grep libsample_fns.so ``` **Issue**: `Error: UnknownFunction` @@ -350,18 +350,18 @@ brew install xcodegen **Issue**: "The Framework 'sample_fns.xcframework' is unsigned" ```bash -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework ``` **Issue**: "While building for iOS Simulator, no library for this platform was found" ```bash # Rebuild with correct structure -rm -rf target/ios/sample_fns.xcframework +rm -rf target/mobench/ios/sample_fns.xcframework cargo mobench build --target ios -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework # Clean Xcode build -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodebuild clean -project BenchRunner.xcodeproj -scheme BenchRunner ``` @@ -372,13 +372,13 @@ xcodebuild clean -project BenchRunner.xcodeproj -scheme BenchRunner **Issue**: "Cannot find type 'RustBuffer' in scope" ```bash # Ensure bridging header exists -cat ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h +cat target/mobench/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h # Should contain: # #import "sample_fnsFFI.h" # Regenerate project -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate ``` diff --git a/CLAUDE.md b/CLAUDE.md index e3b833f..705367e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,9 +94,13 @@ cargo test --all cargo mobench init --target android --output bench-config.toml # Build mobile artifacts (recommended approach) +# Outputs to target/mobench/ by default cargo mobench build --target android cargo mobench build --target ios +# Build to custom output directory +cargo mobench build --target android --output-dir ./my-output + # List discovered benchmarks cargo mobench list @@ -135,6 +139,7 @@ cargo mobench package-ipa --method adhoc - Generates or updates mobile app projects from embedded templates - Syncs native libraries into platform-specific directories - Builds APK (Android) or xcframework (iOS) +- Outputs all artifacts to `target/mobench/` by default (use `--output-dir` to customize) - No manual script execution needed ### Repository Development Builds @@ -144,10 +149,10 @@ library builds, binding generation, and app packaging without extra scripts. **Important iOS Build Details:** -The mobench iOS builder creates an xcframework with the following structure: +The mobench iOS builder creates an xcframework with the following structure (default output directory is `target/mobench/`): ``` -target/ios/sample_fns.xcframework/ +target/mobench/ios/sample_fns.xcframework/ ├── Info.plist # XCFramework manifest ├── ios-arm64/ # Device slice │ └── sample_fns.framework/ @@ -178,7 +183,7 @@ target/ios/sample_fns.xcframework/ **Automatic Code Signing**: The build step automatically signs the xcframework with: ```bash -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework ``` If automatic signing fails, the script will display a warning with instructions for manual signing. @@ -225,8 +230,8 @@ cargo mobench run \ --iterations 20 \ --warmup 3 \ --devices "iPhone 14-16" \ - --ios-app target/ios/BenchRunner.ipa \ - --ios-test-suite target/ios/BenchRunnerUITests.zip \ + --ios-app target/mobench/ios/BenchRunner.ipa \ + --ios-test-suite target/mobench/ios/BenchRunnerUITests.zip \ --output run-summary.json ``` @@ -425,11 +430,11 @@ The mobench iOS builder manually constructs an xcframework (not using `xcodebuil 6. **Static vs Dynamic**: The xcframework contains static libraries (`.a` archives built with `staticlib` crate-type), not dynamic frameworks. This requires a bridging header in the Xcode project to expose C types to Swift. -7. **Code Signing**: After building, the xcframework must be code-signed for Xcode to accept it: `codesign --force --deep --sign - target/ios/sample_fns.xcframework` +7. **Code Signing**: After building, the xcframework must be code-signed for Xcode to accept it: `codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework` ### Gradle Integration (Android) -The Android app expects `.so` files under `android/app/src/main/jniLibs/{abi}/libsample_fns.so`. The mobench Android builder copies them from `target/android/{abi}/release/` to the correct locations. +The Android app expects `.so` files under `target/mobench/android/app/src/main/jniLibs/{abi}/libsample_fns.so`. The mobench Android builder copies them from `target/{abi}/release/` to the correct locations. ## Configuration Files @@ -449,8 +454,8 @@ project = "mobile-bench-rs" # iOS only: [ios_xcuitest] -app = "target/ios/BenchRunner.ipa" -test_suite = "target/ios/BenchRunnerUITests.zip" +app = "target/mobench/ios/BenchRunner.ipa" +test_suite = "target/mobench/ios/BenchRunnerUITests.zip" ``` ### `device-matrix.yaml` (generated by `plan` command) @@ -474,7 +479,7 @@ devices: **Solution**: Code-sign the xcframework after building: ```bash -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework ``` ### Issue: "While building for iOS Simulator, no library for this platform was found" From f853279bb1fb2dfc7b3b717f657353d22cb92be6 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 12:18:10 +0100 Subject: [PATCH 020/196] Add comprehensive docs.rs documentation - Add extensive crate-level documentation to all four crates: - mobench-sdk: Full SDK docs with examples, architecture, and best practices - mobench-macros: #[benchmark] macro documentation with usage examples - mobench-runner: Timing harness docs with runnable examples - mobench: CLI documentation with command reference - Document all public types with examples: - BenchSpec, BenchSample, BenchReport, BenchError - Target, BuildConfig, BuildProfile, BuildResult - InitConfig and builder types - Configure docs.rs metadata in all Cargo.toml files: - Add documentation URLs - Add maintenance badges - Configure rustdoc-args for docs.rs builds - Add serde_json dev-dependency to mobench-runner for doc tests --- Cargo.lock | 1 + crates/mobench-macros/Cargo.toml | 8 + crates/mobench-macros/src/lib.rs | 177 ++++++++++++++++-- crates/mobench-runner/Cargo.toml | 11 ++ crates/mobench-runner/src/lib.rs | 299 ++++++++++++++++++++++++++++++- crates/mobench-sdk/Cargo.toml | 8 + crates/mobench-sdk/src/lib.rs | 286 ++++++++++++++++++++++++----- crates/mobench-sdk/src/types.rs | 230 ++++++++++++++++++++---- crates/mobench/Cargo.toml | 8 + crates/mobench/src/lib.rs | 97 ++++++++++ 10 files changed, 1039 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38d3edf..dd30414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -833,6 +833,7 @@ name = "mobench-runner" version = "0.1.7" dependencies = [ "serde", + "serde_json", "thiserror 1.0.69", ] diff --git a/crates/mobench-macros/Cargo.toml b/crates/mobench-macros/Cargo.toml index a63ce0e..71b02f3 100644 --- a/crates/mobench-macros/Cargo.toml +++ b/crates/mobench-macros/Cargo.toml @@ -6,9 +6,17 @@ license.workspace = true authors = ["Dominik Clemente - dcbuilder.eth "] description = "Proc macros for mobench-sdk - #[benchmark] attribute" repository = "https://github.com/worldcoin/mobile-bench-rs" +documentation = "https://docs.rs/mobench-macros" readme = "README.md" keywords = ["benchmark", "mobile", "macro", "procedural"] +[badges] +maintenance = { status = "actively-developed" } + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [lib] proc-macro = true diff --git a/crates/mobench-macros/src/lib.rs b/crates/mobench-macros/src/lib.rs index 88e40dc..926bef7 100644 --- a/crates/mobench-macros/src/lib.rs +++ b/crates/mobench-macros/src/lib.rs @@ -1,19 +1,88 @@ -//! Procedural macros for mobench-sdk +//! # mobench-macros //! -//! This crate provides the `#[benchmark]` attribute macro for marking functions -//! as benchmarkable. Functions marked with this attribute are automatically -//! registered and can be discovered at runtime. +//! Procedural macros for the mobench mobile benchmarking SDK. +//! +//! This crate provides the [`#[benchmark]`](macro@benchmark) attribute macro +//! that marks functions for mobile benchmarking. Functions annotated with this +//! macro are automatically registered in a global registry and can be discovered +//! and executed at runtime. +//! +//! ## Usage +//! +//! Most users should import the macro via [`mobench-sdk`](https://crates.io/crates/mobench-sdk) +//! rather than using this crate directly: +//! +//! ```ignore +//! use mobench_sdk::benchmark; +//! +//! #[benchmark] +//! fn my_benchmark() { +//! // Your benchmark code here +//! let result = expensive_computation(); +//! std::hint::black_box(result); +//! } +//! ``` +//! +//! ## How It Works +//! +//! The `#[benchmark]` macro: +//! +//! 1. **Preserves the original function** - The function remains callable as normal +//! 2. **Registers with inventory** - Creates a static registration that the SDK discovers at runtime +//! 3. **Captures the fully-qualified name** - Uses `module_path!()` to generate unique names like `my_crate::my_module::my_benchmark` +//! +//! ## Requirements +//! +//! - The [`inventory`](https://crates.io/crates/inventory) crate must be in your dependency tree +//! - Functions must have no parameters and return `()` +//! - The function should not panic during normal execution +//! +//! ## Example: Multiple Benchmarks +//! +//! ```ignore +//! use mobench_sdk::benchmark; +//! +//! #[benchmark] +//! fn benchmark_sorting() { +//! let mut data: Vec = (0..1000).rev().collect(); +//! data.sort(); +//! std::hint::black_box(data); +//! } +//! +//! #[benchmark] +//! fn benchmark_hashing() { +//! use std::collections::hash_map::DefaultHasher; +//! use std::hash::{Hash, Hasher}; +//! +//! let mut hasher = DefaultHasher::new(); +//! "hello world".hash(&mut hasher); +//! std::hint::black_box(hasher.finish()); +//! } +//! ``` +//! +//! Both functions will be registered with names like: +//! - `my_crate::benchmark_sorting` +//! - `my_crate::benchmark_hashing` +//! +//! ## Crate Ecosystem +//! +//! This crate is part of the mobench ecosystem: +//! +//! - **[`mobench-sdk`](https://crates.io/crates/mobench-sdk)** - Core SDK (re-exports this macro) +//! - **[`mobench`](https://crates.io/crates/mobench)** - CLI tool +//! - **`mobench-macros`** (this crate) - Proc macros +//! - **[`mobench-runner`](https://crates.io/crates/mobench-runner)** - Timing harness use proc_macro::TokenStream; use quote::quote; use syn::{ItemFn, parse_macro_input}; -/// Marks a function as a benchmark. +/// Marks a function as a benchmark for mobile execution. /// -/// This macro registers the function in the global benchmark registry and -/// makes it available for execution via the mobench-sdk runtime. +/// This attribute macro registers the function in the global benchmark registry, +/// making it discoverable and executable by the mobench runtime. /// -/// # Example +/// # Usage /// /// ```ignore /// use mobench_sdk::benchmark; @@ -25,8 +94,96 @@ use syn::{ItemFn, parse_macro_input}; /// } /// ``` /// -/// The macro preserves the original function and creates a registration entry -/// that allows the benchmark to be discovered and invoked by name. +/// # Function Requirements +/// +/// The annotated function must: +/// - Take no parameters +/// - Return `()` (unit type) +/// - Not panic during normal execution +/// +/// # Best Practices +/// +/// ## Use `black_box` to Prevent Optimization +/// +/// Always wrap results with [`std::hint::black_box`] to prevent the compiler +/// from optimizing away the computation: +/// +/// ```ignore +/// #[benchmark] +/// fn good_benchmark() { +/// let result = compute_something(); +/// std::hint::black_box(result); // Prevents optimization +/// } +/// ``` +/// +/// ## Avoid Side Effects +/// +/// Benchmarks should be deterministic. Avoid: +/// - File I/O +/// - Network calls +/// - Random number generation (unless seeded) +/// - Global mutable state +/// +/// ## Keep Benchmarks Focused +/// +/// Each benchmark should measure one specific operation: +/// +/// ```ignore +/// // Good: Focused benchmark +/// #[benchmark] +/// fn benchmark_json_parse() { +/// let json = r#"{"key": "value"}"#; +/// let parsed: serde_json::Value = serde_json::from_str(json).unwrap(); +/// std::hint::black_box(parsed); +/// } +/// +/// // Avoid: Multiple operations in one benchmark +/// #[benchmark] +/// fn benchmark_everything() { +/// let json = create_json(); // Measured +/// let parsed = parse_json(&json); // Measured +/// let serialized = serialize(parsed); // Measured +/// std::hint::black_box(serialized); +/// } +/// ``` +/// +/// # Generated Code +/// +/// The macro generates code equivalent to: +/// +/// ```ignore +/// fn my_benchmark() { +/// // Original function body +/// } +/// +/// inventory::submit! { +/// mobench_sdk::registry::BenchFunction { +/// name: "my_crate::my_module::my_benchmark", +/// invoke: |_args| { +/// my_benchmark(); +/// Ok(()) +/// }, +/// } +/// } +/// ``` +/// +/// # Discovering Benchmarks +/// +/// Registered benchmarks can be discovered at runtime: +/// +/// ```ignore +/// use mobench_sdk::{discover_benchmarks, list_benchmark_names}; +/// +/// // Get all benchmark names +/// for name in list_benchmark_names() { +/// println!("Found: {}", name); +/// } +/// +/// // Get full benchmark info +/// for bench in discover_benchmarks() { +/// println!("Benchmark: {}", bench.name); +/// } +/// ``` #[proc_macro_attribute] pub fn benchmark(_attr: TokenStream, item: TokenStream) -> TokenStream { let input_fn = parse_macro_input!(item as ItemFn); diff --git a/crates/mobench-runner/Cargo.toml b/crates/mobench-runner/Cargo.toml index ccac3bd..dbccfb2 100644 --- a/crates/mobench-runner/Cargo.toml +++ b/crates/mobench-runner/Cargo.toml @@ -6,9 +6,20 @@ license.workspace = true authors = ["Dominik Clemente - dcbuilder.eth "] description = "Lightweight benchmarking harness for mobile devices" repository = "https://github.com/worldcoin/mobile-bench-rs" +documentation = "https://docs.rs/mobench-runner" readme = "README.md" keywords = ["benchmark", "mobile", "timing", "profiling"] +[badges] +maintenance = { status = "actively-developed" } + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [dependencies] serde.workspace = true thiserror.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/mobench-runner/src/lib.rs b/crates/mobench-runner/src/lib.rs index 915109b..a931c15 100644 --- a/crates/mobench-runner/src/lib.rs +++ b/crates/mobench-runner/src/lib.rs @@ -1,19 +1,151 @@ -//! Shared benchmarking harness that will be compiled into mobile targets. -//! For now this runs on the host and provides the same API surface we will -//! expose over FFI to Kotlin/Swift. +//! # mobench-runner +//! +//! A lightweight benchmarking harness designed for mobile platforms. +//! +//! This crate provides the core timing infrastructure for the mobench ecosystem. +//! It's designed to be minimal and portable, with no platform-specific dependencies, +//! making it suitable for compilation to Android and iOS targets. +//! +//! ## Overview +//! +//! The runner executes benchmark functions with: +//! - Configurable warmup iterations +//! - Precise nanosecond-resolution timing +//! - Simple, serializable results +//! +//! ## Usage +//! +//! Most users should use this crate via [`mobench-sdk`](https://crates.io/crates/mobench-sdk). +//! Direct usage is typically only needed for custom integrations: +//! +//! ``` +//! use mobench_runner::{BenchSpec, run_closure, BenchError}; +//! +//! // Define a benchmark specification +//! let spec = BenchSpec::new("my_benchmark", 100, 10)?; +//! +//! // Run the benchmark +//! let report = run_closure(spec, || { +//! // Your benchmark code +//! let sum: u64 = (0..1000).sum(); +//! std::hint::black_box(sum); +//! Ok(()) +//! })?; +//! +//! // Analyze results +//! let mean_ns = report.samples.iter() +//! .map(|s| s.duration_ns) +//! .sum::() / report.samples.len() as u64; +//! +//! println!("Mean: {} ns", mean_ns); +//! # Ok::<(), BenchError>(()) +//! ``` +//! +//! ## Types +//! +//! | Type | Description | +//! |------|-------------| +//! | [`BenchSpec`] | Benchmark configuration (name, iterations, warmup) | +//! | [`BenchSample`] | Single timing measurement in nanoseconds | +//! | [`BenchReport`] | Complete results with all samples | +//! | [`BenchError`] | Error conditions during benchmarking | +//! +//! ## Crate Ecosystem +//! +//! This crate is part of the mobench ecosystem: +//! +//! - **[`mobench-sdk`](https://crates.io/crates/mobench-sdk)** - Core SDK with build automation +//! - **[`mobench`](https://crates.io/crates/mobench)** - CLI tool +//! - **[`mobench-macros`](https://crates.io/crates/mobench-macros)** - `#[benchmark]` proc macro +//! - **`mobench-runner`** (this crate) - Timing harness use serde::{Deserialize, Serialize}; use std::time::{Duration, Instant}; use thiserror::Error; +/// Benchmark specification defining what and how to benchmark. +/// +/// Contains the benchmark name, number of measurement iterations, and +/// warmup iterations to perform before measuring. +/// +/// # Example +/// +/// ``` +/// use mobench_runner::BenchSpec; +/// +/// // Create a spec for 100 iterations with 10 warmup runs +/// let spec = BenchSpec::new("sorting_benchmark", 100, 10)?; +/// +/// assert_eq!(spec.name, "sorting_benchmark"); +/// assert_eq!(spec.iterations, 100); +/// assert_eq!(spec.warmup, 10); +/// # Ok::<(), mobench_runner::BenchError>(()) +/// ``` +/// +/// # Serialization +/// +/// `BenchSpec` implements `Serialize` and `Deserialize` for JSON persistence: +/// +/// ``` +/// use mobench_runner::BenchSpec; +/// +/// let spec = BenchSpec { +/// name: "my_bench".to_string(), +/// iterations: 50, +/// warmup: 5, +/// }; +/// +/// let json = serde_json::to_string(&spec)?; +/// let restored: BenchSpec = serde_json::from_str(&json)?; +/// +/// assert_eq!(spec.name, restored.name); +/// # Ok::<(), serde_json::Error>(()) +/// ``` #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BenchSpec { + /// Name of the benchmark, typically the fully-qualified function name. + /// + /// Examples: `"my_crate::fibonacci"`, `"sorting_benchmark"` pub name: String, + + /// Number of iterations to measure. + /// + /// Each iteration produces one [`BenchSample`]. Must be greater than zero. pub iterations: u32, + + /// Number of warmup iterations before measurement. + /// + /// Warmup iterations are not recorded. They allow CPU caches to warm + /// and any JIT compilation to complete. Can be zero. pub warmup: u32, } impl BenchSpec { + /// Creates a new benchmark specification. + /// + /// # Arguments + /// + /// * `name` - Name identifier for the benchmark + /// * `iterations` - Number of measured iterations (must be > 0) + /// * `warmup` - Number of warmup iterations (can be 0) + /// + /// # Errors + /// + /// Returns [`BenchError::NoIterations`] if `iterations` is zero. + /// + /// # Example + /// + /// ``` + /// use mobench_runner::BenchSpec; + /// + /// let spec = BenchSpec::new("test", 100, 10)?; + /// assert_eq!(spec.iterations, 100); + /// + /// // Zero iterations is an error + /// let err = BenchSpec::new("test", 0, 10); + /// assert!(err.is_err()); + /// # Ok::<(), mobench_runner::BenchError>(()) + /// ``` pub fn new(name: impl Into, iterations: u32, warmup: u32) -> Result { if iterations == 0 { return Err(BenchError::NoIterations); @@ -27,12 +159,32 @@ impl BenchSpec { } } +/// A single timing sample from a benchmark iteration. +/// +/// Contains the elapsed time in nanoseconds for one execution of the +/// benchmark function. +/// +/// # Example +/// +/// ``` +/// use mobench_runner::BenchSample; +/// +/// let sample = BenchSample { duration_ns: 1_500_000 }; +/// +/// // Convert to milliseconds +/// let ms = sample.duration_ns as f64 / 1_000_000.0; +/// assert_eq!(ms, 1.5); +/// ``` #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BenchSample { + /// Duration of the iteration in nanoseconds. + /// + /// Measured using [`std::time::Instant`] for monotonic, high-resolution timing. pub duration_ns: u64, } impl BenchSample { + /// Creates a sample from a [`Duration`]. fn from_duration(duration: Duration) -> Self { Self { duration_ns: duration.as_nanos() as u64, @@ -40,20 +192,132 @@ impl BenchSample { } } +/// Complete benchmark report with all timing samples. +/// +/// Contains the original specification and all collected samples. +/// Can be serialized to JSON for storage or transmission. +/// +/// # Example +/// +/// ``` +/// use mobench_runner::{BenchSpec, run_closure}; +/// +/// let spec = BenchSpec::new("example", 50, 5)?; +/// let report = run_closure(spec, || { +/// std::hint::black_box(42); +/// Ok(()) +/// })?; +/// +/// // Calculate statistics +/// let samples: Vec = report.samples.iter() +/// .map(|s| s.duration_ns) +/// .collect(); +/// +/// let min = samples.iter().min().unwrap(); +/// let max = samples.iter().max().unwrap(); +/// let mean = samples.iter().sum::() / samples.len() as u64; +/// +/// println!("Min: {} ns, Max: {} ns, Mean: {} ns", min, max, mean); +/// # Ok::<(), mobench_runner::BenchError>(()) +/// ``` #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BenchReport { + /// The specification used for this benchmark run. pub spec: BenchSpec, + + /// All collected timing samples. + /// + /// The length equals `spec.iterations`. Samples are in execution order. pub samples: Vec, } +/// Errors that can occur during benchmark execution. +/// +/// # Example +/// +/// ``` +/// use mobench_runner::{BenchSpec, BenchError}; +/// +/// // Zero iterations produces an error +/// let result = BenchSpec::new("test", 0, 10); +/// assert!(matches!(result, Err(BenchError::NoIterations))); +/// ``` #[derive(Debug, Error)] pub enum BenchError { + /// The iteration count was zero. + /// + /// At least one iteration is required to produce a measurement. #[error("iterations must be greater than zero")] NoIterations, + + /// The benchmark function failed during execution. + /// + /// Contains a description of the failure. #[error("benchmark function failed: {0}")] Execution(String), } +/// Runs a benchmark by executing a closure repeatedly. +/// +/// This is the core benchmarking function. It: +/// +/// 1. Executes the closure `spec.warmup` times without recording +/// 2. Executes the closure `spec.iterations` times, recording each duration +/// 3. Returns a [`BenchReport`] with all samples +/// +/// # Arguments +/// +/// * `spec` - Benchmark configuration specifying iterations and warmup +/// * `f` - Closure to benchmark; must return `Result<(), BenchError>` +/// +/// # Returns +/// +/// A [`BenchReport`] containing all timing samples, or a [`BenchError`] if +/// the benchmark fails. +/// +/// # Example +/// +/// ``` +/// use mobench_runner::{BenchSpec, run_closure, BenchError}; +/// +/// let spec = BenchSpec::new("sum_benchmark", 100, 10)?; +/// +/// let report = run_closure(spec, || { +/// let sum: u64 = (0..1000).sum(); +/// std::hint::black_box(sum); +/// Ok(()) +/// })?; +/// +/// assert_eq!(report.samples.len(), 100); +/// +/// // Calculate mean duration +/// let total_ns: u64 = report.samples.iter().map(|s| s.duration_ns).sum(); +/// let mean_ns = total_ns / report.samples.len() as u64; +/// println!("Mean: {} ns", mean_ns); +/// # Ok::<(), BenchError>(()) +/// ``` +/// +/// # Error Handling +/// +/// If the closure returns an error, the benchmark stops immediately: +/// +/// ``` +/// use mobench_runner::{BenchSpec, run_closure, BenchError}; +/// +/// let spec = BenchSpec::new("failing_bench", 100, 0)?; +/// +/// let result = run_closure(spec, || { +/// Err(BenchError::Execution("simulated failure".into())) +/// }); +/// +/// assert!(result.is_err()); +/// # Ok::<(), BenchError>(()) +/// ``` +/// +/// # Timing Precision +/// +/// Uses [`std::time::Instant`] for timing, which provides monotonic, +/// nanosecond-resolution measurements on most platforms. pub fn run_closure(spec: BenchSpec, mut f: F) -> Result where F: FnMut() -> Result<(), BenchError>, @@ -62,10 +326,12 @@ where return Err(BenchError::NoIterations); } + // Warmup phase - not measured for _ in 0..spec.warmup { f()?; } + // Measurement phase let mut samples = Vec::with_capacity(spec.iterations as usize); for _ in 0..spec.iterations { let start = Instant::now(); @@ -89,4 +355,31 @@ mod tests { let non_zero = report.samples.iter().filter(|s| s.duration_ns > 0).count(); assert!(non_zero >= 1); } + + #[test] + fn rejects_zero_iterations() { + let result = BenchSpec::new("test", 0, 10); + assert!(matches!(result, Err(BenchError::NoIterations))); + } + + #[test] + fn allows_zero_warmup() { + let spec = BenchSpec::new("test", 5, 0).unwrap(); + assert_eq!(spec.warmup, 0); + + let report = run_closure(spec, || Ok(())).unwrap(); + assert_eq!(report.samples.len(), 5); + } + + #[test] + fn serializes_to_json() { + let spec = BenchSpec::new("test", 10, 2).unwrap(); + let report = run_closure(spec, || Ok(())).unwrap(); + + let json = serde_json::to_string(&report).unwrap(); + let restored: BenchReport = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.spec.name, "test"); + assert_eq!(restored.samples.len(), 10); + } } diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 3fd8d01..69b33d0 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -6,9 +6,17 @@ license.workspace = true authors = ["Dominik Clemente - dcbuilder.eth "] description = "Mobile benchmarking SDK for Rust - run benchmarks on real devices" repository = "https://github.com/worldcoin/mobile-bench-rs" +documentation = "https://docs.rs/mobench-sdk" readme = "README.md" keywords = ["benchmark", "mobile", "android", "ios", "performance"] categories = ["development-tools::profiling", "development-tools::testing"] + +[badges] +maintenance = { status = "actively-developed" } + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] include = [ "src/**/*", "templates/**/*", diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 0803365..3401136 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -1,74 +1,268 @@ -//! Mobile Benchmark SDK for Rust +//! # mobench-sdk //! -//! `mobench-sdk` is a library for benchmarking Rust functions on real mobile devices -//! (Android and iOS) via BrowserStack. It provides a simple API similar to criterion.rs -//! but targets mobile platforms. +//! A mobile benchmarking SDK for Rust that enables running performance benchmarks +//! on real Android and iOS devices via BrowserStack App Automate. //! -//! # Quick Start +//! ## Overview +//! +//! `mobench-sdk` provides a simple, declarative API for defining benchmarks that can +//! run on mobile devices. It handles the complexity of cross-compilation, FFI bindings, +//! and mobile app packaging automatically. +//! +//! ## Quick Start +//! +//! ### 1. Add Dependencies //! -//! 1. Add mobench-sdk to your project: //! ```toml //! [dependencies] //! mobench-sdk = "0.1" +//! inventory = "0.3" # Required for benchmark registration //! ``` //! -//! 2. Mark functions with `#[benchmark]`: +//! ### 2. Define Benchmarks +//! +//! Use the [`#[benchmark]`](macro@benchmark) attribute to mark functions for benchmarking: +//! //! ```ignore //! use mobench_sdk::benchmark; //! //! #[benchmark] //! fn my_expensive_operation() { -//! // Your code here -//! let result = compute_something(); -//! std::hint::black_box(result); +//! let result = expensive_computation(); +//! std::hint::black_box(result); // Prevent optimization //! } -//! ``` //! -//! 3. Initialize mobile project: -//! ```bash -//! cargo mobench init --target android +//! #[benchmark] +//! fn another_benchmark() { +//! for i in 0..1000 { +//! std::hint::black_box(i * i); +//! } +//! } //! ``` //! -//! 4. Build and run: +//! ### 3. Build and Run +//! +//! Use the `mobench` CLI to build and run benchmarks: +//! //! ```bash +//! # Install the CLI +//! cargo install mobench +//! +//! # Build for Android (outputs to target/mobench/) //! cargo mobench build --target android -//! cargo mobench run --target android --function my_expensive_operation +//! +//! # Build for iOS +//! cargo mobench build --target ios +//! +//! # Run on BrowserStack +//! cargo mobench run --target android --function my_expensive_operation \ +//! --iterations 100 --warmup 10 --devices "Google Pixel 7-13.0" //! ``` //! -//! # Architecture +//! ## Architecture //! //! The SDK consists of several components: //! -//! - **Registry**: Discovers functions marked with `#[benchmark]` at runtime -//! - **Runner**: Executes benchmarks and collects timing data -//! - **Builders**: Automates building Android/iOS apps -//! - **Codegen**: Generates mobile app templates +//! | Module | Description | +//! |--------|-------------| +//! | [`registry`] | Runtime discovery of `#[benchmark]` functions | +//! | [`runner`] | Benchmark execution and timing infrastructure | +//! | [`builders`] | Android and iOS build automation | +//! | [`codegen`] | Mobile app template generation | +//! | [`types`] | Common types and error definitions | +//! +//! ## Crate Ecosystem +//! +//! The mobench ecosystem consists of four crates: +//! +//! - **`mobench-sdk`** (this crate) - Core SDK library with build automation +//! - **[`mobench`](https://crates.io/crates/mobench)** - CLI tool for building and running benchmarks +//! - **[`mobench-macros`](https://crates.io/crates/mobench-macros)** - `#[benchmark]` proc macro +//! - **[`mobench-runner`](https://crates.io/crates/mobench-runner)** - Lightweight timing harness //! -//! # Example: Programmatic Usage +//! ## Programmatic Usage +//! +//! You can also use the SDK programmatically: +//! +//! ### Using the Builder Pattern //! //! ```ignore -//! use mobench_sdk::{BenchmarkBuilder, BenchSpec}; -//! -//! fn main() -> Result<(), mobench_sdk::BenchError> { -//! // Using the builder pattern -//! let report = BenchmarkBuilder::new("my_benchmark") -//! .iterations(100) -//! .warmup(10) -//! .run()?; -//! -//! println!("Samples: {}", report.samples.len()); -//! -//! // Or using BenchSpec directly -//! let spec = BenchSpec { -//! name: "my_benchmark".to_string(), -//! iterations: 50, -//! warmup: 5, -//! }; -//! let report = mobench_sdk::run_benchmark(spec)?; -//! -//! Ok(()) +//! use mobench_sdk::BenchmarkBuilder; +//! +//! let report = BenchmarkBuilder::new("my_benchmark") +//! .iterations(100) +//! .warmup(10) +//! .run()?; +//! +//! println!("Mean: {} ns", report.samples.iter() +//! .map(|s| s.duration_ns) +//! .sum::() / report.samples.len() as u64); +//! ``` +//! +//! ### Using BenchSpec Directly +//! +//! ```ignore +//! use mobench_sdk::{BenchSpec, run_benchmark}; +//! +//! let spec = BenchSpec { +//! name: "my_benchmark".to_string(), +//! iterations: 50, +//! warmup: 5, +//! }; +//! +//! let report = run_benchmark(spec)?; +//! println!("Collected {} samples", report.samples.len()); +//! ``` +//! +//! ### Discovering Benchmarks +//! +//! ```ignore +//! use mobench_sdk::{discover_benchmarks, list_benchmark_names}; +//! +//! // Get all registered benchmark names +//! let names = list_benchmark_names(); +//! for name in names { +//! println!("Found benchmark: {}", name); //! } +//! +//! // Get full benchmark function info +//! let benchmarks = discover_benchmarks(); +//! for bench in benchmarks { +//! println!("Benchmark: {}", bench.name); +//! } +//! ``` +//! +//! ## Building Mobile Apps +//! +//! The SDK includes builders for automating mobile app creation: +//! +//! ### Android Builder +//! +//! ```ignore +//! use mobench_sdk::builders::AndroidBuilder; +//! use mobench_sdk::{BuildConfig, BuildProfile, Target}; +//! +//! let builder = AndroidBuilder::new(".", "my-bench-crate") +//! .verbose(true) +//! .output_dir("target/mobench"); // Default +//! +//! let config = BuildConfig { +//! target: Target::Android, +//! profile: BuildProfile::Release, +//! incremental: true, +//! }; +//! +//! let result = builder.build(&config)?; +//! println!("APK built at: {:?}", result.app_path); //! ``` +//! +//! ### iOS Builder +//! +//! ```ignore +//! use mobench_sdk::builders::{IosBuilder, SigningMethod}; +//! use mobench_sdk::{BuildConfig, BuildProfile, Target}; +//! +//! let builder = IosBuilder::new(".", "my-bench-crate") +//! .verbose(true); +//! +//! let config = BuildConfig { +//! target: Target::Ios, +//! profile: BuildProfile::Release, +//! incremental: true, +//! }; +//! +//! let result = builder.build(&config)?; +//! println!("xcframework built at: {:?}", result.app_path); +//! +//! // Package IPA for distribution +//! let ipa_path = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?; +//! ``` +//! +//! ## Output Directory +//! +//! By default, all mobile artifacts are written to `target/mobench/`: +//! +//! ```text +//! target/mobench/ +//! ├── android/ +//! │ ├── app/ +//! │ │ ├── src/main/jniLibs/ # Native .so libraries +//! │ │ └── build/outputs/apk/ # Built APK +//! │ └── ... +//! └── ios/ +//! ├── sample_fns.xcframework/ # Built xcframework +//! ├── BenchRunner/ # Xcode project +//! └── BenchRunner.ipa # Packaged IPA +//! ``` +//! +//! This keeps generated files inside `target/`, following Rust conventions +//! and preventing accidental commits of mobile project files. +//! +//! ## Platform Requirements +//! +//! ### Android +//! +//! - Android NDK (set `ANDROID_NDK_HOME` environment variable) +//! - `cargo-ndk` (`cargo install cargo-ndk`) +//! - Rust targets: `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` +//! +//! ### iOS +//! +//! - Xcode with command line tools +//! - `uniffi-bindgen` (`cargo install uniffi-bindgen`) +//! - `xcodegen` (optional, `brew install xcodegen`) +//! - Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim` +//! +//! ## Best Practices +//! +//! ### Use `black_box` to Prevent Optimization +//! +//! Always wrap benchmark results with [`std::hint::black_box`] to prevent the +//! compiler from optimizing away the computation: +//! +//! ```ignore +//! #[benchmark] +//! fn correct_benchmark() { +//! let result = expensive_computation(); +//! std::hint::black_box(result); // Result is "used" +//! } +//! ``` +//! +//! ### Avoid Side Effects +//! +//! Benchmarks should be deterministic and avoid I/O operations: +//! +//! ```ignore +//! // Good: Pure computation +//! #[benchmark] +//! fn good_benchmark() { +//! let data = vec![1, 2, 3, 4, 5]; +//! let sum: i32 = data.iter().sum(); +//! std::hint::black_box(sum); +//! } +//! +//! // Avoid: File I/O adds noise +//! #[benchmark] +//! fn noisy_benchmark() { +//! let data = std::fs::read_to_string("data.txt").unwrap(); // Don't do this +//! std::hint::black_box(data); +//! } +//! ``` +//! +//! ### Choose Appropriate Iteration Counts +//! +//! - **Warmup**: 5-10 iterations to warm CPU caches and JIT +//! - **Iterations**: 50-100 for stable statistics +//! - Mobile devices may have more variance than desktop +//! +//! ## Feature Flags +//! +//! Currently, `mobench-sdk` has no optional feature flags. All functionality +//! is included by default. +//! +//! ## License +//! +//! MIT License - see repository for details. // Public modules pub mod builders; @@ -91,7 +285,13 @@ pub use types::{ // Re-export mobench-runner types for backward compatibility pub use mobench_runner; -/// Library version +/// Library version, matching `Cargo.toml`. +/// +/// This can be used to verify SDK compatibility: +/// +/// ``` +/// assert!(!mobench_sdk::VERSION.is_empty()); +/// ``` pub const VERSION: &str = env!("CARGO_PKG_VERSION"); #[cfg(test)] diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs index 803b640..a5fc5f4 100644 --- a/crates/mobench-sdk/src/types.rs +++ b/crates/mobench-sdk/src/types.rs @@ -1,6 +1,20 @@ -//! Core types for mobench-sdk +//! Core types for mobench-sdk. //! -//! This module re-exports types from mobench-runner and adds SDK-specific types. +//! This module defines the fundamental types used throughout the SDK: +//! +//! - [`BenchError`] - Error types for benchmark and build operations +//! - [`Target`] - Platform selection (Android, iOS, or both) +//! - [`BuildConfig`] / [`BuildProfile`] - Build configuration options +//! - [`BuildResult`] - Output from build operations +//! - [`InitConfig`] - Project initialization settings +//! +//! ## Re-exports from mobench-runner +//! +//! For convenience, this module also re-exports types from [`mobench_runner`]: +//! +//! - [`BenchSpec`] - Benchmark specification (name, iterations, warmup) +//! - [`BenchSample`] - Single timing measurement +//! - [`RunnerReport`] - Complete benchmark results // Re-export mobench-runner types for convenience pub use mobench_runner::{ @@ -9,50 +23,118 @@ pub use mobench_runner::{ use std::path::PathBuf; -/// Error types for mobench-sdk operations +/// Error types for mobench-sdk operations. +/// +/// This enum covers all error conditions that can occur during +/// benchmark registration, execution, and mobile app building. +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::{run_benchmark, BenchSpec, BenchError}; +/// +/// let spec = BenchSpec { +/// name: "nonexistent".to_string(), +/// iterations: 10, +/// warmup: 1, +/// }; +/// +/// match run_benchmark(spec) { +/// Ok(report) => println!("Success!"), +/// Err(BenchError::UnknownFunction(name)) => { +/// eprintln!("Benchmark '{}' not found", name); +/// } +/// Err(e) => eprintln!("Other error: {}", e), +/// } +/// ``` #[derive(Debug, thiserror::Error)] pub enum BenchError { - /// Error from the benchmark runner + /// Error from the underlying benchmark runner. + /// + /// This wraps errors from [`mobench_runner::BenchError`], such as + /// zero iterations or execution failures. #[error("runner error: {0}")] Runner(#[from] mobench_runner::BenchError), - /// Benchmark function not found in registry + /// The requested benchmark function was not found in the registry. + /// + /// This occurs when calling [`run_benchmark`](crate::run_benchmark) with + /// a function name that hasn't been registered via `#[benchmark]`. #[error("unknown benchmark function: {0}")] UnknownFunction(String), - /// Error during benchmark execution + /// An error occurred during benchmark execution. + /// + /// This is a catch-all for execution-time errors that don't fit + /// other categories. #[error("execution error: {0}")] Execution(String), - /// I/O error + /// An I/O error occurred. + /// + /// Common causes include missing files, permission issues, or + /// disk space problems during build operations. #[error("I/O error: {0}")] Io(#[from] std::io::Error), - /// Serialization error + /// JSON serialization or deserialization failed. + /// + /// This can occur when reading/writing benchmark specifications + /// or configuration files. #[error("serialization error: {0}")] Serialization(#[from] serde_json::Error), - /// Configuration error + /// A configuration error occurred. + /// + /// This indicates invalid or missing configuration, such as + /// malformed TOML files or missing required fields. #[error("configuration error: {0}")] Config(String), - /// Build error + /// A build error occurred. + /// + /// This covers failures during mobile app building, including: + /// - Missing build tools (cargo-ndk, xcodebuild, etc.) + /// - Compilation errors + /// - Code signing failures + /// - Missing dependencies #[error("build error: {0}")] Build(String), } -/// Target platform for benchmarks +/// Target platform for benchmarks. +/// +/// Specifies which mobile platform(s) to build for or run benchmarks on. +/// +/// # Example +/// +/// ``` +/// use mobench_sdk::Target; +/// +/// let target = Target::Android; +/// assert_eq!(target.as_str(), "android"); +/// +/// let both = Target::Both; +/// assert_eq!(both.as_str(), "both"); +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Target { - /// Android platform + /// Android platform (APK with native .so libraries). Android, - /// iOS platform + /// iOS platform (xcframework with static libraries). Ios, - /// Both platforms + /// Both Android and iOS platforms. Both, } impl Target { + /// Returns the string representation of the target. + /// + /// # Returns + /// + /// - `"android"` for [`Target::Android`] + /// - `"ios"` for [`Target::Ios`] + /// - `"both"` for [`Target::Both`] pub fn as_str(&self) -> &'static str { match self { Target::Android => "android", @@ -62,40 +144,106 @@ impl Target { } } -/// Configuration for initializing a benchmark project +/// Configuration for initializing a new benchmark project. +/// +/// Used by the `cargo mobench init` command to generate project scaffolding. +/// +/// # Example +/// +/// ``` +/// use mobench_sdk::{InitConfig, Target}; +/// use std::path::PathBuf; +/// +/// let config = InitConfig { +/// target: Target::Android, +/// project_name: "my-benchmarks".to_string(), +/// output_dir: PathBuf::from("./bench-mobile"), +/// generate_examples: true, +/// }; +/// ``` #[derive(Debug, Clone)] pub struct InitConfig { - /// Target platform(s) + /// Target platform(s) to initialize for. pub target: Target, - /// Project name + /// Name of the benchmark project/crate. pub project_name: String, - /// Output directory for generated files + /// Output directory for generated files. pub output_dir: PathBuf, - /// Whether to generate example benchmarks + /// Whether to generate example benchmark functions. pub generate_examples: bool, } -/// Configuration for building mobile apps +/// Configuration for building mobile apps. +/// +/// Controls the build process including target platform, optimization level, +/// and caching behavior. +/// +/// # Example +/// +/// ``` +/// use mobench_sdk::{BuildConfig, BuildProfile, Target}; +/// +/// // Release build for Android +/// let config = BuildConfig { +/// target: Target::Android, +/// profile: BuildProfile::Release, +/// incremental: true, +/// }; +/// +/// // Debug build for iOS +/// let ios_config = BuildConfig { +/// target: Target::Ios, +/// profile: BuildProfile::Debug, +/// incremental: false, // Force rebuild +/// }; +/// ``` #[derive(Debug, Clone)] pub struct BuildConfig { - /// Target platform + /// Target platform to build for. pub target: Target, - /// Build profile (debug or release) + /// Build profile (debug or release). pub profile: BuildProfile, - /// Whether to skip build if artifacts exist + /// If `true`, skip rebuilding if artifacts already exist. pub incremental: bool, } -/// Build profile +/// Build profile controlling optimization and debug info. +/// +/// Similar to Cargo's `--release` flag, this controls whether the build +/// is optimized for debugging or performance. +/// +/// # Example +/// +/// ``` +/// use mobench_sdk::BuildProfile; +/// +/// let debug = BuildProfile::Debug; +/// assert_eq!(debug.as_str(), "debug"); +/// +/// let release = BuildProfile::Release; +/// assert_eq!(release.as_str(), "release"); +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BuildProfile { - /// Debug build + /// Debug build with debug symbols and no optimizations. + /// + /// Faster compilation but slower runtime. Useful for development + /// and troubleshooting. Debug, - /// Release build + /// Release build with optimizations enabled. + /// + /// Slower compilation but faster runtime. Use this for actual + /// benchmark measurements. Release, } impl BuildProfile { + /// Returns the string representation of the profile. + /// + /// # Returns + /// + /// - `"debug"` for [`BuildProfile::Debug`] + /// - `"release"` for [`BuildProfile::Release`] pub fn as_str(&self) -> &'static str { match self { BuildProfile::Debug => "debug", @@ -104,13 +252,35 @@ impl BuildProfile { } } -/// Result of a build operation +/// Result of a successful build operation. +/// +/// Contains paths to the built artifacts, which can be used for +/// deployment to BrowserStack or local testing. +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::builders::AndroidBuilder; +/// +/// let result = builder.build(&config)?; +/// +/// println!("App built at: {:?}", result.app_path); +/// if let Some(test_suite) = result.test_suite_path { +/// println!("Test suite at: {:?}", test_suite); +/// } +/// ``` #[derive(Debug, Clone)] pub struct BuildResult { - /// Platform that was built + /// Platform that was built. pub platform: Target, - /// Path to the app artifact (APK, IPA, etc.) + /// Path to the main app artifact. + /// + /// - Android: Path to the APK file + /// - iOS: Path to the xcframework directory pub app_path: PathBuf, - /// Path to the test suite artifact (if applicable) + /// Path to the test suite artifact, if applicable. + /// + /// - Android: Path to the androidTest APK (for Espresso) + /// - iOS: Path to the XCUITest runner zip pub test_suite_path: Option, } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 5839275..fac60da 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -6,10 +6,18 @@ license.workspace = true authors = ["Dominik Clemente - dcbuilder.eth "] description = "Mobile benchmarking CLI for Rust - Run benchmarks on real Android and iOS devices" repository = "https://github.com/worldcoin/mobile-bench-rs" +documentation = "https://docs.rs/mobench" readme = "README.md" keywords = ["benchmark", "mobile", "android", "ios", "performance"] categories = ["development-tools", "command-line-utilities"] +[badges] +maintenance = { status = "actively-developed" } + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + default-run = "mobench" # Dual binary targets allow the tool to work as both: diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 8a15392..f76ea3a 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -1,3 +1,100 @@ +//! # mobench +//! +//! Command-line tool for building and running Rust benchmarks on mobile devices. +//! +//! ## Overview +//! +//! `mobench` is the CLI orchestrator for the mobench ecosystem. It handles: +//! +//! - **Building** - Compiles Rust code for Android/iOS and packages mobile apps +//! - **Running** - Executes benchmarks locally or on BrowserStack devices +//! - **Reporting** - Collects and formats benchmark results +//! +//! ## Installation +//! +//! ```bash +//! cargo install mobench +//! ``` +//! +//! ## Quick Start +//! +//! ```bash +//! # Initialize a benchmark project +//! cargo mobench init --target android --output bench-config.toml +//! +//! # Build for Android +//! cargo mobench build --target android +//! +//! # Build for iOS +//! cargo mobench build --target ios +//! +//! # Run locally (no device required) +//! cargo mobench run --target android --function my_benchmark --local-only +//! +//! # Run on BrowserStack +//! cargo mobench run --target android --function my_benchmark \ +//! --iterations 100 --warmup 10 --devices "Google Pixel 7-13.0" +//! ``` +//! +//! ## Commands +//! +//! | Command | Description | +//! |---------|-------------| +//! | `init` | Initialize a new benchmark project | +//! | `build` | Build mobile artifacts (APK/xcframework) | +//! | `run` | Execute benchmarks locally or on devices | +//! | `list` | List discovered benchmark functions | +//! | `fetch` | Retrieve results from BrowserStack | +//! | `package-ipa` | Package iOS app as IPA | +//! | `package-xcuitest` | Package XCUITest runner | +//! +//! ## Output Directory +//! +//! All build artifacts are written to `target/mobench/` by default: +//! +//! ```text +//! target/mobench/ +//! ├── android/ # Android project and APK +//! └── ios/ # iOS project, xcframework, and IPA +//! ``` +//! +//! Use `--output-dir` to customize the output location. +//! +//! ## Configuration +//! +//! Benchmarks can be configured via command-line arguments or a TOML config file: +//! +//! ```toml +//! target = "android" +//! function = "my_crate::my_benchmark" +//! iterations = 100 +//! warmup = 10 +//! +//! [browserstack] +//! app_automate_username = "${BROWSERSTACK_USERNAME}" +//! app_automate_access_key = "${BROWSERSTACK_ACCESS_KEY}" +//! project = "my-project" +//! ``` +//! +//! ## BrowserStack Integration +//! +//! The CLI integrates with BrowserStack App Automate for running benchmarks +//! on real devices. Set credentials via environment variables: +//! +//! ```bash +//! export BROWSERSTACK_USERNAME="your_username" +//! export BROWSERSTACK_ACCESS_KEY="your_access_key" +//! ``` +//! +//! ## Crate Ecosystem +//! +//! This crate is part of the mobench ecosystem: +//! +//! - **`mobench`** (this crate) - CLI tool +//! - **[`mobench-sdk`](https://crates.io/crates/mobench-sdk)** - Core SDK with build automation +//! - **[`mobench-macros`](https://crates.io/crates/mobench-macros)** - `#[benchmark]` proc macro +//! - **[`mobench-runner`](https://crates.io/crates/mobench-runner)** - Timing harness + use anyhow::{Context, Result, anyhow, bail}; use clap::{Parser, Subcommand, ValueEnum}; use serde::{Deserialize, Serialize}; From f5c3a15f04a27a73a54c73785b9ccb01a3e37990 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 13:55:25 +0100 Subject: [PATCH 021/196] Bump version to 0.1.8 for release --- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- crates/mobench-sdk/Cargo.toml | 4 ++-- crates/mobench/Cargo.toml | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd30414..5b318ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "clap", @@ -821,7 +821,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.7" +version = "0.1.8" dependencies = [ "proc-macro2", "quote", @@ -830,7 +830,7 @@ dependencies = [ [[package]] name = "mobench-runner" -version = "0.1.7" +version = "0.1.8" dependencies = [ "serde", "serde_json", @@ -839,7 +839,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "include_dir", @@ -1165,7 +1165,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.7" +version = "0.1.8" dependencies = [ "camino", "mobench-runner", diff --git a/Cargo.toml b/Cargo.toml index e0782c8..3f68b6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.7" +version = "0.1.8" [workspace.dependencies] anyhow = "1" diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 69b33d0..65830d4 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -29,8 +29,8 @@ crate-type = ["lib"] [dependencies] # Core dependencies -mobench-runner = { version = "0.1.7", path = "../mobench-runner" } -mobench-macros = { version = "0.1.7", path = "../mobench-macros" } +mobench-runner = { version = "0.1.8", path = "../mobench-runner" } +mobench-macros = { version = "0.1.8", path = "../mobench-macros" } # Registry inventory.workspace = true diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index fac60da..f433e71 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -34,8 +34,8 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.7", path = "../mobench-sdk" } -mobench-runner = { version = "0.1.7", path = "../mobench-runner" } +mobench-sdk = { version = "0.1.8", path = "../mobench-sdk" } +mobench-runner = { version = "0.1.8", path = "../mobench-runner" } clap.workspace = true serde.workspace = true serde_json.workspace = true From 343fb045df6b39beee78302a15236ddba60c4f92 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 16:31:59 +0100 Subject: [PATCH 022/196] Implement all IMPROVEMENTS.md tasks with enhanced DX This commit implements all 11 developer experience improvements from IMPROVEMENTS.md, making mobench significantly more user-friendly. ## Key Changes ### P0 - Critical (Tasks 1-3) - Fix aws-lc-rs Android NDK incompatibility (ring backend) - Fix workspace target directory detection (cargo metadata) - Auto-generate project scaffolding during build ### P1 - High Priority (Tasks 4-6) - Process template variables during build (TemplateContext) - Improve uniffi-bindgen handling (local binary + fallback) - Generate error handling code dynamically (generic catch) ### P2 - Medium Priority (Tasks 7-9) - Add mobench.toml configuration file support - Improve error messages with actionable suggestions - Add --crate-path flag for custom crate locations ### P3 - Nice to Have (Tasks 10-11) - Add --dry-run and --verbose CLI modes - Auto-generate local.properties for Android ## Code Quality Improvements - Extract shared utilities to builders/common.rs (DRY) - Consistent error message formatting with: - Clear description of what failed - Context (commands, paths, exit status) - Actionable fix suggestions - Links to documentation - Standardized path formatting using display() ## New Files - crates/mobench/src/config.rs - Configuration file support - crates/mobench-sdk/src/builders/common.rs - Shared utilities - IMPROVEMENTS.md - Task tracking document ## Consolidation - Merged mobench-runner crate into mobench-sdk - Timing functionality now in mobench-sdk/src/timing.rs --- BUILD.md | 20 + CLAUDE.md | 13 +- Cargo.lock | 13 +- Cargo.toml | 1 - IMPROVEMENTS.md | 656 ++++++++++++++++++ README.md | 3 +- crates/mobench-macros/README.md | 3 +- crates/mobench-macros/src/lib.rs | 3 +- crates/mobench-runner/Cargo.toml | 25 - crates/mobench-runner/README.md | 131 ---- crates/mobench-sdk/Cargo.toml | 28 +- crates/mobench-sdk/README.md | 3 +- crates/mobench-sdk/src/builders/android.rs | 495 ++++++++++--- crates/mobench-sdk/src/builders/common.rs | 193 ++++++ crates/mobench-sdk/src/builders/ios.rs | 551 +++++++++++---- crates/mobench-sdk/src/builders/mod.rs | 1 + crates/mobench-sdk/src/codegen.rs | 116 +++- crates/mobench-sdk/src/lib.rs | 67 +- crates/mobench-sdk/src/runner.rs | 6 +- .../src/lib.rs => mobench-sdk/src/timing.rs} | 82 +-- crates/mobench-sdk/src/types.rs | 28 +- .../src/main/java/MainActivity.kt.template | 9 +- .../BenchRunner/BenchRunnerFFI.swift.template | 11 +- crates/mobench/Cargo.toml | 1 - crates/mobench/README.md | 3 +- crates/mobench/src/config.rs | 630 +++++++++++++++++ crates/mobench/src/lib.rs | 172 ++++- crates/sample-fns/Cargo.toml | 2 +- crates/sample-fns/src/lib.rs | 40 +- 29 files changed, 2719 insertions(+), 587 deletions(-) create mode 100644 IMPROVEMENTS.md delete mode 100644 crates/mobench-runner/Cargo.toml delete mode 100644 crates/mobench-runner/README.md create mode 100644 crates/mobench-sdk/src/builders/common.rs rename crates/{mobench-runner/src/lib.rs => mobench-sdk/src/timing.rs} (80%) create mode 100644 crates/mobench/src/config.rs diff --git a/BUILD.md b/BUILD.md index df05be3..d149016 100644 --- a/BUILD.md +++ b/BUILD.md @@ -341,6 +341,26 @@ unzip -l target/mobench/android/app/build/outputs/apk/debug/app-debug.apk | grep - Check function name is correct: `fibonacci`, `checksum`, `sample_fns::fibonacci`, `sample_fns::checksum` - Function names are case-sensitive +**Issue**: `aws-lc-sys` fails to compile for Android NDK +``` +error occurred in cc-rs: command did not execute successfully +.../clang ... --target=aarch64-linux-android24 ... getentropy.c +``` + +This happens because `rustls` 0.23+ uses `aws-lc-rs` as the default crypto backend, which doesn't compile for Android NDK targets. + +**Solution**: Configure rustls to use the `ring` crypto backend instead. Add this to your root `Cargo.toml`: +```toml +[workspace.dependencies] +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +``` + +Then in each crate that uses rustls (directly or transitively): +```toml +[dependencies] +rustls = { workspace = true } +``` + ### iOS **Issue**: `xcodegen: command not found` diff --git a/CLAUDE.md b/CLAUDE.md index 705367e..3e7ca09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,12 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.7):** +**Published on crates.io as the mobench ecosystem (v0.1.8):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking -- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with build automation +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation - **[mobench-macros](https://crates.io/crates/mobench-macros)** - `#[benchmark]` attribute proc macro -- **[mobench-runner](https://crates.io/crates/mobench-runner)** - Lightweight timing harness for mobile devices All packages are licensed under MIT (World Foundation, 2026). @@ -44,9 +43,8 @@ fn my_benchmark() { The repository is organized as a Cargo workspace: - **`crates/mobench`**: CLI orchestrator that drives the entire workflow - building artifacts, uploading to BrowserStack, executing runs, and collecting results. Entry point for all operations. -- **`crates/mobench-sdk`**: Core SDK library with registry system, builders (AndroidBuilder, IosBuilder), template generation, and BrowserStack integration. +- **`crates/mobench-sdk`**: Core SDK library with timing harness, registry system, builders (AndroidBuilder, IosBuilder), template generation, and BrowserStack integration. Includes the `timing` module for lightweight benchmarking (can be used standalone with `runner-only` feature). - **`crates/mobench-macros`**: Proc macro crate providing the `#[benchmark]` attribute for marking functions. -- **`crates/mobench-runner`**: Lightweight timing harness library that gets embedded in mobile binaries. Provides timing infrastructure for benchmarks. - **`examples/basic-benchmark`**: Minimal SDK usage example with `#[benchmark]`. - **`examples/ffi-benchmark`**: Full UniFFI surface example (types + `run_benchmark`). @@ -537,16 +535,15 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) - `src/browserstack.rs`: BrowserStack REST API client - **`crates/mobench-sdk/`**: Core SDK library (published to crates.io) - `src/lib.rs`: Public API surface + - `src/timing.rs`: Lightweight timing harness (BenchSpec, BenchReport, run_closure) - `src/registry.rs`: Function discovery via `inventory` crate - - `src/runner.rs`: Timing harness integration + - `src/runner.rs`: Benchmark execution engine using timing module - `src/builders/android.rs`: Android build automation - `src/builders/ios.rs`: iOS build automation - `src/codegen.rs`: Template generation from embedded files - `templates/`: Embedded Android/iOS app templates (via `include_dir!`) - **`crates/mobench-macros/`**: Proc macro crate (published to crates.io) - `src/lib.rs`: `#[benchmark]` attribute implementation -- **`crates/mobench-runner/`**: Timing harness (published to crates.io) - - `src/lib.rs`: Core timing and reporting logic ### Example & Testing diff --git a/Cargo.lock b/Cargo.lock index 5b318ee..fd8547c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -807,7 +807,6 @@ dependencies = [ "clap", "dotenvy", "inventory", - "mobench-runner", "mobench-sdk", "reqwest", "sample-fns", @@ -828,15 +827,6 @@ dependencies = [ "syn", ] -[[package]] -name = "mobench-runner" -version = "0.1.8" -dependencies = [ - "serde", - "serde_json", - "thiserror 1.0.69", -] - [[package]] name = "mobench-sdk" version = "0.1.8" @@ -845,7 +835,6 @@ dependencies = [ "include_dir", "inventory", "mobench-macros", - "mobench-runner", "serde", "serde_json", "thiserror 1.0.69", @@ -1168,7 +1157,7 @@ name = "sample-fns" version = "0.1.8" dependencies = [ "camino", - "mobench-runner", + "mobench-sdk", "serde", "serde_json", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index 3f68b6b..90d32aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] members = [ "crates/mobench", - "crates/mobench-runner", "crates/mobench-macros", "crates/mobench-sdk", "crates/sample-fns", diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..bcae93b --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,656 @@ +# Mobench Developer Experience Improvements + +This document captures improvements needed for mobench based on real-world integration testing with the world-id-protocol project (ZK proof benchmarking on mobile devices). + +## Summary + +| Priority | Task | Description | Status | +|----------|------|-------------|--------| +| P0 | [#1](#task-1-fix-aws-lc-rs-android-ndk-incompatibility) | Fix aws-lc-rs Android NDK incompatibility | DONE | +| P0 | [#2](#task-2-fix-workspace-target-directory-detection) | Fix workspace target directory detection | DONE | +| P0 | [#3](#task-3-auto-generate-project-scaffolding-during-build) | Auto-generate project scaffolding during build | DONE | +| P1 | [#4](#task-4-process-template-variables-during-build) | Process template variables during build | DONE | +| P1 | [#5](#task-5-improve-uniffi-bindgen-handling) | Improve uniffi-bindgen handling | DONE | +| P1 | [#6](#task-6-generate-error-handling-code-dynamically) | Generate error handling code dynamically | DONE | +| P2 | [#7](#task-7-add-configuration-file-support-mobenchtoml) | Add configuration file support (mobench.toml) | DONE | +| P2 | [#8](#task-8-improve-error-messages) | Improve error messages | DONE | +| P2 | [#9](#task-9-add---crate-path-flag) | Add --crate-path flag | DONE | +| P3 | [#10](#task-10-add---dry-run-and---verbose-modes) | Add --dry-run and --verbose modes | DONE | +| P3 | [#11](#task-11-auto-generate-localproperties-for-android) | Auto-generate local.properties for Android | DONE | + +--- + +## P0 - Critical (Blocking Issues) + +### Task 1: Fix aws-lc-rs Android NDK Incompatibility + +**Problem** + +The default `rustls` 0.23+ uses `aws-lc-rs` as the crypto backend, which fails to compile for Android NDK targets. Users see cryptic C compilation errors: + +``` +error occurred in cc-rs: command did not execute successfully +.../clang ... --target=aarch64-linux-android24 ... getentropy.c +``` + +**Root Cause** + +`aws-lc-sys` contains C code that doesn't compile correctly with the Android NDK toolchain. + +**Solution** + +Update the generated `Cargo.toml` templates to configure rustls with the `ring` crypto backend: + +```toml +[workspace.dependencies] +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +``` + +**Files to Modify** + +- `crates/mobench-sdk/src/codegen.rs` - Update Cargo.toml template generation +- `crates/mobench-sdk/templates/` - Update any hardcoded rustls dependencies +- `CLAUDE.md` / `BUILD.md` - Document the issue and workaround + +**Acceptance Criteria** + +- [ ] New projects generated with `init-sdk` compile for Android without rustls errors +- [ ] Documentation explains the aws-lc-rs issue and how to fix existing projects + +--- + +### Task 2: Fix Workspace Target Directory Detection + +**Problem** + +mobench looks for the host library at `/target/debug/lib*.dylib` but Cargo workspaces use a shared `/target/` directory. + +``` +build error: host library for UniFFI not found at +"/path/to/bench-mobile/target/debug/libbench_mobile.dylib" +``` + +**Root Cause** + +Hardcoded path assumption: `self.project_root.join("target")` instead of querying Cargo for the actual target directory. + +**Solution** + +Use `cargo metadata` to detect the actual target directory: + +```rust +fn get_target_dir(crate_dir: &Path) -> Result { + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--no-deps"]) + .current_dir(crate_dir) + .output()?; + let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)?; + Ok(PathBuf::from(metadata["target_directory"].as_str().unwrap())) +} +``` + +**Files to Modify** + +- `crates/mobench-sdk/src/builders/android.rs` + - `generate_uniffi_bindings()` - Use correct target dir for host library + - `copy_native_libraries()` - Use correct target dir for .so files +- `crates/mobench-sdk/src/builders/ios.rs` - Similar changes +- Consider adding `cargo_metadata` crate or manual JSON parsing + +**Acceptance Criteria** + +- [ ] `mobench build` works in Cargo workspace projects +- [ ] `mobench build` still works in standalone crate projects + +--- + +### Task 3: Auto-generate Project Scaffolding During Build + +**Problem** + +`mobench build` assumes Android/iOS project files already exist. When missing, it fails with confusing errors: + +``` +build error: Failed to run Gradle: No such file or directory (os error 2) +``` + +Users don't know they need to run `init-sdk` first. + +**Solution** + +In `cmd_build()`, check if project exists and auto-generate if missing: + +```rust +fn cmd_build(target: SdkTarget, ...) -> Result<()> { + let output_dir = output_dir.unwrap_or_else(|| PathBuf::from("target/mobench")); + + if matches!(target, SdkTarget::Android | SdkTarget::Both) { + let android_dir = output_dir.join("android"); + if !android_dir.join("build.gradle").exists() { + println!("Android project not found, generating scaffolding..."); + generate_android_project(&android_dir, &crate_name, &template_context)?; + } + } + + if matches!(target, SdkTarget::Ios | SdkTarget::Both) { + let ios_dir = output_dir.join("ios").join("BenchRunner"); + if !ios_dir.join("project.yml").exists() { + println!("iOS project not found, generating scaffolding..."); + generate_ios_project(&ios_dir, &crate_name, &template_context)?; + } + } + + // Continue with normal build... +} +``` + +**Files to Modify** + +- `crates/mobench/src/lib.rs` - Update `cmd_build()` function +- `crates/mobench-sdk/src/codegen.rs` - Extract `generate_android_project()` and `generate_ios_project()` to be callable separately from full `generate_project()` + +**Acceptance Criteria** + +- [ ] Running `mobench build --target android` in a fresh project generates Android scaffolding automatically +- [ ] Running `mobench build --target ios` generates iOS scaffolding automatically +- [ ] Existing projects are not overwritten + +--- + +## P1 - High Priority + +### Task 4: Process Template Variables During Build + +**Problem** + +Template files contain `{{VARIABLE}}` placeholders that are only processed during `init-sdk`. Users who run `build` on a project without `init-sdk` get broken files with literal `{{VAR}}` strings. + +**Variables Used** + +| Variable | Example Value | Description | +|----------|---------------|-------------| +| `{{PACKAGE_NAME}}` | `dev.world.bench` | Android package / iOS bundle prefix | +| `{{LIBRARY_NAME}}` | `bench_mobile` | Rust library name | +| `{{UNIFFI_NAMESPACE}}` | `bench_mobile` | UniFFI namespace (usually same as library) | +| `{{PROJECT_NAME_PASCAL}}` | `BenchRunner` | PascalCase project name | +| `{{DEFAULT_FUNCTION}}` | `my_crate::my_func` | Default benchmark function | +| `{{APP_NAME}}` | `My Bench App` | Display name | +| `{{BUNDLE_ID}}` | `dev.world.bench` | iOS bundle identifier | +| `{{BUNDLE_ID_PREFIX}}` | `dev.world` | iOS bundle prefix | + +**Solution** + +1. Create a `TemplateContext` struct with all variables +2. Extract template processing into a shared function +3. Call it from both `init-sdk` and `build` +4. Derive values from crate metadata when not explicitly configured + +```rust +pub struct TemplateContext { + pub package_name: String, + pub library_name: String, + pub uniffi_namespace: String, + pub project_name_pascal: String, + pub default_function: String, + pub app_name: String, + pub bundle_id: String, + pub bundle_id_prefix: String, +} + +impl TemplateContext { + pub fn from_crate(crate_dir: &Path) -> Result { + // Read Cargo.toml, extract [lib] name, derive other values + } +} + +pub fn process_templates(dir: &Path, ctx: &TemplateContext) -> Result<()> { + for entry in WalkDir::new(dir) { + let path = entry?.path(); + if path.is_file() { + let content = fs::read_to_string(path)?; + let processed = content + .replace("{{PACKAGE_NAME}}", &ctx.package_name) + .replace("{{LIBRARY_NAME}}", &ctx.library_name) + // ... etc + fs::write(path, processed)?; + } + } + Ok(()) +} +``` + +**Files to Modify** + +- `crates/mobench-sdk/src/codegen.rs` - Add `TemplateContext` and `process_templates()` +- `crates/mobench-sdk/src/builders/android.rs` - Call `process_templates()` after generating scaffolding +- `crates/mobench-sdk/src/builders/ios.rs` - Same + +**Acceptance Criteria** + +- [ ] All `{{VAR}}` placeholders are replaced during `build` +- [ ] Values are derived from crate metadata when not configured +- [ ] Custom values can be provided via config file (see Task 7) + +--- + +### Task 5: Improve uniffi-bindgen Handling + +**Problem** + +Users must manually add a `[[bin]]` target for uniffi-bindgen and install it globally. This is undocumented and confusing. + +**Current Workaround (manual)** + +```toml +# In bench-mobile/Cargo.toml +[[bin]] +name = "uniffi-bindgen" +path = "src/bin/uniffi-bindgen.rs" + +[dependencies] +uniffi = { version = "0.28", features = ["cli"] } +``` + +```rust +// src/bin/uniffi-bindgen.rs +fn main() { uniffi::uniffi_bindgen_main() } +``` + +**Solution** + +1. Generate the uniffi-bindgen binary target during project scaffolding +2. Use `cargo run -p --bin uniffi-bindgen` instead of global binary + +```rust +fn run_uniffi_bindgen(crate_dir: &Path, crate_name: &str, args: &[&str]) -> Result<()> { + // Try running via cargo first (works if crate has the binary) + let cargo_result = Command::new("cargo") + .args(["run", "-p", crate_name, "--bin", "uniffi-bindgen", "--"]) + .args(args) + .current_dir(crate_dir) + .status(); + + if cargo_result.is_ok() && cargo_result.unwrap().success() { + return Ok(()); + } + + // Fall back to global uniffi-bindgen + let global_result = Command::new("uniffi-bindgen") + .args(args) + .current_dir(crate_dir) + .status()?; + + if !global_result.success() { + return Err(BenchError::Build( + "uniffi-bindgen failed. Ensure your crate has a uniffi-bindgen binary \ + or install globally with: cargo install uniffi_bindgen".into() + )); + } + + Ok(()) +} +``` + +**Files to Modify** + +- `crates/mobench-sdk/src/codegen.rs` - Generate uniffi-bindgen binary in Cargo.toml template +- `crates/mobench-sdk/src/builders/android.rs` - Use new `run_uniffi_bindgen()` function +- `crates/mobench-sdk/src/builders/ios.rs` - Same + +**Acceptance Criteria** + +- [ ] New projects have uniffi-bindgen binary generated automatically +- [ ] Build works without global uniffi-bindgen installation +- [ ] Clear error message if uniffi-bindgen can't be found + +--- + +### Task 6: Generate Error Handling Code Dynamically + +**Problem** + +Mobile app templates hardcode error variants like `BenchException.InvalidIterations` that may not exist in the user's UniFFI schema, causing compilation failures. + +**Current Template (broken)** + +```kotlin +// MainActivity.kt +} catch (e: BenchException.InvalidIterations) { + "Error: ${e.message}" +} catch (e: BenchException.UnknownFunction) { + "Error: ${e.message}" +``` + +```swift +// BenchRunnerFFI.swift +case .InvalidIterations(let message): + return "Error: \(message)" +case .UnknownFunction(let message): + return "Error: \(message)" +``` + +**Solution** + +Use generic catch pattern that works with any error type: + +```kotlin +// MainActivity.kt +} catch (e: BenchException) { + "Error: ${e.message}" +} catch (e: Exception) { + "Unexpected error: ${e.message}" +} +``` + +```swift +// BenchRunnerFFI.swift +private static func formatBenchError(_ error: BenchError) -> String { + return "Error: \(error.localizedDescription)" +} +``` + +**Files to Modify** + +- `crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template` +- `crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template` + +**Acceptance Criteria** + +- [ ] Generated Android app compiles with any BenchError variant set +- [ ] Generated iOS app compiles with any BenchError variant set +- [ ] Error messages are still descriptive + +--- + +## P2 - Medium Priority + +### Task 7: Add Configuration File Support (mobench.toml) + +**Problem** + +Users must pass many CLI flags repeatedly. No way to persist project configuration. + +**Solution** + +Support `mobench.toml` at project root: + +```toml +[project] +crate = "bench-mobile" +library_name = "bench_mobile" + +[android] +package = "com.example.bench" +min_sdk = 24 +target_sdk = 34 + +[ios] +bundle_id = "com.example.bench" +deployment_target = "15.0" + +[benchmarks] +default_function = "my_crate::my_benchmark" +default_iterations = 100 +default_warmup = 10 +``` + +**Files to Modify** + +- `crates/mobench/src/lib.rs` - Add config file loading at startup +- `crates/mobench-sdk/src/types.rs` - Add `MobenchConfig` struct +- Create `crates/mobench/src/config.rs` module + +**Acceptance Criteria** + +- [ ] Config file is loaded automatically if present +- [ ] CLI flags override config file values +- [ ] `mobench init` can generate a starter config file + +--- + +### Task 8: Improve Error Messages + +**Problem** + +Error messages don't explain what's expected or how to fix issues. + +**Current** + +``` +build error: Benchmark crate 'bench-mobile' not found. Tried: + - "/path/bench-mobile/bench-mobile" + - "/path/bench-mobile/crates/bench-mobile" +``` + +**Improved** + +``` +build error: Benchmark crate 'bench-mobile' not found. + +Searched locations: + ✗ /path/project/bench-mobile/Cargo.toml + ✗ /path/project/crates/bench-mobile/Cargo.toml + +To fix this: + 1. Create a bench-mobile/ directory with your benchmark crate, or + 2. Use --crate-path to specify the benchmark crate location: + mobench build --target android --crate-path ./my-benchmarks + +Run 'mobench init-sdk --help' to generate a new benchmark project. +``` + +**Files to Modify** + +- `crates/mobench-sdk/src/builders/android.rs` - All error returns +- `crates/mobench-sdk/src/builders/ios.rs` - All error returns +- `crates/mobench-sdk/src/types.rs` - Enhance `BenchError` variants with more context + +**Acceptance Criteria** + +- [x] All error messages include actionable fix suggestions +- [x] Searched paths are listed when file not found +- [x] Links to documentation where appropriate + +--- + +### Task 9: Add --crate-path Flag + +**Problem** + +mobench hardcodes looking for `bench-mobile/` or `crates/sample-fns/`. Real projects have different structures like `crates/benchmarks/`, `benches/mobile/`, etc. + +**Solution** + +Add `--crate-path` flag to `build` command: + +```rust +#[derive(Parser)] +struct Build { + #[arg(long)] + target: SdkTarget, + + /// Path to the benchmark crate (default: auto-detect bench-mobile/ or crates/sample-fns/) + #[arg(long)] + crate_path: Option, + + #[arg(long)] + release: bool, + + #[arg(long)] + output_dir: Option, +} +``` + +**Files to Modify** + +- `crates/mobench/src/lib.rs` - Add CLI argument, pass to builders +- `crates/mobench-sdk/src/builders/android.rs` - Accept optional `crate_path` in constructor +- `crates/mobench-sdk/src/builders/ios.rs` - Same + +**Acceptance Criteria** + +- [ ] `mobench build --target android --crate-path ./my-bench` works +- [ ] Auto-detection still works when `--crate-path` not provided +- [ ] Error message suggests `--crate-path` when auto-detection fails + +--- + +## P3 - Nice to Have + +### Task 10: Add --dry-run and --verbose Modes + +**Problem** + +Hard to debug what mobench is doing. Users can't preview changes before they happen. + +**Solution** + +Add global flags: + +```rust +#[derive(Parser)] +#[command(name = "mobench")] +struct Cli { + /// Print what would be done without actually doing it + #[arg(long, global = true)] + dry_run: bool, + + /// Print verbose output including all commands + #[arg(long, short = 'v', global = true)] + verbose: bool, + + #[command(subcommand)] + command: Command, +} +``` + +**Behavior** + +- `--dry-run`: Print commands that would be executed, files that would be created/modified +- `--verbose`: Print all commands as they run, show full output + +**Files to Modify** + +- `crates/mobench/src/lib.rs` - Add global flags, thread through to all functions +- `crates/mobench-sdk/src/builders/*.rs` - Respect dry_run/verbose flags + +**Acceptance Criteria** + +- [ ] `mobench build --target android --dry-run` shows what would happen without making changes +- [ ] `mobench build --target android --verbose` shows all commands being run + +--- + +### Task 11: Auto-generate local.properties for Android + +**Problem** + +Gradle fails without `sdk.dir` being set. Users must manually create `local.properties`: + +``` +SDK location not found. Define a valid SDK location with an ANDROID_HOME +environment variable or by setting the sdk.dir path in your project's +local properties file. +``` + +**Solution** + +Auto-detect Android SDK and generate `local.properties`: + +```rust +fn ensure_local_properties(android_dir: &Path) -> Result<()> { + let local_props = android_dir.join("local.properties"); + if local_props.exists() { + return Ok(()); + } + + let sdk_dir = detect_android_sdk()?; + fs::write(&local_props, format!("sdk.dir={}\n", sdk_dir.display()))?; + println!(" Generated local.properties with SDK at {}", sdk_dir.display()); + Ok(()) +} + +fn detect_android_sdk() -> Result { + // Check environment variables + if let Ok(sdk) = std::env::var("ANDROID_HOME") { + return Ok(PathBuf::from(sdk)); + } + if let Ok(sdk) = std::env::var("ANDROID_SDK_ROOT") { + return Ok(PathBuf::from(sdk)); + } + + // Check common locations + let home = dirs::home_dir().ok_or_else(|| BenchError::Build("Cannot find home directory".into()))?; + + let candidates = [ + home.join("Library/Android/sdk"), // macOS default + home.join("Android/Sdk"), // Linux default + PathBuf::from("/usr/local/android-sdk"), // Common Linux location + ]; + + for candidate in &candidates { + if candidate.exists() { + return Ok(candidate.clone()); + } + } + + Err(BenchError::Build( + "Android SDK not found. Set ANDROID_HOME environment variable or install Android Studio.".into() + )) +} +``` + +**Files to Modify** + +- `crates/mobench-sdk/src/builders/android.rs` - Add `ensure_local_properties()`, call before Gradle + +**Acceptance Criteria** + +- [ ] `local.properties` is auto-generated if missing +- [ ] Existing `local.properties` is not overwritten +- [ ] Clear error if SDK cannot be found + +--- + +## Implementation Order + +Recommended order based on dependencies and impact: + +1. **Task 2** - Target directory detection (unblocks workspace projects) +2. **Task 3** - Auto-generate scaffolding (simplifies getting started) +3. **Task 4** - Template processing (makes generated projects work) +4. **Task 1** - aws-lc-rs fix (unblocks Android builds) +5. **Task 6** - Error handling (makes generated code compile) +6. **Task 11** - local.properties (removes manual step) +7. **Task 5** - uniffi-bindgen (removes manual step) +8. **Task 9** - --crate-path (flexibility for real projects) +9. **Task 8** - Error messages (better debugging) +10. **Task 7** - Config file (power users) +11. **Task 10** - dry-run/verbose (debugging) + +## Testing Strategy + +After implementing, verify with: + +1. **Fresh project test:** + ```bash + cargo new my-bench && cd my-bench + cargo add mobench-sdk + # Add a #[benchmark] function + mobench build --target android + # Should produce working APK + ``` + +2. **Workspace project test:** + ```bash + git clone https://github.com/worldcoin/world-id-protocol + cd world-id-protocol + mobench build --target android --crate-path ./bench-mobile + # Should produce working APK + ``` + +3. **Both platforms test:** + ```bash + mobench build --target both + # Should produce both APK and xcframework/IPA + ``` diff --git a/README.md b/README.md index f354fa4..bb6a772 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,8 @@ mobench provides a Rust API and a CLI for running benchmarks on real mobile devi ## Workspace crates - `crates/mobench` ([mobench](https://crates.io/crates/mobench)): CLI tool that builds, runs, and fetches benchmarks -- `crates/mobench-sdk` ([mobench-sdk](https://crates.io/crates/mobench-sdk)): core SDK (builders, registry, codegen) +- `crates/mobench-sdk` ([mobench-sdk](https://crates.io/crates/mobench-sdk)): core SDK (timing harness, builders, registry, codegen) - `crates/mobench-macros` ([mobench-macros](https://crates.io/crates/mobench-macros)): `#[benchmark]` proc macro -- `crates/mobench-runner` ([mobench-runner](https://crates.io/crates/mobench-runner)): lightweight timing harness - `crates/sample-fns`: sample benchmarks and UniFFI bindings - `examples/basic-benchmark`: minimal SDK integration example - `examples/ffi-benchmark`: full UniFFI/FFI surface example diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index 1927f1c..cb5ed9e 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -177,9 +177,8 @@ fn hash_and_encode() { This crate is part of the mobench ecosystem for mobile benchmarking: - **[mobench](https://crates.io/crates/mobench)** - CLI tool for running benchmarks -- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - SDK for integrating benchmarks +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - SDK with timing harness for integrating benchmarks - **[mobench-macros](https://crates.io/crates/mobench-macros)** - This crate (proc macros) -- **[mobench-runner](https://crates.io/crates/mobench-runner)** - Timing harness ## See Also diff --git a/crates/mobench-macros/src/lib.rs b/crates/mobench-macros/src/lib.rs index 926bef7..418f911 100644 --- a/crates/mobench-macros/src/lib.rs +++ b/crates/mobench-macros/src/lib.rs @@ -68,10 +68,9 @@ //! //! This crate is part of the mobench ecosystem: //! -//! - **[`mobench-sdk`](https://crates.io/crates/mobench-sdk)** - Core SDK (re-exports this macro) +//! - **[`mobench-sdk`](https://crates.io/crates/mobench-sdk)** - Core SDK with timing harness (re-exports this macro) //! - **[`mobench`](https://crates.io/crates/mobench)** - CLI tool //! - **`mobench-macros`** (this crate) - Proc macros -//! - **[`mobench-runner`](https://crates.io/crates/mobench-runner)** - Timing harness use proc_macro::TokenStream; use quote::quote; diff --git a/crates/mobench-runner/Cargo.toml b/crates/mobench-runner/Cargo.toml deleted file mode 100644 index dbccfb2..0000000 --- a/crates/mobench-runner/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "mobench-runner" -version.workspace = true -edition.workspace = true -license.workspace = true -authors = ["Dominik Clemente - dcbuilder.eth "] -description = "Lightweight benchmarking harness for mobile devices" -repository = "https://github.com/worldcoin/mobile-bench-rs" -documentation = "https://docs.rs/mobench-runner" -readme = "README.md" -keywords = ["benchmark", "mobile", "timing", "profiling"] - -[badges] -maintenance = { status = "actively-developed" } - -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] - -[dependencies] -serde.workspace = true -thiserror.workspace = true - -[dev-dependencies] -serde_json.workspace = true diff --git a/crates/mobench-runner/README.md b/crates/mobench-runner/README.md deleted file mode 100644 index 64ee97a..0000000 --- a/crates/mobench-runner/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# mobench-runner - -Lightweight benchmarking harness for mobile devices. - -This crate provides the core timing infrastructure for running benchmarks on mobile platforms (Android and iOS). It's designed to be embedded in mobile apps and provides accurate timing measurements with configurable iterations and warmup cycles. - -## Features - -- **Accurate timing**: High-precision timing measurements for benchmarks -- **Configurable**: Set iterations and warmup cycles -- **Mobile-optimized**: Designed for resource-constrained mobile environments -- **Serializable results**: Results can be serialized with serde for transmission to host -- **No dependencies**: Minimal dependencies for fast compilation - -## Usage - -Add this to your `Cargo.toml`: - -```toml -[dependencies] -mobench-runner = "0.1" -``` - -### Basic Example - -```rust -use mobench_runner::{BenchSpec, run_closure}; - -// Create a benchmark specification -let spec = BenchSpec { - name: "my_benchmark".to_string(), - iterations: 100, - warmup: 10, -}; - -// Run a closure as a benchmark -let report = run_closure(spec, || { - // Your benchmark code here - let result = expensive_computation(); - std::hint::black_box(result); // Prevent optimization - Ok(()) -})?; - -// Access timing results -println!("Mean: {} ns", report.mean_ns()); -println!("Median: {} ns", report.median_ns()); -println!("Min: {} ns", report.min_ns()); -println!("Max: {} ns", report.max_ns()); -``` - -### With Error Handling - -```rust -use mobench_runner::{BenchSpec, BenchError, run_closure}; - -fn my_benchmark() -> Result<(), String> { - // Your code that might fail - Ok(()) -} - -let spec = BenchSpec::new("my_benchmark", 50, 5)?; - -let report = run_closure(spec, || { - my_benchmark().map_err(|e| BenchError::Execution(e)) -})?; -``` - -## Types - -### `BenchSpec` - -Specification for a benchmark run: - -```rust -pub struct BenchSpec { - pub name: String, // Benchmark name - pub iterations: u32, // Number of iterations to run - pub warmup: u32, // Number of warmup iterations -} -``` - -### `BenchReport` - -Results from a benchmark run: - -```rust -pub struct BenchReport { - pub spec: BenchSpec, - pub samples: Vec, -} -``` - -Provides helper methods: -- `mean_ns()` - Mean execution time -- `median_ns()` - Median execution time -- `min_ns()` - Minimum execution time -- `max_ns()` - Maximum execution time -- `stddev_ns()` - Standard deviation - -### `BenchSample` - -Individual timing sample: - -```rust -pub struct BenchSample { - pub duration_ns: u64, // Duration in nanoseconds -} -``` - -## Use Cases - -This crate is typically used as a dependency in larger benchmarking systems: - -1. **Mobile benchmark harness**: Embed in mobile apps to run benchmarks on real devices -2. **Cross-platform timing**: Consistent timing API across platforms -3. **Remote benchmarking**: Serialize results and send to analysis tools - -## Part of mobench - -This crate is part of the [mobench](https://crates.io/crates/mobench) ecosystem for mobile benchmarking: - -- **[mobench](https://crates.io/crates/mobench)** - CLI tool for running benchmarks -- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - SDK for integrating benchmarks -- **[mobench-macros](https://crates.io/crates/mobench-macros)** - Proc macros for `#[benchmark]` -- **[mobench-runner](https://crates.io/crates/mobench-runner)** - This crate (timing harness) - -## License - -Licensed under the MIT License. See [LICENSE.md](../../LICENSE.md) for details. - -Copyright (c) 2026 World Foundation diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 65830d4..f19ea58 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -27,27 +27,33 @@ include = [ [lib] crate-type = ["lib"] +[features] +default = ["full"] +# Full SDK with build automation, templates, and registry +full = ["dep:mobench-macros", "dep:inventory", "dep:include_dir", "dep:toml", "dep:anyhow"] +# Minimal timing-only mode for mobile binaries (small footprint) +runner-only = [] + [dependencies] -# Core dependencies -mobench-runner = { version = "0.1.8", path = "../mobench-runner" } -mobench-macros = { version = "0.1.8", path = "../mobench-macros" } +# Proc macros (only with full feature) +mobench-macros = { version = "0.1.8", path = "../mobench-macros", optional = true } -# Registry -inventory.workspace = true +# Registry (only with full feature) +inventory = { workspace = true, optional = true } -# Serialization +# Serialization (always needed for BenchSpec/BenchReport) serde.workspace = true serde_json.workspace = true # Error handling thiserror.workspace = true -anyhow.workspace = true +anyhow = { workspace = true, optional = true } -# Template embedding (Phase 1) -include_dir.workspace = true +# Template embedding (only with full feature) +include_dir = { workspace = true, optional = true } -# Build automation (Phase 1) -toml.workspace = true +# Build automation (only with full feature) +toml = { workspace = true, optional = true } [dev-dependencies] # Test dependencies will be added as needed diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index 248f5fa..0c4b1aa 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -402,9 +402,8 @@ devices: This is the core SDK of the mobench ecosystem: - **[mobench](https://crates.io/crates/mobench)** - CLI tool (recommended for most users) -- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - This crate (SDK library) +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - This crate (SDK library with timing harness) - **[mobench-macros](https://crates.io/crates/mobench-macros)** - Proc macros -- **[mobench-runner](https://crates.io/crates/mobench-runner)** - Timing harness ## See Also diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index 2469861..e4b860f 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -4,7 +4,9 @@ //! package them into an APK using Gradle. use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; +use super::common::{get_cargo_target_dir, host_lib_path, run_command}; use std::env; +use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -18,6 +20,10 @@ pub struct AndroidBuilder { crate_name: String, /// Whether to use verbose output verbose: bool, + /// Optional explicit crate directory (overrides auto-detection) + crate_dir: Option, + /// Whether to run in dry-run mode (print what would be done without making changes) + dry_run: bool, } impl AndroidBuilder { @@ -34,6 +40,8 @@ impl AndroidBuilder { project_root: root, crate_name: crate_name.into(), verbose: false, + crate_dir: None, + dry_run: false, } } @@ -46,15 +54,37 @@ impl AndroidBuilder { self } + /// Sets the explicit crate directory + /// + /// By default, the builder searches for the crate in: + /// - `{project_root}/bench-mobile/` + /// - `{project_root}/crates/{crate_name}/` + /// + /// Use this to override auto-detection and point directly to the crate. + pub fn crate_dir(mut self, dir: impl Into) -> Self { + self.crate_dir = Some(dir.into()); + self + } + /// Enables verbose output pub fn verbose(mut self, verbose: bool) -> Self { self.verbose = verbose; self } + /// Enables dry-run mode + /// + /// In dry-run mode, the builder prints what would be done without actually + /// making any changes. Useful for previewing the build process. + pub fn dry_run(mut self, dry_run: bool) -> Self { + self.dry_run = dry_run; + self + } + /// Builds the Android app with the given configuration /// /// This performs the following steps: + /// 0. Auto-generate project scaffolding if missing /// 1. Build Rust libraries for Android ABIs using cargo-ndk /// 2. Generate UniFFI Kotlin bindings /// 3. Copy .so files to jniLibs directories @@ -65,6 +95,39 @@ impl AndroidBuilder { /// * `Ok(BuildResult)` containing the path to the built APK /// * `Err(BenchError)` if the build fails pub fn build(&self, config: &BuildConfig) -> Result { + let android_dir = self.output_dir.join("android"); + let profile_name = match config.profile { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + }; + + if self.dry_run { + println!("\n[dry-run] Android build plan:"); + println!(" Step 0: Check/generate Android project scaffolding at {:?}", android_dir); + println!(" Step 1: Build Rust libraries for Android ABIs (arm64-v8a, armeabi-v7a, x86_64)"); + println!(" Command: cargo ndk --target --platform 24 build {}", + if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); + println!(" Step 2: Generate UniFFI Kotlin bindings"); + println!(" Output: {:?}", android_dir.join("app/src/main/java/uniffi")); + println!(" Step 3: Copy .so files to jniLibs directories"); + println!(" Destination: {:?}", android_dir.join("app/src/main/jniLibs")); + println!(" Step 4: Build Android APK with Gradle"); + println!(" Command: ./gradlew assemble{}", if profile_name == "release" { "Release" } else { "Debug" }); + println!(" Output: {:?}", android_dir.join(format!("app/build/outputs/apk/{}/app-{}.apk", profile_name, profile_name))); + println!(" Step 5: Build Android test APK"); + println!(" Command: ./gradlew assemble{}AndroidTest", if profile_name == "release" { "Release" } else { "Debug" }); + + // Return a placeholder result for dry-run + return Ok(BuildResult { + platform: Target::Android, + app_path: android_dir.join(format!("app/build/outputs/apk/{}/app-{}.apk", profile_name, profile_name)), + test_suite_path: Some(android_dir.join(format!("app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk", profile_name, profile_name))), + }); + } + + // Step 0: Ensure Android project scaffolding exists + crate::codegen::ensure_android_project(&self.output_dir, &self.crate_name)?; + // Step 1: Build Rust libraries println!("Building Rust libraries for Android..."); self.build_rust_libraries(config)?; @@ -94,6 +157,18 @@ impl AndroidBuilder { /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) fn find_crate_dir(&self) -> Result { + // If explicit crate_dir was provided, use it + if let Some(ref dir) = self.crate_dir { + if dir.exists() { + return Ok(dir.clone()); + } + return Err(BenchError::Build(format!( + "Specified crate path does not exist: {:?}.\n\n\ + Tip: pass --crate-path pointing at a directory containing Cargo.toml.", + dir + ))); + } + // Try bench-mobile/ first (SDK projects) let bench_mobile_dir = self.project_root.join("bench-mobile"); if bench_mobile_dir.exists() { @@ -106,9 +181,25 @@ impl AndroidBuilder { return Ok(crates_dir); } + let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml"); + let crates_manifest = crates_dir.join("Cargo.toml"); Err(BenchError::Build(format!( - "Benchmark crate '{}' not found. Tried:\n - {:?}\n - {:?}", - self.crate_name, bench_mobile_dir, crates_dir + "Benchmark crate '{}' not found.\n\n\ + Searched locations:\n\ + - {}\n\ + - {}\n\n\ + To fix this:\n\ + 1. Create a bench-mobile/ directory with your benchmark crate, or\n\ + 2. Use --crate-path to specify the benchmark crate location:\n\ + cargo mobench build --target android --crate-path ./my-benchmarks\n\n\ + Common issues:\n\ + - Typo in crate name (check Cargo.toml [package] name)\n\ + - Wrong working directory (run from project root)\n\ + - Missing Cargo.toml in the crate directory\n\n\ + Run 'cargo mobench init-sdk --help' to generate a new benchmark project.", + self.crate_name, + bench_mobile_manifest.display(), + crates_manifest.display() ))) } @@ -121,6 +212,11 @@ impl AndroidBuilder { // Android ABIs to build for let abis = vec!["arm64-v8a", "armeabi-v7a", "x86_64"]; + let release_flag = if matches!(config.profile, BuildProfile::Release) { + "--release" + } else { + "" + }; for abi in abis { if self.verbose { @@ -136,23 +232,69 @@ impl AndroidBuilder { .arg("build"); // Add release flag if needed - if matches!(config.profile, BuildProfile::Release) { - cmd.arg("--release"); + if !release_flag.is_empty() { + cmd.arg(release_flag); } // Set working directory cmd.current_dir(&crate_dir); // Execute build + let command_hint = if release_flag.is_empty() { + format!("cargo ndk --target {} --platform 24 build", abi) + } else { + format!("cargo ndk --target {} --platform 24 build {}", abi, release_flag) + }; let output = cmd .output() - .map_err(|e| BenchError::Build(format!("Failed to run cargo-ndk: {}", e)))?; + .map_err(|e| BenchError::Build(format!( + "Failed to start cargo-ndk for {}.\n\n\ + Command: {}\n\ + Crate directory: {}\n\ + System error: {}\n\n\ + Tips:\n\ + - Install cargo-ndk: cargo install cargo-ndk\n\ + - Ensure cargo is on PATH", + abi, + command_hint, + crate_dir.display(), + e + )))?; if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); + let profile = if matches!(config.profile, BuildProfile::Release) { + "release" + } else { + "debug" + }; + let rust_target = match abi { + "arm64-v8a" => "aarch64-linux-android", + "armeabi-v7a" => "armv7-linux-androideabi", + "x86_64" => "x86_64-linux-android", + _ => abi, + }; return Err(BenchError::Build(format!( - "cargo-ndk build failed for {}: {}", - abi, stderr + "cargo-ndk build failed for {} ({} profile).\n\n\ + Command: {}\n\ + Crate directory: {}\n\ + Exit status: {}\n\n\ + Stdout:\n{}\n\n\ + Stderr:\n{}\n\n\ + Common causes:\n\ + - Missing Rust target: rustup target add {}\n\ + - NDK not found: set ANDROID_NDK_HOME\n\ + - Compilation error in Rust code (see output above)\n\ + - Incompatible native dependencies (some C libraries do not support Android)", + abi, + profile, + command_hint, + crate_dir.display(), + output.status, + stdout, + stderr, + rust_target, ))); } } @@ -167,7 +309,15 @@ impl AndroidBuilder { match output { Ok(output) if output.status.success() => Ok(()), _ => Err(BenchError::Build( - "cargo-ndk is not installed. Install it with: cargo install cargo-ndk".to_string(), + "cargo-ndk is not installed or not in PATH.\n\n\ + cargo-ndk is required to cross-compile Rust for Android.\n\n\ + To install:\n\ + cargo install cargo-ndk\n\ + Verify with:\n\ + cargo ndk --version\n\n\ + You also need the Android NDK. Set ANDROID_NDK_HOME or install via Android Studio.\n\ + See: https://github.com/nickelc/cargo-ndk" + .to_string(), )), } } @@ -196,22 +346,6 @@ impl AndroidBuilder { return Ok(()); } - // Check if uniffi-bindgen is available - let uniffi_available = Command::new("uniffi-bindgen") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - - if !uniffi_available { - return Err(BenchError::Build( - "uniffi-bindgen not found and no pre-generated bindings exist.\n\ - Install it with: cargo install uniffi-bindgen\n\ - Or use pre-generated bindings by copying them to the expected location." - .to_string(), - )); - } - // Build host library to feed uniffi-bindgen let mut build_cmd = Command::new("cargo"); build_cmd.arg("build"); @@ -227,15 +361,61 @@ impl AndroidBuilder { .join("main") .join("java"); - let mut cmd = Command::new("uniffi-bindgen"); - cmd.arg("generate") + // Try cargo run first (works if crate has uniffi-bindgen binary target) + let cargo_run_result = Command::new("cargo") + .args(["run", "-p", &self.crate_name, "--bin", "uniffi-bindgen", "--"]) + .arg("generate") .arg("--library") .arg(&lib_path) .arg("--language") .arg("kotlin") .arg("--out-dir") - .arg(&out_dir); - run_command(cmd, "uniffi-bindgen kotlin")?; + .arg(&out_dir) + .current_dir(&crate_dir) + .output(); + + let use_cargo_run = cargo_run_result + .as_ref() + .map(|o| o.status.success()) + .unwrap_or(false); + + if use_cargo_run { + if self.verbose { + println!(" Generated bindings using cargo run uniffi-bindgen"); + } + } else { + // Fall back to global uniffi-bindgen + let uniffi_available = Command::new("uniffi-bindgen") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !uniffi_available { + return Err(BenchError::Build( + "uniffi-bindgen not found and no pre-generated bindings exist.\n\n\ + To fix this, either:\n\ + 1. Add a uniffi-bindgen binary to your crate:\n\ + [[bin]]\n\ + name = \"uniffi-bindgen\"\n\ + path = \"src/bin/uniffi-bindgen.rs\"\n\n\ + 2. Or install uniffi-bindgen globally:\n\ + cargo install uniffi-bindgen\n\n\ + 3. Or pre-generate bindings and commit them." + .to_string(), + )); + } + + let mut cmd = Command::new("uniffi-bindgen"); + cmd.arg("generate") + .arg("--library") + .arg(&lib_path) + .arg("--language") + .arg("kotlin") + .arg("--out-dir") + .arg(&out_dir); + run_command(cmd, "uniffi-bindgen kotlin")?; + } if self.verbose { println!(" Generated UniFFI Kotlin bindings at {:?}", out_dir); @@ -245,17 +425,24 @@ impl AndroidBuilder { /// Copies .so files to Android jniLibs directories fn copy_native_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> { + let crate_dir = self.find_crate_dir()?; let profile_dir = match config.profile { BuildProfile::Debug => "debug", BuildProfile::Release => "release", }; - let target_dir = self.project_root.join("target"); + // Use cargo metadata to find the actual target directory (handles workspaces) + let target_dir = get_cargo_target_dir(&crate_dir)?; let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs"); // Create jniLibs directories if they don't exist - std::fs::create_dir_all(&jni_libs_dir) - .map_err(|e| BenchError::Build(format!("Failed to create jniLibs directory: {}", e)))?; + std::fs::create_dir_all(&jni_libs_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to create jniLibs directory at {}: {}. Check output directory permissions.", + jni_libs_dir.display(), + e + )) + })?; // Map cargo-ndk ABIs to Android jniLibs ABIs let abi_mappings = vec![ @@ -272,14 +459,25 @@ impl AndroidBuilder { let dest_dir = jni_libs_dir.join(android_abi); std::fs::create_dir_all(&dest_dir).map_err(|e| { - BenchError::Build(format!("Failed to create {} directory: {}", android_abi, e)) + BenchError::Build(format!( + "Failed to create ABI directory {} at {}: {}. Check output directory permissions.", + android_abi, + dest_dir.display(), + e + )) })?; let dest = dest_dir.join(format!("lib{}.so", self.crate_name.replace("-", "_"))); if src.exists() { std::fs::copy(&src, &dest).map_err(|e| { - BenchError::Build(format!("Failed to copy {} library: {}", android_abi, e)) + BenchError::Build(format!( + "Failed to copy {} library from {} to {}: {}. Ensure cargo-ndk completed successfully.", + android_abi, + src.display(), + dest.display(), + e + )) })?; if self.verbose { @@ -293,17 +491,112 @@ impl AndroidBuilder { Ok(()) } + /// Ensures local.properties exists with sdk.dir set + /// + /// Gradle requires this file to know where the Android SDK is located. + /// This function auto-generates the file if missing by detecting the SDK path + /// from environment variables or common installation locations. + fn ensure_local_properties(&self, android_dir: &Path) -> Result<(), BenchError> { + let local_props = android_dir.join("local.properties"); + + // If local.properties already exists, leave it alone + if local_props.exists() { + return Ok(()); + } + + // Try to find Android SDK path + let sdk_dir = self.find_android_sdk()?; + + // Write local.properties + let content = format!("sdk.dir={}\n", sdk_dir.display()); + fs::write(&local_props, content).map_err(|e| { + BenchError::Build(format!( + "Failed to write local.properties at {:?}: {}. Check output directory permissions.", + local_props, e + )) + })?; + + if self.verbose { + println!(" Generated local.properties with sdk.dir={}", sdk_dir.display()); + } + + Ok(()) + } + + /// Finds the Android SDK installation path + fn find_android_sdk(&self) -> Result { + let mut searched = Vec::new(); + + // Check ANDROID_HOME first (standard) + if let Ok(path) = env::var("ANDROID_HOME") { + let sdk_path = PathBuf::from(&path); + if sdk_path.exists() { + return Ok(sdk_path); + } + searched.push(sdk_path); + } + + // Check ANDROID_SDK_ROOT (alternative) + if let Ok(path) = env::var("ANDROID_SDK_ROOT") { + let sdk_path = PathBuf::from(&path); + if sdk_path.exists() { + return Ok(sdk_path); + } + searched.push(sdk_path); + } + + // Check common installation locations + if let Ok(home) = env::var("HOME") { + let home_path = PathBuf::from(home); + let candidates = [ + home_path.join("Library/Android/sdk"), // macOS (Android Studio) + home_path.join("Android/Sdk"), // Linux (Android Studio) + home_path.join(".android/sdk"), // Alternative Linux + ]; + + for candidate in &candidates { + if candidate.exists() { + return Ok(candidate.clone()); + } + searched.push(candidate.clone()); + } + } + + let searched_list = if searched.is_empty() { + " - (no candidates found)".to_string() + } else { + searched + .iter() + .map(|path| format!(" - {}", path.display())) + .collect::>() + .join("\n") + }; + + Err(BenchError::Build(format!( + "Android SDK not found.\n\n\ + Searched:\n{}\n\n\ + Set ANDROID_HOME or ANDROID_SDK_ROOT to your SDK path (for example: $HOME/Library/Android/sdk).\n\ + You can also install the SDK via Android Studio.", + searched_list + ))) + } + /// Builds the Android APK using Gradle fn build_apk(&self, config: &BuildConfig) -> Result { let android_dir = self.output_dir.join("android"); if !android_dir.exists() { return Err(BenchError::Build(format!( - "Android project not found at {:?}", - android_dir + "Android project not found at {}.\n\n\ + Expected a Gradle project under the output directory.\n\ + Run `cargo mobench init-sdk --target android` or `cargo mobench build --target android` from the project root to generate it.", + android_dir.display() ))); } + // Ensure local.properties exists with sdk.dir + self.ensure_local_properties(&android_dir)?; + // Determine Gradle task let gradle_task = match config.profile { BuildProfile::Debug => "assembleDebug", @@ -320,13 +613,38 @@ impl AndroidBuilder { let output = cmd .output() - .map_err(|e| BenchError::Build(format!("Failed to run Gradle: {}", e)))?; + .map_err(|e| BenchError::Build(format!( + "Failed to run Gradle wrapper.\n\n\ + Command: ./gradlew {}\n\ + Working directory: {}\n\ + Error: {}\n\n\ + Tips:\n\ + - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\ + - Run ./gradlew --version in that directory to verify the wrapper", + gradle_task, + android_dir.display(), + e + )))?; if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); return Err(BenchError::Build(format!( - "Gradle build failed: {}", - stderr + "Gradle build failed.\n\n\ + Command: ./gradlew {}\n\ + Working directory: {}\n\ + Exit status: {}\n\n\ + Stdout:\n{}\n\n\ + Stderr:\n{}\n\n\ + Tips:\n\ + - Re-run with verbose mode to pass --info to Gradle\n\ + - Run ./gradlew {} --stacktrace for a full stack trace", + gradle_task, + android_dir.display(), + output.status, + stdout, + stderr, + gradle_task, ))); } @@ -343,8 +661,13 @@ impl AndroidBuilder { if !apk_path.exists() { return Err(BenchError::Build(format!( - "APK not found at expected location: {:?}", - apk_path + "APK not found at expected location: {}.\n\n\ + Gradle task {} reported success but no APK was produced.\n\ + Check app/build/outputs/apk/{} and rerun ./gradlew {} if needed.", + apk_path.display(), + gradle_task, + profile_name, + gradle_task ))); } @@ -357,8 +680,10 @@ impl AndroidBuilder { if !android_dir.exists() { return Err(BenchError::Build(format!( - "Android project not found at {:?}", - android_dir + "Android project not found at {}.\n\n\ + Expected a Gradle project under the output directory.\n\ + Run `cargo mobench init-sdk --target android` or `cargo mobench build --target android` from the project root to generate it.", + android_dir.display() ))); } @@ -376,13 +701,38 @@ impl AndroidBuilder { let output = cmd .output() - .map_err(|e| BenchError::Build(format!("Failed to run Gradle: {}", e)))?; + .map_err(|e| BenchError::Build(format!( + "Failed to run Gradle wrapper.\n\n\ + Command: ./gradlew {}\n\ + Working directory: {}\n\ + Error: {}\n\n\ + Tips:\n\ + - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\ + - Run ./gradlew --version in that directory to verify the wrapper", + gradle_task, + android_dir.display(), + e + )))?; if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); return Err(BenchError::Build(format!( - "Gradle test APK build failed: {}", - stderr + "Gradle test APK build failed.\n\n\ + Command: ./gradlew {}\n\ + Working directory: {}\n\ + Exit status: {}\n\n\ + Stdout:\n{}\n\n\ + Stderr:\n{}\n\n\ + Tips:\n\ + - Re-run with verbose mode to pass --info to Gradle\n\ + - Run ./gradlew {} --stacktrace for a full stack trace", + gradle_task, + android_dir.display(), + output.status, + stdout, + stderr, + gradle_task, ))); } @@ -398,8 +748,13 @@ impl AndroidBuilder { if !apk_path.exists() { return Err(BenchError::Build(format!( - "Android test APK not found at expected location: {:?}", - apk_path + "Android test APK not found at expected location: {}.\n\n\ + Gradle task {} reported success but no test APK was produced.\n\ + Check app/build/outputs/apk/androidTest/{} and rerun ./gradlew {} if needed.", + apk_path.display(), + gradle_task, + profile_name, + gradle_task ))); } @@ -407,52 +762,6 @@ impl AndroidBuilder { } } -// Shared helpers -fn host_lib_path(project_dir: &Path, crate_name: &str) -> Result { - let lib_prefix = if cfg!(target_os = "windows") { - "" - } else { - "lib" - }; - let lib_ext = match env::consts::OS { - "macos" => "dylib", - "linux" => "so", - other => { - return Err(BenchError::Build(format!( - "unsupported host OS for binding generation: {}", - other - ))); - } - }; - let path = project_dir.join("target").join("debug").join(format!( - "{}{}.{}", - lib_prefix, - crate_name.replace('-', "_"), - lib_ext - )); - if !path.exists() { - return Err(BenchError::Build(format!( - "host library for UniFFI not found at {:?}", - path - ))); - } - Ok(path) -} - -fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> { - let output = cmd - .output() - .map_err(|e| BenchError::Build(format!("Failed to run {}: {}", description, e)))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(BenchError::Build(format!( - "{} failed: {}", - description, stderr - ))); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/mobench-sdk/src/builders/common.rs b/crates/mobench-sdk/src/builders/common.rs new file mode 100644 index 0000000..e9ff888 --- /dev/null +++ b/crates/mobench-sdk/src/builders/common.rs @@ -0,0 +1,193 @@ +//! Common utilities shared between Android and iOS builders +//! +//! This module provides helper functions for: +//! - Detecting Cargo target directories (workspace-aware) +//! - Finding host libraries for UniFFI binding generation +//! - Running external commands with consistent error handling + +use std::env; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::types::BenchError; + +/// Detects the actual Cargo target directory using `cargo metadata`. +/// +/// This correctly handles Cargo workspaces where the target directory +/// is at the workspace root, not the crate directory. +/// +/// # Arguments +/// * `crate_dir` - Path to the crate directory containing Cargo.toml +/// +/// # Returns +/// The path to the target directory, or falls back to `crate_dir/target` if detection fails. +pub fn get_cargo_target_dir(crate_dir: &Path) -> Result { + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--no-deps"]) + .current_dir(crate_dir) + .output() + .map_err(|e| { + BenchError::Build(format!( + "Failed to run cargo metadata.\n\n\ + Working directory: {}\n\ + Error: {}\n\n\ + Ensure cargo is installed and on PATH.", + crate_dir.display(), + e + )) + })?; + + if !output.status.success() { + // Fall back to crate_dir/target if cargo metadata fails + return Ok(crate_dir.join("target")); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse the JSON to extract target_directory + // Using simple string parsing to avoid adding serde_json dependency + if let Some(start) = stdout.find("\"target_directory\":\"") { + let rest = &stdout[start + 20..]; + if let Some(end) = rest.find('"') { + let target_dir = &rest[..end]; + // Handle escaped backslashes in Windows paths + let target_dir = target_dir.replace("\\\\", "\\"); + return Ok(PathBuf::from(target_dir)); + } + } + + // Fall back to crate_dir/target if parsing fails + Ok(crate_dir.join("target")) +} + +/// Finds the host library path for UniFFI binding generation. +/// +/// UniFFI requires a host-compiled library to generate bindings. This function +/// locates that library in the target directory. +/// +/// # Arguments +/// * `crate_dir` - Path to the crate directory +/// * `crate_name` - Name of the crate (used to construct library filename) +/// +/// # Returns +/// Path to the host library (e.g., `libfoo.dylib` on macOS, `libfoo.so` on Linux) +pub fn host_lib_path(crate_dir: &Path, crate_name: &str) -> Result { + let lib_prefix = if cfg!(target_os = "windows") { + "" + } else { + "lib" + }; + let lib_ext = match env::consts::OS { + "macos" => "dylib", + "linux" => "so", + other => { + return Err(BenchError::Build(format!( + "Unsupported host OS for binding generation: {}\n\n\ + Supported platforms:\n\ + - macOS (generates .dylib)\n\ + - Linux (generates .so)\n\n\ + Windows is not currently supported for binding generation.", + other + ))); + } + }; + + // Use cargo metadata to find the actual target directory + let target_dir = get_cargo_target_dir(crate_dir)?; + + let lib_name = format!( + "{}{}.{}", + lib_prefix, + crate_name.replace('-', "_"), + lib_ext + ); + let path = target_dir.join("debug").join(&lib_name); + + if !path.exists() { + return Err(BenchError::Build(format!( + "Host library for UniFFI not found.\n\n\ + Expected: {}\n\ + Target directory: {}\n\n\ + To fix this:\n\ + 1. Build the host library first:\n\ + cargo build -p {}\n\n\ + 2. Ensure your crate produces a cdylib:\n\ + [lib]\n\ + crate-type = [\"cdylib\"]\n\n\ + 3. Check that the library name matches: {}", + path.display(), + target_dir.display(), + crate_name, + lib_name + ))); + } + Ok(path) +} + +/// Runs an external command with consistent error handling. +/// +/// Captures both stdout and stderr on failure and formats them into +/// an actionable error message. +/// +/// # Arguments +/// * `cmd` - The command to execute +/// * `description` - Human-readable description of what the command does +/// +/// # Returns +/// `Ok(())` if the command succeeds, or a `BenchError` with detailed output on failure. +pub fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> { + let output = cmd.output().map_err(|e| { + BenchError::Build(format!( + "Failed to start {}.\n\n\ + Error: {}\n\n\ + Ensure the tool is installed and available on PATH.", + description, e + )) + })?; + + if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "{} failed.\n\n\ + Exit status: {}\n\n\ + Stdout:\n{}\n\n\ + Stderr:\n{}", + description, output.status, stdout, stderr + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_cargo_target_dir_fallback() { + // For a non-existent directory, should fall back gracefully + let result = get_cargo_target_dir(Path::new("/nonexistent/path")); + // Should either error or return fallback path + assert!(result.is_ok() || result.is_err()); + } + + #[test] + fn test_host_lib_path_not_found() { + let result = host_lib_path(Path::new("/tmp"), "nonexistent-crate"); + assert!(result.is_err()); + let err = result.unwrap_err(); + let msg = format!("{}", err); + assert!(msg.contains("Host library for UniFFI not found")); + assert!(msg.contains("cargo build")); + } + + #[test] + fn test_run_command_not_found() { + let cmd = Command::new("nonexistent-command-12345"); + let result = run_command(cmd, "test command"); + assert!(result.is_err()); + let err = result.unwrap_err(); + let msg = format!("{}", err); + assert!(msg.contains("Failed to start")); + } +} diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 458aeb5..986ab52 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -4,6 +4,7 @@ //! create an xcframework that can be used in Xcode projects. use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; +use super::common::{get_cargo_target_dir, host_lib_path, run_command}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -19,6 +20,10 @@ pub struct IosBuilder { crate_name: String, /// Whether to use verbose output verbose: bool, + /// Optional explicit crate directory (overrides auto-detection) + crate_dir: Option, + /// Whether to run in dry-run mode (print what would be done without making changes) + dry_run: bool, } impl IosBuilder { @@ -35,6 +40,8 @@ impl IosBuilder { project_root: root, crate_name: crate_name.into(), verbose: false, + crate_dir: None, + dry_run: false, } } @@ -47,15 +54,37 @@ impl IosBuilder { self } + /// Sets the explicit crate directory + /// + /// By default, the builder searches for the crate in: + /// - `{project_root}/bench-mobile/` + /// - `{project_root}/crates/{crate_name}/` + /// + /// Use this to override auto-detection and point directly to the crate. + pub fn crate_dir(mut self, dir: impl Into) -> Self { + self.crate_dir = Some(dir.into()); + self + } + /// Enables verbose output pub fn verbose(mut self, verbose: bool) -> Self { self.verbose = verbose; self } + /// Enables dry-run mode + /// + /// In dry-run mode, the builder prints what would be done without actually + /// making any changes. Useful for previewing the build process. + pub fn dry_run(mut self, dry_run: bool) -> Self { + self.dry_run = dry_run; + self + } + /// Builds the iOS app with the given configuration /// /// This performs the following steps: + /// 0. Auto-generate project scaffolding if missing /// 1. Build Rust libraries for iOS targets (device + simulator) /// 2. Generate UniFFI Swift bindings and C headers /// 3. Create xcframework with proper structure @@ -68,6 +97,38 @@ impl IosBuilder { /// * `Err(BenchError)` if the build fails pub fn build(&self, config: &BuildConfig) -> Result { let framework_name = self.crate_name.replace("-", "_"); + let ios_dir = self.output_dir.join("ios"); + let xcframework_path = ios_dir.join(format!("{}.xcframework", framework_name)); + + if self.dry_run { + println!("\n[dry-run] iOS build plan:"); + println!(" Step 0: Check/generate iOS project scaffolding at {:?}", ios_dir.join("BenchRunner")); + println!(" Step 1: Build Rust libraries for iOS targets"); + println!(" Command: cargo build --target aarch64-apple-ios --lib {}", + if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); + println!(" Command: cargo build --target aarch64-apple-ios-sim --lib {}", + if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); + println!(" Step 2: Generate UniFFI Swift bindings"); + println!(" Output: {:?}", ios_dir.join("BenchRunner/BenchRunner/Generated")); + println!(" Step 3: Create xcframework at {:?}", xcframework_path); + println!(" - ios-arm64/{}.framework (device)", framework_name); + println!(" - ios-simulator-arm64/{}.framework (simulator)", framework_name); + println!(" Step 4: Code-sign xcframework"); + println!(" Command: codesign --force --deep --sign - {:?}", xcframework_path); + println!(" Step 5: Generate Xcode project with xcodegen (if project.yml exists)"); + println!(" Command: xcodegen generate"); + + // Return a placeholder result for dry-run + return Ok(BuildResult { + platform: Target::Ios, + app_path: xcframework_path, + test_suite_path: None, + }); + } + + // Step 0: Ensure iOS project scaffolding exists + crate::codegen::ensure_ios_project(&self.output_dir, &self.crate_name)?; + // Step 1: Build Rust libraries println!("Building Rust libraries for iOS..."); self.build_rust_libraries(config)?; @@ -94,12 +155,17 @@ impl IosBuilder { )) })?; let include_dir = self.output_dir.join("ios/include"); - fs::create_dir_all(&include_dir) - .map_err(|e| BenchError::Build(format!("Failed to create include dir: {}", e)))?; + fs::create_dir_all(&include_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to create include dir at {}: {}. Check output directory permissions.", + include_dir.display(), + e + )) + })?; let header_dest = include_dir.join(format!("{}.h", framework_name)); fs::copy(&header_src, &header_dest).map_err(|e| { BenchError::Build(format!( - "Failed to copy UniFFI header to {:?}: {}", + "Failed to copy UniFFI header to {:?}: {}. Check output directory permissions.", header_dest, e )) })?; @@ -116,6 +182,18 @@ impl IosBuilder { /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) fn find_crate_dir(&self) -> Result { + // If explicit crate_dir was provided, use it + if let Some(ref dir) = self.crate_dir { + if dir.exists() { + return Ok(dir.clone()); + } + return Err(BenchError::Build(format!( + "Specified crate path does not exist: {:?}.\n\n\ + Tip: pass --crate-path pointing at a directory containing Cargo.toml.", + dir + ))); + } + // Try bench-mobile/ first (SDK projects) let bench_mobile_dir = self.project_root.join("bench-mobile"); if bench_mobile_dir.exists() { @@ -128,9 +206,25 @@ impl IosBuilder { return Ok(crates_dir); } + let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml"); + let crates_manifest = crates_dir.join("Cargo.toml"); Err(BenchError::Build(format!( - "Benchmark crate '{}' not found. Tried:\n - {:?}\n - {:?}", - self.crate_name, bench_mobile_dir, crates_dir + "Benchmark crate '{}' not found.\n\n\ + Searched locations:\n\ + - {}\n\ + - {}\n\n\ + To fix this:\n\ + 1. Create a bench-mobile/ directory with your benchmark crate, or\n\ + 2. Use --crate-path to specify the benchmark crate location:\n\ + cargo mobench build --target ios --crate-path ./my-benchmarks\n\n\ + Common issues:\n\ + - Typo in crate name (check Cargo.toml [package] name)\n\ + - Wrong working directory (run from project root)\n\ + - Missing Cargo.toml in the crate directory\n\n\ + Run 'cargo mobench init-sdk --help' to generate a new benchmark project.", + self.crate_name, + bench_mobile_manifest.display(), + crates_manifest.display() ))) } @@ -146,6 +240,11 @@ impl IosBuilder { // Check if targets are installed self.check_rust_targets(&targets)?; + let release_flag = if matches!(config.profile, BuildProfile::Release) { + "--release" + } else { + "" + }; for target in targets { if self.verbose { @@ -156,23 +255,53 @@ impl IosBuilder { cmd.arg("build").arg("--target").arg(target).arg("--lib"); // Add release flag if needed - if matches!(config.profile, BuildProfile::Release) { - cmd.arg("--release"); + if !release_flag.is_empty() { + cmd.arg(release_flag); } // Set working directory cmd.current_dir(&crate_dir); // Execute build + let command_hint = if release_flag.is_empty() { + format!("cargo build --target {} --lib", target) + } else { + format!("cargo build --target {} --lib {}", target, release_flag) + }; let output = cmd .output() - .map_err(|e| BenchError::Build(format!("Failed to run cargo: {}", e)))?; + .map_err(|e| BenchError::Build(format!( + "Failed to run cargo for {}.\n\n\ + Command: {}\n\ + Crate directory: {}\n\ + Error: {}\n\n\ + Tip: ensure cargo is installed and on PATH.", + target, + command_hint, + crate_dir.display(), + e + )))?; if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); return Err(BenchError::Build(format!( - "cargo build failed for {}: {}", - target, stderr + "cargo build failed for {}.\n\n\ + Command: {}\n\ + Crate directory: {}\n\ + Exit status: {}\n\n\ + Stdout:\n{}\n\n\ + Stderr:\n{}\n\n\ + Tips:\n\ + - Ensure Xcode command line tools are installed (xcode-select --install)\n\ + - Confirm Rust targets are installed (rustup target add {})", + target, + command_hint, + crate_dir.display(), + output.status, + stdout, + stderr, + target ))); } } @@ -187,14 +316,25 @@ impl IosBuilder { .arg("list") .arg("--installed") .output() - .map_err(|e| BenchError::Build(format!("Failed to check rustup targets: {}", e)))?; + .map_err(|e| { + BenchError::Build(format!( + "Failed to check rustup targets: {}. Ensure rustup is installed and on PATH.", + e + )) + })?; let installed = String::from_utf8_lossy(&output.stdout); for target in targets { if !installed.contains(target) { return Err(BenchError::Build(format!( - "Rust target {} is not installed. Install it with: rustup target add {}", + "Rust target '{}' is not installed.\n\n\ + This target is required to compile for iOS.\n\n\ + To install:\n\ + rustup target add {}\n\n\ + For a complete iOS setup, you need both:\n\ + rustup target add aarch64-apple-ios # Device\n\ + rustup target add aarch64-apple-ios-sim # Simulator (Apple Silicon)", target, target ))); } @@ -224,22 +364,6 @@ impl IosBuilder { return Ok(()); } - // Check if uniffi-bindgen is available - let uniffi_available = Command::new("uniffi-bindgen") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - - if !uniffi_available { - return Err(BenchError::Build( - "uniffi-bindgen not found and no pre-generated bindings exist.\n\ - Install it with: cargo install uniffi-bindgen\n\ - Or use pre-generated bindings by copying them to the expected location." - .to_string(), - )); - } - // Build host library to feed uniffi-bindgen let mut build_cmd = Command::new("cargo"); build_cmd.arg("build"); @@ -254,18 +378,68 @@ impl IosBuilder { .join("BenchRunner") .join("Generated"); fs::create_dir_all(&out_dir).map_err(|e| { - BenchError::Build(format!("Failed to create Swift bindings dir: {}", e)) + BenchError::Build(format!( + "Failed to create Swift bindings dir at {}: {}. Check output directory permissions.", + out_dir.display(), + e + )) })?; - let mut cmd = Command::new("uniffi-bindgen"); - cmd.arg("generate") + // Try cargo run first (works if crate has uniffi-bindgen binary target) + let cargo_run_result = Command::new("cargo") + .args(["run", "-p", &self.crate_name, "--bin", "uniffi-bindgen", "--"]) + .arg("generate") .arg("--library") .arg(&lib_path) .arg("--language") .arg("swift") .arg("--out-dir") - .arg(&out_dir); - run_command(cmd, "uniffi-bindgen swift")?; + .arg(&out_dir) + .current_dir(&crate_dir) + .output(); + + let use_cargo_run = cargo_run_result + .as_ref() + .map(|o| o.status.success()) + .unwrap_or(false); + + if use_cargo_run { + if self.verbose { + println!(" Generated bindings using cargo run uniffi-bindgen"); + } + } else { + // Fall back to global uniffi-bindgen + let uniffi_available = Command::new("uniffi-bindgen") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !uniffi_available { + return Err(BenchError::Build( + "uniffi-bindgen not found and no pre-generated bindings exist.\n\n\ + To fix this, either:\n\ + 1. Add a uniffi-bindgen binary to your crate:\n\ + [[bin]]\n\ + name = \"uniffi-bindgen\"\n\ + path = \"src/bin/uniffi-bindgen.rs\"\n\n\ + 2. Or install uniffi-bindgen globally:\n\ + cargo install uniffi-bindgen\n\n\ + 3. Or pre-generate bindings and commit them." + .to_string(), + )); + } + + let mut cmd = Command::new("uniffi-bindgen"); + cmd.arg("generate") + .arg("--library") + .arg(&lib_path) + .arg("--language") + .arg("swift") + .arg("--out-dir") + .arg(&out_dir); + run_command(cmd, "uniffi-bindgen swift")?; + } if self.verbose { println!(" Generated UniFFI Swift bindings at {:?}", out_dir); @@ -281,7 +455,8 @@ impl IosBuilder { BuildProfile::Release => "release", }; - let target_dir = self.project_root.join("target"); + let crate_dir = self.find_crate_dir()?; + let target_dir = get_cargo_target_dir(&crate_dir)?; let xcframework_dir = self.output_dir.join("ios"); let framework_name = &self.crate_name.replace("-", "_"); let xcframework_path = xcframework_dir.join(format!("{}.xcframework", framework_name)); @@ -289,13 +464,21 @@ impl IosBuilder { // Remove existing xcframework if it exists if xcframework_path.exists() { fs::remove_dir_all(&xcframework_path).map_err(|e| { - BenchError::Build(format!("Failed to remove old xcframework: {}", e)) + BenchError::Build(format!( + "Failed to remove old xcframework at {}: {}. Close any tools using it and retry.", + xcframework_path.display(), + e + )) })?; } // Create xcframework directory fs::create_dir_all(&xcframework_dir).map_err(|e| { - BenchError::Build(format!("Failed to create xcframework directory: {}", e)) + BenchError::Build(format!( + "Failed to create xcframework directory at {}: {}. Check output directory permissions.", + xcframework_dir.display(), + e + )) })?; // Build framework structure for each platform @@ -332,7 +515,11 @@ impl IosBuilder { // Create directories fs::create_dir_all(&headers_dir).map_err(|e| { - BenchError::Build(format!("Failed to create framework directories: {}", e)) + BenchError::Build(format!( + "Failed to create framework directories at {}: {}. Check output directory permissions.", + headers_dir.display(), + e + )) })?; // Copy static library @@ -341,13 +528,21 @@ impl IosBuilder { if !src_lib.exists() { return Err(BenchError::Build(format!( - "Static library not found: {:?}", - src_lib + "Static library not found at {}.\n\n\ + Expected output from cargo build --target --lib.\n\ + Ensure your crate has [lib] crate-type = [\"staticlib\"].", + src_lib.display() ))); } - fs::copy(&src_lib, &dest_lib) - .map_err(|e| BenchError::Build(format!("Failed to copy static library: {}", e)))?; + fs::copy(&src_lib, &dest_lib).map_err(|e| { + BenchError::Build(format!( + "Failed to copy static library from {} to {}: {}. Check output directory permissions.", + src_lib.display(), + dest_lib.display(), + e + )) + })?; // Copy UniFFI-generated header into the framework let header_name = format!("{}FFI.h", framework_name); @@ -357,10 +552,13 @@ impl IosBuilder { header_name )) })?; - fs::copy(&header_path, headers_dir.join(&header_name)).map_err(|e| { + let dest_header = headers_dir.join(&header_name); + fs::copy(&header_path, &dest_header).map_err(|e| { BenchError::Build(format!( - "Failed to copy UniFFI header from {:?}: {}", - header_path, e + "Failed to copy UniFFI header from {} to {}: {}. Check output directory permissions.", + header_path.display(), + dest_header.display(), + e )) })?; @@ -369,8 +567,14 @@ impl IosBuilder { "framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}", framework_name, framework_name ); - fs::write(headers_dir.join("module.modulemap"), modulemap_content) - .map_err(|e| BenchError::Build(format!("Failed to write module.modulemap: {}", e)))?; + let modulemap_path = headers_dir.join("module.modulemap"); + fs::write(&modulemap_path, modulemap_content).map_err(|e| { + BenchError::Build(format!( + "Failed to write module.modulemap at {}: {}. Check output directory permissions.", + modulemap_path.display(), + e + )) + })?; // Create framework Info.plist self.create_framework_plist(&framework_dir, framework_name, platform)?; @@ -421,8 +625,13 @@ impl IosBuilder { } ); - fs::write(framework_dir.join("Info.plist"), plist_content).map_err(|e| { - BenchError::Build(format!("Failed to write framework Info.plist: {}", e)) + let plist_path = framework_dir.join("Info.plist"); + fs::write(&plist_path, plist_content).map_err(|e| { + BenchError::Build(format!( + "Failed to write framework Info.plist at {}: {}. Check output directory permissions.", + plist_path.display(), + e + )) })?; Ok(()) @@ -477,8 +686,13 @@ impl IosBuilder { framework_name, framework_name ); - fs::write(xcframework_path.join("Info.plist"), plist_content).map_err(|e| { - BenchError::Build(format!("Failed to write xcframework Info.plist: {}", e)) + let plist_path = xcframework_path.join("Info.plist"); + fs::write(&plist_path, plist_content).map_err(|e| { + BenchError::Build(format!( + "Failed to write xcframework Info.plist at {}: {}. Check output directory permissions.", + plist_path.display(), + e + )) })?; Ok(()) @@ -531,16 +745,30 @@ impl IosBuilder { println!(" Generating Xcode project with xcodegen"); } + let project_dir = ios_dir.join("BenchRunner"); let output = Command::new("xcodegen") .arg("generate") - .current_dir(ios_dir.join("BenchRunner")) + .current_dir(&project_dir) .output(); match output { Ok(output) if output.status.success() => Ok(()), Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - Err(BenchError::Build(format!("xcodegen failed: {}", stderr))) + Err(BenchError::Build(format!( + "xcodegen failed.\n\n\ + Command: xcodegen generate\n\ + Working directory: {}\n\ + Exit status: {}\n\n\ + Stdout:\n{}\n\n\ + Stderr:\n{}\n\n\ + Tip: install xcodegen with: brew install xcodegen", + project_dir.display(), + output.status, + stdout, + stderr + ))) } Err(e) => { println!("Warning: xcodegen not found or failed: {}", e); @@ -561,7 +789,9 @@ impl IosBuilder { return Some(candidate_swift); } - let target_dir = self.project_root.join("target"); + // Get the actual target directory (handles workspace case) + let crate_dir = self.find_crate_dir().ok()?; + let target_dir = get_cargo_target_dir(&crate_dir).ok()?; // Common UniFFI output location when using uniffi::generate_scaffolding let candidate = target_dir.join("uniffi").join(header_name); if candidate.exists() { @@ -595,52 +825,6 @@ impl IosBuilder { } } -// Shared helpers (duplicated with android builder) -fn host_lib_path(project_dir: &Path, crate_name: &str) -> Result { - let lib_prefix = if cfg!(target_os = "windows") { - "" - } else { - "lib" - }; - let lib_ext = match env::consts::OS { - "macos" => "dylib", - "linux" => "so", - other => { - return Err(BenchError::Build(format!( - "unsupported host OS for binding generation: {}", - other - ))); - } - }; - let path = project_dir.join("target").join("debug").join(format!( - "{}{}.{}", - lib_prefix, - crate_name.replace('-', "_"), - lib_ext - )); - if !path.exists() { - return Err(BenchError::Build(format!( - "host library for UniFFI not found at {:?}", - path - ))); - } - Ok(path) -} - -fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> { - let output = cmd - .output() - .map_err(|e| BenchError::Build(format!("Failed to run {}: {}", description, e)))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(BenchError::Build(format!( - "{} failed: {}", - description, stderr - ))); - } - Ok(()) -} - #[allow(clippy::collapsible_if)] fn find_codesign_identity() -> Option { let output = Command::new("security") @@ -706,7 +890,7 @@ fn embed_provisioning_profile(app_path: &Path, profile: &Path) -> Result<(), Ben let dest = app_path.join("embedded.mobileprovision"); fs::copy(profile, &dest).map_err(|e| { BenchError::Build(format!( - "Failed to embed provisioning profile {:?}: {}", + "Failed to embed provisioning profile at {:?}: {}. Check the profile path and file permissions.", dest, e )) })?; @@ -718,10 +902,18 @@ fn codesign_bundle(app_path: &Path, identity: &str) -> Result<(), BenchError> { .args(["--force", "--deep", "--sign", identity]) .arg(app_path) .output() - .map_err(|e| BenchError::Build(format!("Failed to run codesign: {}", e)))?; + .map_err(|e| { + BenchError::Build(format!( + "Failed to run codesign: {}. Ensure Xcode command line tools are installed.", + e + )) + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(BenchError::Build(format!("codesign failed: {}", stderr))); + return Err(BenchError::Build(format!( + "codesign failed: {}. Verify you have a valid signing identity.", + stderr + ))); } Ok(()) } @@ -772,8 +964,9 @@ impl IosBuilder { // Verify Xcode project exists if !project_path.exists() { return Err(BenchError::Build(format!( - "Xcode project not found at {:?}. Run `cargo mobench build --target ios` first.", - project_path + "Xcode project not found at {}.\n\n\ + Run `cargo mobench build --target ios` first or check --output-dir.", + project_path.display() ))); } @@ -781,8 +974,13 @@ impl IosBuilder { let ipa_path = export_path.join(format!("{}.ipa", scheme)); // Create target/ios directory if it doesn't exist - fs::create_dir_all(&export_path) - .map_err(|e| BenchError::Build(format!("Failed to create export directory: {}", e)))?; + fs::create_dir_all(&export_path).map_err(|e| { + BenchError::Build(format!( + "Failed to create export directory at {}: {}. Check output directory permissions.", + export_path.display(), + e + )) + })?; println!("Building {} for device...", scheme); @@ -833,18 +1031,39 @@ impl IosBuilder { .join(format!("{}.app", scheme)); if !app_path.exists() { - // Only fail if the .app wasn't created - if let Ok(output) = build_result { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(BenchError::Build(format!( - "xcodebuild build failed and app bundle not found: {}", - stderr - ))); + match build_result { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "xcodebuild build failed and app bundle was not created.\n\n\ + Project: {}\n\ + Scheme: {}\n\ + Configuration: {}\n\ + Derived data: {}\n\ + Exit status: {}\n\n\ + Stdout:\n{}\n\n\ + Stderr:\n{}\n\n\ + Tip: run xcodebuild manually to inspect the failure.", + project_path.display(), + scheme, + build_configuration, + build_dir.display(), + output.status, + stdout, + stderr + ))); + } + Err(err) => { + return Err(BenchError::Build(format!( + "Failed to run xcodebuild: {}.\n\n\ + App bundle not found at {}.\n\ + Check that Xcode command line tools are installed.", + err, + app_path.display() + ))); + } } - return Err(BenchError::Build(format!( - "App bundle not found at {:?}. Build may have failed.", - app_path - ))); } if self.verbose { @@ -894,11 +1113,20 @@ impl IosBuilder { let payload_dir = export_path.join("Payload"); if payload_dir.exists() { fs::remove_dir_all(&payload_dir).map_err(|e| { - BenchError::Build(format!("Failed to remove old Payload dir: {}", e)) + BenchError::Build(format!( + "Failed to remove old Payload dir at {}: {}. Close any tools using it and retry.", + payload_dir.display(), + e + )) })?; } - fs::create_dir_all(&payload_dir) - .map_err(|e| BenchError::Build(format!("Failed to create Payload dir: {}", e)))?; + fs::create_dir_all(&payload_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to create Payload dir at {}: {}. Check output directory permissions.", + payload_dir.display(), + e + )) + })?; // Copy app bundle into Payload/ let dest_app = payload_dir.join(format!("{}.app", scheme)); @@ -906,8 +1134,13 @@ impl IosBuilder { // Create zip archive if ipa_path.exists() { - fs::remove_file(&ipa_path) - .map_err(|e| BenchError::Build(format!("Failed to remove old IPA: {}", e)))?; + fs::remove_file(&ipa_path).map_err(|e| { + BenchError::Build(format!( + "Failed to remove old IPA at {}: {}. Check file permissions.", + ipa_path.display(), + e + )) + })?; } let mut cmd = Command::new("zip"); @@ -921,8 +1154,13 @@ impl IosBuilder { run_command(cmd, "zip IPA")?; // Clean up Payload directory - fs::remove_dir_all(&payload_dir) - .map_err(|e| BenchError::Build(format!("Failed to clean up Payload dir: {}", e)))?; + fs::remove_dir_all(&payload_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to clean up Payload dir at {}: {}. Check file permissions.", + payload_dir.display(), + e + )) + })?; println!("✓ IPA created: {:?}", ipa_path); Ok(ipa_path) @@ -938,14 +1176,20 @@ impl IosBuilder { if !project_path.exists() { return Err(BenchError::Build(format!( - "Xcode project not found at {:?}. Run `cargo mobench build --target ios` first.", - project_path + "Xcode project not found at {}.\n\n\ + Run `cargo mobench build --target ios` first or check --output-dir.", + project_path.display() ))); } let export_path = self.output_dir.join("ios"); - fs::create_dir_all(&export_path) - .map_err(|e| BenchError::Build(format!("Failed to create export directory: {}", e)))?; + fs::create_dir_all(&export_path).map_err(|e| { + BenchError::Build(format!( + "Failed to create export directory at {}: {}. Check output directory permissions.", + export_path.display(), + e + )) + })?; let build_dir = self.output_dir.join("ios/build"); println!("Building XCUITest runner for {}...", scheme); @@ -1009,17 +1253,39 @@ impl IosBuilder { } if !runner_path.exists() { - if let Ok(output) = build_result { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(BenchError::Build(format!( - "xcodebuild build-for-testing failed and runner not found: {}", - stderr - ))); + match build_result { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "xcodebuild build-for-testing failed and runner was not created.\n\n\ + Project: {}\n\ + Scheme: {}\n\ + Derived data: {}\n\ + Exit status: {}\n\ + Log: {}\n\n\ + Stdout:\n{}\n\n\ + Stderr:\n{}\n\n\ + Tip: open the log file above for more context.", + project_path.display(), + scheme, + build_dir.display(), + output.status, + log_path.display(), + stdout, + stderr + ))); + } + Err(err) => { + return Err(BenchError::Build(format!( + "Failed to run xcodebuild: {}.\n\n\ + XCUITest runner not found at {}.\n\ + Check that Xcode command line tools are installed.", + err, + runner_path.display() + ))); + } } - return Err(BenchError::Build(format!( - "XCUITest runner not found at {:?}. Build may have failed.", - runner_path - ))); } let profile = find_provisioning_profile(); @@ -1038,8 +1304,13 @@ impl IosBuilder { let zip_path = export_path.join(format!("{}UITests.zip", scheme)); if zip_path.exists() { - fs::remove_file(&zip_path) - .map_err(|e| BenchError::Build(format!("Failed to remove old zip: {}", e)))?; + fs::remove_file(&zip_path).map_err(|e| { + BenchError::Build(format!( + "Failed to remove old zip at {}: {}. Check file permissions.", + zip_path.display(), + e + )) + })?; } let mut zip_cmd = Command::new("zip"); diff --git a/crates/mobench-sdk/src/builders/mod.rs b/crates/mobench-sdk/src/builders/mod.rs index 73acac9..124e214 100644 --- a/crates/mobench-sdk/src/builders/mod.rs +++ b/crates/mobench-sdk/src/builders/mod.rs @@ -5,6 +5,7 @@ pub mod android; pub mod ios; +mod common; // Re-export builders pub use android::AndroidBuilder; diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 688637a..b9a80d8 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -80,6 +80,8 @@ fn generate_bench_mobile_crate(output_dir: &Path, project_name: &str) -> Result< let crate_name = format!("{}-bench-mobile", project_name); // Generate Cargo.toml + // Note: We configure rustls to use 'ring' instead of 'aws-lc-rs' (default in rustls 0.23+) + // because aws-lc-rs doesn't compile for Android NDK targets. let cargo_toml = format!( r#"[package] name = "{}" @@ -99,6 +101,23 @@ default = [] [build-dependencies] uniffi = {{ version = "0.28", features = ["build"] }} + +# Binary for generating UniFFI bindings (used by mobench build) +[[bin]] +name = "uniffi-bindgen" +path = "src/bin/uniffi-bindgen.rs" + +# IMPORTANT: If your project uses rustls (directly or transitively), you must configure +# it to use the 'ring' crypto backend instead of 'aws-lc-rs' (the default in rustls 0.23+). +# aws-lc-rs doesn't compile for Android NDK targets due to C compilation issues. +# +# Add this to your root Cargo.toml: +# [workspace.dependencies] +# rustls = {{ version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }} +# +# Then in each crate that uses rustls: +# [dependencies] +# rustls = {{ workspace = true }} "#, crate_name, project_name ); @@ -235,11 +254,28 @@ uniffi::setup_scaffolding!(); fs::write(crate_dir.join("build.rs"), build_rs)?; + // Generate uniffi-bindgen binary (used by mobench build) + let bin_dir = crate_dir.join("src/bin"); + fs::create_dir_all(&bin_dir)?; + let uniffi_bindgen_rs = r#"fn main() { + uniffi::uniffi_bindgen_main() +} +"#; + fs::write(bin_dir.join("uniffi-bindgen.rs"), uniffi_bindgen_rs)?; + Ok(()) } -/// Generates Android project structure -fn generate_android_project(output_dir: &Path, project_slug: &str) -> Result<(), BenchError> { +/// Generates Android project structure from templates +/// +/// This function can be called standalone to generate just the Android +/// project scaffolding, useful for auto-generation during build. +/// +/// # Arguments +/// +/// * `output_dir` - Directory to write the `android/` project into +/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile") +pub fn generate_android_project(output_dir: &Path, project_slug: &str) -> Result<(), BenchError> { let target_dir = output_dir.join("android"); let vars = vec![ TemplateVar { @@ -263,8 +299,18 @@ fn generate_android_project(output_dir: &Path, project_slug: &str) -> Result<(), Ok(()) } -/// Generates iOS project structure -fn generate_ios_project( +/// Generates iOS project structure from templates +/// +/// This function can be called standalone to generate just the iOS +/// project scaffolding, useful for auto-generation during build. +/// +/// # Arguments +/// +/// * `output_dir` - Directory to write the `ios/` project into +/// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile") +/// * `project_pascal` - PascalCase version of project name (e.g., "BenchMobile") +/// * `bundle_prefix` - iOS bundle ID prefix (e.g., "dev.world.bench") +pub fn generate_ios_project( output_dir: &Path, project_slug: &str, project_pascal: &str, @@ -454,7 +500,8 @@ fn sanitize_package_name(name: &str) -> String { .replace("--", "-") } -fn to_pascal_case(input: &str) -> String { +/// Converts a string to PascalCase +pub fn to_pascal_case(input: &str) -> String { input .split(|c: char| !c.is_ascii_alphanumeric()) .filter(|s| !s.is_empty()) @@ -467,6 +514,65 @@ fn to_pascal_case(input: &str) -> String { .collect::() } +/// Checks if the Android project scaffolding exists at the given output directory +/// +/// Returns true if the `android/build.gradle` or `android/build.gradle.kts` file exists. +pub fn android_project_exists(output_dir: &Path) -> bool { + let android_dir = output_dir.join("android"); + android_dir.join("build.gradle").exists() || android_dir.join("build.gradle.kts").exists() +} + +/// Checks if the iOS project scaffolding exists at the given output directory +/// +/// Returns true if the `ios/BenchRunner/project.yml` file exists. +pub fn ios_project_exists(output_dir: &Path) -> bool { + output_dir.join("ios/BenchRunner/project.yml").exists() +} + +/// Auto-generates Android project scaffolding from a crate name +/// +/// This is a convenience function that derives template variables from the +/// crate name and generates the Android project structure. +/// +/// # Arguments +/// +/// * `output_dir` - Directory to write the `android/` project into +/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile") +pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> { + if android_project_exists(output_dir) { + return Ok(()); + } + + println!("Android project not found, generating scaffolding..."); + let project_slug = crate_name.replace('-', "_"); + generate_android_project(output_dir, &project_slug)?; + println!(" Generated Android project at {:?}", output_dir.join("android")); + Ok(()) +} + +/// Auto-generates iOS project scaffolding from a crate name +/// +/// This is a convenience function that derives template variables from the +/// crate name and generates the iOS project structure. +/// +/// # Arguments +/// +/// * `output_dir` - Directory to write the `ios/` project into +/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile") +pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> { + if ios_project_exists(output_dir) { + return Ok(()); + } + + println!("iOS project not found, generating scaffolding..."); + let project_slug = crate_name.replace('-', "_"); + let project_pascal = to_pascal_case(&project_slug); + let bundle_prefix = format!("dev.world.{}", project_slug.replace('_', "-")); + generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix)?; + println!(" Generated iOS project at {:?}", output_dir.join("ios")); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 3401136..7418967 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -65,20 +65,34 @@ //! //! | Module | Description | //! |--------|-------------| -//! | [`registry`] | Runtime discovery of `#[benchmark]` functions | -//! | [`runner`] | Benchmark execution and timing infrastructure | -//! | [`builders`] | Android and iOS build automation | -//! | [`codegen`] | Mobile app template generation | +//! | [`timing`] | Core timing infrastructure (always available) | +//! | [`registry`] | Runtime discovery of `#[benchmark]` functions (requires `full` feature) | +//! | [`runner`] | Benchmark execution engine (requires `full` feature) | +//! | [`builders`] | Android and iOS build automation (requires `full` feature) | +//! | [`codegen`] | Mobile app template generation (requires `full` feature) | //! | [`types`] | Common types and error definitions | //! //! ## Crate Ecosystem //! -//! The mobench ecosystem consists of four crates: +//! The mobench ecosystem consists of three crates: //! -//! - **`mobench-sdk`** (this crate) - Core SDK library with build automation +//! - **`mobench-sdk`** (this crate) - Core SDK library with timing harness and build automation //! - **[`mobench`](https://crates.io/crates/mobench)** - CLI tool for building and running benchmarks //! - **[`mobench-macros`](https://crates.io/crates/mobench-macros)** - `#[benchmark]` proc macro -//! - **[`mobench-runner`](https://crates.io/crates/mobench-runner)** - Lightweight timing harness +//! +//! ## Feature Flags +//! +//! | Feature | Default | Description | +//! |---------|---------|-------------| +//! | `full` | Yes | Full SDK with build automation, templates, and registry | +//! | `runner-only` | No | Minimal timing-only mode for mobile binaries | +//! +//! For mobile binaries where binary size matters, use `runner-only`: +//! +//! ```toml +//! [dependencies] +//! mobench-sdk = { version = "0.1", default-features = false, features = ["runner-only"] } +//! ``` //! //! ## Programmatic Usage //! @@ -255,35 +269,43 @@ //! - **Iterations**: 50-100 for stable statistics //! - Mobile devices may have more variance than desktop //! -//! ## Feature Flags -//! -//! Currently, `mobench-sdk` has no optional feature flags. All functionality -//! is included by default. -//! //! ## License //! //! MIT License - see repository for details. -// Public modules +// Core timing module - always available +pub mod timing; +pub mod types; + +// Full SDK modules - only with "full" feature +#[cfg(feature = "full")] pub mod builders; +#[cfg(feature = "full")] pub mod codegen; +#[cfg(feature = "full")] pub mod registry; +#[cfg(feature = "full")] pub mod runner; -pub mod types; -// Re-export the benchmark macro from bench-macros +// Re-export the benchmark macro from bench-macros (only with full feature) +#[cfg(feature = "full")] pub use mobench_macros::benchmark; -// Re-export key types for convenience +// Re-export key types for convenience (full feature) +#[cfg(feature = "full")] pub use registry::{BenchFunction, discover_benchmarks, find_benchmark, list_benchmark_names}; +#[cfg(feature = "full")] pub use runner::{BenchmarkBuilder, run_benchmark}; -pub use types::{ - BenchError, BenchSample, BenchSpec, BuildConfig, BuildProfile, BuildResult, InitConfig, - RunnerReport, Target, -}; -// Re-export mobench-runner types for backward compatibility -pub use mobench_runner; +// Re-export types that are always available +pub use types::{BenchError, BenchSample, BenchSpec, RunnerReport}; + +// Re-export types that require full feature +#[cfg(feature = "full")] +pub use types::{BuildConfig, BuildProfile, BuildResult, InitConfig, Target}; + +// Re-export timing types at the crate root for convenience +pub use timing::{run_closure, TimingError}; /// Library version, matching `Cargo.toml`. /// @@ -303,6 +325,7 @@ mod tests { assert!(!VERSION.is_empty()); } + #[cfg(feature = "full")] #[test] fn test_discover_benchmarks_compiles() { // This test just ensures the function is accessible diff --git a/crates/mobench-sdk/src/runner.rs b/crates/mobench-sdk/src/runner.rs index 3c43d20..ae4763b 100644 --- a/crates/mobench-sdk/src/runner.rs +++ b/crates/mobench-sdk/src/runner.rs @@ -4,8 +4,8 @@ //! and collects timing data. use crate::registry::find_benchmark; +use crate::timing::{run_closure, TimingError}; use crate::types::{BenchError, BenchSpec, RunnerReport}; -use mobench_runner::run_closure; /// Runs a benchmark by name /// @@ -42,9 +42,9 @@ pub fn run_benchmark(spec: BenchSpec) -> Result { // Create a closure that invokes the registered function let closure = - || (bench_fn.invoke)(&[]).map_err(|e| mobench_runner::BenchError::Execution(e.to_string())); + || (bench_fn.invoke)(&[]).map_err(|e| TimingError::Execution(e.to_string())); - // Run the benchmark using mobench-runner's timing infrastructure + // Run the benchmark using the timing infrastructure let report = run_closure(spec, closure)?; Ok(report) diff --git a/crates/mobench-runner/src/lib.rs b/crates/mobench-sdk/src/timing.rs similarity index 80% rename from crates/mobench-runner/src/lib.rs rename to crates/mobench-sdk/src/timing.rs index a931c15..f07fa44 100644 --- a/crates/mobench-runner/src/lib.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -1,25 +1,23 @@ -//! # mobench-runner +//! Lightweight benchmarking harness for mobile platforms. //! -//! A lightweight benchmarking harness designed for mobile platforms. -//! -//! This crate provides the core timing infrastructure for the mobench ecosystem. +//! This module provides the core timing infrastructure for the mobench ecosystem. //! It's designed to be minimal and portable, with no platform-specific dependencies, //! making it suitable for compilation to Android and iOS targets. //! //! ## Overview //! -//! The runner executes benchmark functions with: +//! The timing module executes benchmark functions with: //! - Configurable warmup iterations //! - Precise nanosecond-resolution timing //! - Simple, serializable results //! //! ## Usage //! -//! Most users should use this crate via [`mobench-sdk`](https://crates.io/crates/mobench-sdk). -//! Direct usage is typically only needed for custom integrations: +//! Most users should use this via the higher-level [`crate::run_benchmark`] function +//! or [`crate::BenchmarkBuilder`]. Direct usage is for custom integrations: //! //! ``` -//! use mobench_runner::{BenchSpec, run_closure, BenchError}; +//! use mobench_sdk::timing::{BenchSpec, run_closure, TimingError}; //! //! // Define a benchmark specification //! let spec = BenchSpec::new("my_benchmark", 100, 10)?; @@ -38,7 +36,7 @@ //! .sum::() / report.samples.len() as u64; //! //! println!("Mean: {} ns", mean_ns); -//! # Ok::<(), BenchError>(()) +//! # Ok::<(), TimingError>(()) //! ``` //! //! ## Types @@ -48,16 +46,18 @@ //! | [`BenchSpec`] | Benchmark configuration (name, iterations, warmup) | //! | [`BenchSample`] | Single timing measurement in nanoseconds | //! | [`BenchReport`] | Complete results with all samples | -//! | [`BenchError`] | Error conditions during benchmarking | +//! | [`TimingError`] | Error conditions during benchmarking | //! -//! ## Crate Ecosystem +//! ## Feature Flags //! -//! This crate is part of the mobench ecosystem: +//! This module is always available. When using `mobench-sdk` with default features, +//! you also get build automation and template generation. For minimal binary size +//! (e.g., on mobile targets), use the `runner-only` feature: //! -//! - **[`mobench-sdk`](https://crates.io/crates/mobench-sdk)** - Core SDK with build automation -//! - **[`mobench`](https://crates.io/crates/mobench)** - CLI tool -//! - **[`mobench-macros`](https://crates.io/crates/mobench-macros)** - `#[benchmark]` proc macro -//! - **`mobench-runner`** (this crate) - Timing harness +//! ```toml +//! [dependencies] +//! mobench-sdk = { version = "0.1", default-features = false, features = ["runner-only"] } +//! ``` use serde::{Deserialize, Serialize}; use std::time::{Duration, Instant}; @@ -71,7 +71,7 @@ use thiserror::Error; /// # Example /// /// ``` -/// use mobench_runner::BenchSpec; +/// use mobench_sdk::timing::BenchSpec; /// /// // Create a spec for 100 iterations with 10 warmup runs /// let spec = BenchSpec::new("sorting_benchmark", 100, 10)?; @@ -79,7 +79,7 @@ use thiserror::Error; /// assert_eq!(spec.name, "sorting_benchmark"); /// assert_eq!(spec.iterations, 100); /// assert_eq!(spec.warmup, 10); -/// # Ok::<(), mobench_runner::BenchError>(()) +/// # Ok::<(), mobench_sdk::timing::TimingError>(()) /// ``` /// /// # Serialization @@ -87,7 +87,7 @@ use thiserror::Error; /// `BenchSpec` implements `Serialize` and `Deserialize` for JSON persistence: /// /// ``` -/// use mobench_runner::BenchSpec; +/// use mobench_sdk::timing::BenchSpec; /// /// let spec = BenchSpec { /// name: "my_bench".to_string(), @@ -131,12 +131,12 @@ impl BenchSpec { /// /// # Errors /// - /// Returns [`BenchError::NoIterations`] if `iterations` is zero. + /// Returns [`TimingError::NoIterations`] if `iterations` is zero. /// /// # Example /// /// ``` - /// use mobench_runner::BenchSpec; + /// use mobench_sdk::timing::BenchSpec; /// /// let spec = BenchSpec::new("test", 100, 10)?; /// assert_eq!(spec.iterations, 100); @@ -144,11 +144,11 @@ impl BenchSpec { /// // Zero iterations is an error /// let err = BenchSpec::new("test", 0, 10); /// assert!(err.is_err()); - /// # Ok::<(), mobench_runner::BenchError>(()) + /// # Ok::<(), mobench_sdk::timing::TimingError>(()) /// ``` - pub fn new(name: impl Into, iterations: u32, warmup: u32) -> Result { + pub fn new(name: impl Into, iterations: u32, warmup: u32) -> Result { if iterations == 0 { - return Err(BenchError::NoIterations); + return Err(TimingError::NoIterations); } Ok(Self { @@ -167,7 +167,7 @@ impl BenchSpec { /// # Example /// /// ``` -/// use mobench_runner::BenchSample; +/// use mobench_sdk::timing::BenchSample; /// /// let sample = BenchSample { duration_ns: 1_500_000 }; /// @@ -200,7 +200,7 @@ impl BenchSample { /// # Example /// /// ``` -/// use mobench_runner::{BenchSpec, run_closure}; +/// use mobench_sdk::timing::{BenchSpec, run_closure}; /// /// let spec = BenchSpec::new("example", 50, 5)?; /// let report = run_closure(spec, || { @@ -218,7 +218,7 @@ impl BenchSample { /// let mean = samples.iter().sum::() / samples.len() as u64; /// /// println!("Min: {} ns, Max: {} ns, Mean: {} ns", min, max, mean); -/// # Ok::<(), mobench_runner::BenchError>(()) +/// # Ok::<(), mobench_sdk::timing::TimingError>(()) /// ``` #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BenchReport { @@ -236,14 +236,14 @@ pub struct BenchReport { /// # Example /// /// ``` -/// use mobench_runner::{BenchSpec, BenchError}; +/// use mobench_sdk::timing::{BenchSpec, TimingError}; /// /// // Zero iterations produces an error /// let result = BenchSpec::new("test", 0, 10); -/// assert!(matches!(result, Err(BenchError::NoIterations))); +/// assert!(matches!(result, Err(TimingError::NoIterations))); /// ``` #[derive(Debug, Error)] -pub enum BenchError { +pub enum TimingError { /// The iteration count was zero. /// /// At least one iteration is required to produce a measurement. @@ -268,17 +268,17 @@ pub enum BenchError { /// # Arguments /// /// * `spec` - Benchmark configuration specifying iterations and warmup -/// * `f` - Closure to benchmark; must return `Result<(), BenchError>` +/// * `f` - Closure to benchmark; must return `Result<(), TimingError>` /// /// # Returns /// -/// A [`BenchReport`] containing all timing samples, or a [`BenchError`] if +/// A [`BenchReport`] containing all timing samples, or a [`TimingError`] if /// the benchmark fails. /// /// # Example /// /// ``` -/// use mobench_runner::{BenchSpec, run_closure, BenchError}; +/// use mobench_sdk::timing::{BenchSpec, run_closure, TimingError}; /// /// let spec = BenchSpec::new("sum_benchmark", 100, 10)?; /// @@ -294,7 +294,7 @@ pub enum BenchError { /// let total_ns: u64 = report.samples.iter().map(|s| s.duration_ns).sum(); /// let mean_ns = total_ns / report.samples.len() as u64; /// println!("Mean: {} ns", mean_ns); -/// # Ok::<(), BenchError>(()) +/// # Ok::<(), TimingError>(()) /// ``` /// /// # Error Handling @@ -302,28 +302,28 @@ pub enum BenchError { /// If the closure returns an error, the benchmark stops immediately: /// /// ``` -/// use mobench_runner::{BenchSpec, run_closure, BenchError}; +/// use mobench_sdk::timing::{BenchSpec, run_closure, TimingError}; /// /// let spec = BenchSpec::new("failing_bench", 100, 0)?; /// /// let result = run_closure(spec, || { -/// Err(BenchError::Execution("simulated failure".into())) +/// Err(TimingError::Execution("simulated failure".into())) /// }); /// /// assert!(result.is_err()); -/// # Ok::<(), BenchError>(()) +/// # Ok::<(), TimingError>(()) /// ``` /// /// # Timing Precision /// /// Uses [`std::time::Instant`] for timing, which provides monotonic, /// nanosecond-resolution measurements on most platforms. -pub fn run_closure(spec: BenchSpec, mut f: F) -> Result +pub fn run_closure(spec: BenchSpec, mut f: F) -> Result where - F: FnMut() -> Result<(), BenchError>, + F: FnMut() -> Result<(), TimingError>, { if spec.iterations == 0 { - return Err(BenchError::NoIterations); + return Err(TimingError::NoIterations); } // Warmup phase - not measured @@ -359,7 +359,7 @@ mod tests { #[test] fn rejects_zero_iterations() { let result = BenchSpec::new("test", 0, 10); - assert!(matches!(result, Err(BenchError::NoIterations))); + assert!(matches!(result, Err(TimingError::NoIterations))); } #[test] diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs index a5fc5f4..7854d03 100644 --- a/crates/mobench-sdk/src/types.rs +++ b/crates/mobench-sdk/src/types.rs @@ -8,17 +8,17 @@ //! - [`BuildResult`] - Output from build operations //! - [`InitConfig`] - Project initialization settings //! -//! ## Re-exports from mobench-runner +//! ## Re-exports from timing module //! -//! For convenience, this module also re-exports types from [`mobench_runner`]: +//! For convenience, this module also re-exports types from [`crate::timing`]: //! //! - [`BenchSpec`] - Benchmark specification (name, iterations, warmup) //! - [`BenchSample`] - Single timing measurement //! - [`RunnerReport`] - Complete benchmark results -// Re-export mobench-runner types for convenience -pub use mobench_runner::{ - BenchError as RunnerError, BenchReport as RunnerReport, BenchSample, BenchSpec, +// Re-export timing types for convenience +pub use crate::timing::{ + BenchReport as RunnerReport, BenchSample, BenchSpec, TimingError as RunnerError, }; use std::path::PathBuf; @@ -51,44 +51,46 @@ use std::path::PathBuf; pub enum BenchError { /// Error from the underlying benchmark runner. /// - /// This wraps errors from [`mobench_runner::BenchError`], such as + /// This wraps errors from [`crate::timing::TimingError`], such as /// zero iterations or execution failures. - #[error("runner error: {0}")] - Runner(#[from] mobench_runner::BenchError), + #[error("benchmark runner error: {0}")] + Runner(#[from] crate::timing::TimingError), /// The requested benchmark function was not found in the registry. /// /// This occurs when calling [`run_benchmark`](crate::run_benchmark) with /// a function name that hasn't been registered via `#[benchmark]`. - #[error("unknown benchmark function: {0}")] + #[error( + "unknown benchmark function: {0}. Ensure it is annotated with #[benchmark] and the crate is linked into the bench-mobile build" + )] UnknownFunction(String), /// An error occurred during benchmark execution. /// /// This is a catch-all for execution-time errors that don't fit /// other categories. - #[error("execution error: {0}")] + #[error("benchmark execution failed: {0}")] Execution(String), /// An I/O error occurred. /// /// Common causes include missing files, permission issues, or /// disk space problems during build operations. - #[error("I/O error: {0}")] + #[error("I/O error: {0}. Check file paths and permissions")] Io(#[from] std::io::Error), /// JSON serialization or deserialization failed. /// /// This can occur when reading/writing benchmark specifications /// or configuration files. - #[error("serialization error: {0}")] + #[error("serialization error: {0}. Ensure the input is valid JSON")] Serialization(#[from] serde_json::Error), /// A configuration error occurred. /// /// This indicates invalid or missing configuration, such as /// malformed TOML files or missing required fields. - #[error("configuration error: {0}")] + #[error("configuration error: {0}. Check mobench.toml or CLI flags")] Config(String), /// A build error occurred. diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index 374da28..ca577dc 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -53,12 +53,9 @@ class MainActivity : AppCompatActivity() { } logBenchReport(report) formatBenchReport(report) - } catch (e: BenchException.InvalidIterations) { - "Error: ${e.message}" - } catch (e: BenchException.UnknownFunction) { - "Error: ${e.message}" - } catch (e: BenchException.ExecutionFailed) { - "Error: ${e.message}" + } catch (e: BenchException) { + // Generic handler for all benchmark errors (InvalidIterations, UnknownFunction, etc.) + "Benchmark error: ${e.message}" } catch (e: Exception) { "Unexpected error: ${e.message}" } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index fcebaa5..e52509d 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -106,13 +106,8 @@ enum {{PROJECT_NAME_PASCAL}}FFI { } private static func formatBenchError(_ error: BenchError) -> String { - switch error { - case .InvalidIterations(let message): - return "Error (InvalidIterations): \(message)" - case .UnknownFunction(let message): - return "Error (UnknownFunction): \(message)" - case .ExecutionFailed(let message): - return "Error (ExecutionFailed): \(message)" - } + // Generic error formatting - works with any BenchError variant + // This avoids hardcoding specific error cases that may not exist in all schemas + return "Benchmark error: \(error.localizedDescription)" } } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index f433e71..d557878 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -35,7 +35,6 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true mobench-sdk = { version = "0.1.8", path = "../mobench-sdk" } -mobench-runner = { version = "0.1.8", path = "../mobench-runner" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 8d07451..26feb2e 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -517,9 +517,8 @@ Ensure: This CLI is part of the mobench ecosystem: - **[mobench](https://crates.io/crates/mobench)** - This crate (CLI tool) -- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - SDK library +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - SDK library with timing harness and build automation - **[mobench-macros](https://crates.io/crates/mobench-macros)** - Proc macros -- **[mobench-runner](https://crates.io/crates/mobench-runner)** - Timing harness ## See Also diff --git a/crates/mobench/src/config.rs b/crates/mobench/src/config.rs new file mode 100644 index 0000000..4edb71b --- /dev/null +++ b/crates/mobench/src/config.rs @@ -0,0 +1,630 @@ +//! Configuration file support for mobench. +//! +//! This module provides support for `mobench.toml` configuration files that allow +//! users to persist project settings and avoid passing CLI flags repeatedly. +//! +//! ## Configuration File Location +//! +//! The configuration file is searched for in the following order: +//! 1. Current working directory (`./mobench.toml`) +//! 2. Parent directories (up to the repository root or filesystem root) +//! +//! ## Example Configuration +//! +//! ```toml +//! [project] +//! crate = "bench-mobile" +//! library_name = "bench_mobile" +//! +//! [android] +//! package = "com.example.bench" +//! min_sdk = 24 +//! target_sdk = 34 +//! +//! [ios] +//! bundle_id = "com.example.bench" +//! deployment_target = "15.0" +//! +//! [benchmarks] +//! default_function = "my_crate::my_benchmark" +//! default_iterations = 100 +//! default_warmup = 10 +//! ``` + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// The default configuration file name. +pub const CONFIG_FILE_NAME: &str = "mobench.toml"; + +/// Root configuration structure for `mobench.toml`. +/// +/// This struct represents the complete configuration file format and is +/// automatically loaded when CLI commands run. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct MobenchConfig { + /// Project-level configuration. + pub project: ProjectConfig, + + /// Android-specific configuration. + pub android: AndroidConfig, + + /// iOS-specific configuration. + pub ios: IosConfig, + + /// Benchmark execution defaults. + pub benchmarks: BenchmarksConfig, +} + +/// Project-level configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ProjectConfig { + /// Name of the benchmark crate (e.g., "bench-mobile"). + /// + /// If not specified, mobench will auto-detect the crate by looking for + /// `bench-mobile/` or `crates/sample-fns/` directories. + #[serde(rename = "crate")] + pub crate_name: Option, + + /// Library name for the Rust crate (e.g., "bench_mobile"). + /// + /// This is typically the crate name with hyphens replaced by underscores. + /// If not specified, it's derived from the crate name. + pub library_name: Option, + + /// Output directory for build artifacts. + /// + /// Defaults to `target/mobench/` if not specified. + pub output_dir: Option, +} + +/// Android-specific configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AndroidConfig { + /// Android package name (e.g., "com.example.bench"). + /// + /// Defaults to "dev.world.bench" if not specified. + pub package: String, + + /// Minimum Android SDK version. + /// + /// Defaults to 24 (Android 7.0). + pub min_sdk: u32, + + /// Target Android SDK version. + /// + /// Defaults to 34 (Android 14). + pub target_sdk: u32, + + /// Android ABIs to build for. + /// + /// Defaults to ["arm64-v8a", "armeabi-v7a", "x86_64"]. + pub abis: Option>, +} + +impl Default for AndroidConfig { + fn default() -> Self { + Self { + package: "dev.world.bench".to_string(), + min_sdk: 24, + target_sdk: 34, + abis: None, + } + } +} + +/// iOS-specific configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct IosConfig { + /// iOS bundle identifier (e.g., "com.example.bench"). + /// + /// Defaults to "dev.world.bench" if not specified. + pub bundle_id: String, + + /// iOS deployment target version. + /// + /// Defaults to "15.0". + pub deployment_target: String, + + /// Development team ID for code signing. + /// + /// If not specified, ad-hoc signing is used. + pub team_id: Option, +} + +impl Default for IosConfig { + fn default() -> Self { + Self { + bundle_id: "dev.world.bench".to_string(), + deployment_target: "15.0".to_string(), + team_id: None, + } + } +} + +/// Benchmark execution defaults. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct BenchmarksConfig { + /// Default benchmark function to run. + /// + /// Can be overridden via CLI `--function` flag. + pub default_function: Option, + + /// Default number of benchmark iterations. + /// + /// Defaults to 100. Can be overridden via CLI `--iterations` flag. + pub default_iterations: u32, + + /// Default number of warmup iterations. + /// + /// Defaults to 10. Can be overridden via CLI `--warmup` flag. + pub default_warmup: u32, +} + +impl Default for BenchmarksConfig { + fn default() -> Self { + Self { + default_function: None, + default_iterations: 100, + default_warmup: 10, + } + } +} + +impl MobenchConfig { + /// Creates a new configuration with default values. + pub fn new() -> Self { + Self::default() + } + + /// Loads configuration from the specified file path. + /// + /// # Arguments + /// + /// * `path` - Path to the configuration file + /// + /// # Returns + /// + /// * `Ok(MobenchConfig)` - Successfully loaded configuration + /// * `Err` - If the file cannot be read or parsed + pub fn load_from_file(path: &Path) -> Result { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {:?}", path))?; + + let config: MobenchConfig = toml::from_str(&contents) + .with_context(|| format!("Failed to parse config file: {:?}", path))?; + + Ok(config) + } + + /// Attempts to find and load configuration from the current directory + /// or any parent directory. + /// + /// This searches for `mobench.toml` starting from the current directory + /// and walking up the directory tree until a config file is found or + /// the root is reached. + /// + /// # Returns + /// + /// * `Ok(Some((config, path)))` - Found and loaded configuration with its path + /// * `Ok(None)` - No configuration file found + /// * `Err` - If a config file was found but couldn't be parsed + pub fn discover() -> Result> { + let cwd = std::env::current_dir().context("Failed to get current directory")?; + Self::discover_from(&cwd) + } + + /// Attempts to find and load configuration starting from the specified directory. + /// + /// # Arguments + /// + /// * `start_dir` - Directory to start searching from + /// + /// # Returns + /// + /// * `Ok(Some((config, path)))` - Found and loaded configuration with its path + /// * `Ok(None)` - No configuration file found + /// * `Err` - If a config file was found but couldn't be parsed + pub fn discover_from(start_dir: &Path) -> Result> { + let mut current = start_dir.to_path_buf(); + + loop { + let config_path = current.join(CONFIG_FILE_NAME); + + if config_path.is_file() { + let config = Self::load_from_file(&config_path)?; + return Ok(Some((config, config_path))); + } + + // Stop at repository root or filesystem root + if current.join(".git").exists() || !current.pop() { + break; + } + } + + Ok(None) + } + + /// Saves the configuration to the specified file path. + /// + /// # Arguments + /// + /// * `path` - Path to write the configuration file + /// + /// # Returns + /// + /// * `Ok(())` - Successfully saved configuration + /// * `Err` - If the file cannot be written + pub fn save_to_file(&self, path: &Path) -> Result<()> { + let contents = + toml::to_string_pretty(self).context("Failed to serialize configuration")?; + + std::fs::write(path, contents) + .with_context(|| format!("Failed to write config file: {:?}", path))?; + + Ok(()) + } + + /// Returns the library name, either from config or derived from crate name. + pub fn library_name(&self) -> Option { + self.project + .library_name + .clone() + .or_else(|| self.project.crate_name.as_ref().map(|c| c.replace('-', "_"))) + } + + /// Generates a starter configuration with sensible defaults. + /// + /// # Arguments + /// + /// * `crate_name` - Name of the benchmark crate + /// + /// # Returns + /// + /// A new `MobenchConfig` with the provided crate name and default values. + pub fn starter(crate_name: &str) -> Self { + let library_name = crate_name.replace('-', "_"); + let package = format!("dev.world.{}", library_name.replace('_', "")); + + Self { + project: ProjectConfig { + crate_name: Some(crate_name.to_string()), + library_name: Some(library_name.clone()), + output_dir: None, // Use default (target/mobench/) + }, + android: AndroidConfig { + package: package.clone(), + min_sdk: 24, + target_sdk: 34, + abis: None, + }, + ios: IosConfig { + bundle_id: package, + deployment_target: "15.0".to_string(), + team_id: None, + }, + benchmarks: BenchmarksConfig { + default_function: Some(format!("{}::my_benchmark", library_name)), + default_iterations: 100, + default_warmup: 10, + }, + } + } + + /// Generates a starter configuration file as a formatted TOML string. + /// + /// This includes helpful comments explaining each configuration option. + /// + /// # Arguments + /// + /// * `crate_name` - Name of the benchmark crate + /// + /// # Returns + /// + /// A formatted TOML string suitable for writing to `mobench.toml`. + pub fn generate_starter_toml(crate_name: &str) -> String { + let library_name = crate_name.replace('-', "_"); + let package = format!("dev.world.{}", library_name.replace('_', "")); + + format!( + r#"# mobench configuration file +# This file configures mobench for building and running mobile benchmarks. +# CLI flags override these settings when provided. + +[project] +# Name of the benchmark crate +crate = "{crate_name}" + +# Rust library name (typically crate name with hyphens replaced by underscores) +library_name = "{library_name}" + +# Output directory for build artifacts (default: target/mobench/) +# output_dir = "target/mobench" + +[android] +# Android package name +package = "{package}" + +# Minimum Android SDK version (default: 24 / Android 7.0) +min_sdk = 24 + +# Target Android SDK version (default: 34 / Android 14) +target_sdk = 34 + +# Android ABIs to build for (optional, defaults to all supported ABIs) +# abis = ["arm64-v8a", "armeabi-v7a", "x86_64"] + +[ios] +# iOS bundle identifier +bundle_id = "{package}" + +# iOS deployment target version (default: 15.0) +deployment_target = "15.0" + +# Development team ID for code signing (optional, uses ad-hoc signing if not set) +# team_id = "YOUR_TEAM_ID" + +[benchmarks] +# Default benchmark function to run +default_function = "{library_name}::my_benchmark" + +# Default number of benchmark iterations (can be overridden with --iterations) +default_iterations = 100 + +# Default number of warmup iterations (can be overridden with --warmup) +default_warmup = 10 +"#, + crate_name = crate_name, + library_name = library_name, + package = package, + ) + } +} + +/// Configuration resolver that merges config file values with CLI arguments. +/// +/// CLI arguments always take precedence over config file values. +#[derive(Debug, Default)] +pub struct ConfigResolver { + /// Loaded configuration, if any. + pub config: Option, + + /// Path to the loaded config file, if any. + pub config_path: Option, +} + +impl ConfigResolver { + /// Creates a new resolver by discovering and loading configuration. + /// + /// If no config file is found, the resolver will use default values + /// which can be overridden by CLI arguments. + pub fn new() -> Result { + match MobenchConfig::discover()? { + Some((config, path)) => Ok(Self { + config: Some(config), + config_path: Some(path), + }), + None => Ok(Self { + config: None, + config_path: None, + }), + } + } + + /// Returns the crate name from config, or None if not configured. + pub fn crate_name(&self) -> Option<&str> { + self.config + .as_ref() + .and_then(|c| c.project.crate_name.as_deref()) + } + + /// Returns the library name from config, derived from crate name if needed. + pub fn library_name(&self) -> Option { + self.config.as_ref().and_then(|c| c.library_name()) + } + + /// Returns the output directory from config. + pub fn output_dir(&self) -> Option<&Path> { + self.config + .as_ref() + .and_then(|c| c.project.output_dir.as_deref()) + } + + /// Returns the default function from config. + pub fn default_function(&self) -> Option<&str> { + self.config + .as_ref() + .and_then(|c| c.benchmarks.default_function.as_deref()) + } + + /// Returns the default iterations from config. + pub fn default_iterations(&self) -> u32 { + self.config + .as_ref() + .map(|c| c.benchmarks.default_iterations) + .unwrap_or(100) + } + + /// Returns the default warmup from config. + pub fn default_warmup(&self) -> u32 { + self.config + .as_ref() + .map(|c| c.benchmarks.default_warmup) + .unwrap_or(10) + } + + /// Returns the Android configuration. + pub fn android(&self) -> AndroidConfig { + self.config + .as_ref() + .map(|c| c.android.clone()) + .unwrap_or_default() + } + + /// Returns the iOS configuration. + pub fn ios(&self) -> IosConfig { + self.config + .as_ref() + .map(|c| c.ios.clone()) + .unwrap_or_default() + } + + /// Resolves a CLI value, using config as fallback. + /// + /// # Arguments + /// + /// * `cli_value` - Value from CLI argument (None if not provided) + /// * `config_getter` - Function to get value from config + /// * `default` - Default value if neither CLI nor config provides a value + /// + /// # Returns + /// + /// The resolved value, preferring CLI over config over default. + pub fn resolve(&self, cli_value: Option, config_getter: F, default: T) -> T + where + F: FnOnce(&MobenchConfig) -> Option, + { + cli_value + .or_else(|| self.config.as_ref().and_then(config_getter)) + .unwrap_or(default) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + #[test] + fn test_default_config() { + let config = MobenchConfig::default(); + assert_eq!(config.android.min_sdk, 24); + assert_eq!(config.android.target_sdk, 34); + assert_eq!(config.ios.deployment_target, "15.0"); + assert_eq!(config.benchmarks.default_iterations, 100); + assert_eq!(config.benchmarks.default_warmup, 10); + } + + #[test] + fn test_starter_config() { + let config = MobenchConfig::starter("my-bench"); + assert_eq!(config.project.crate_name, Some("my-bench".to_string())); + assert_eq!(config.project.library_name, Some("my_bench".to_string())); + assert_eq!(config.android.package, "dev.world.mybench"); + assert_eq!(config.ios.bundle_id, "dev.world.mybench"); + } + + #[test] + fn test_load_from_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("mobench.toml"); + + let toml_content = r#" +[project] +crate = "test-bench" +library_name = "test_bench" + +[android] +package = "com.test.bench" +min_sdk = 21 +target_sdk = 33 + +[ios] +bundle_id = "com.test.bench" +deployment_target = "14.0" + +[benchmarks] +default_function = "test_bench::test_fn" +default_iterations = 50 +default_warmup = 5 +"#; + + let mut file = std::fs::File::create(&config_path).unwrap(); + file.write_all(toml_content.as_bytes()).unwrap(); + + let config = MobenchConfig::load_from_file(&config_path).unwrap(); + + assert_eq!(config.project.crate_name, Some("test-bench".to_string())); + assert_eq!(config.project.library_name, Some("test_bench".to_string())); + assert_eq!(config.android.package, "com.test.bench"); + assert_eq!(config.android.min_sdk, 21); + assert_eq!(config.android.target_sdk, 33); + assert_eq!(config.ios.bundle_id, "com.test.bench"); + assert_eq!(config.ios.deployment_target, "14.0"); + assert_eq!( + config.benchmarks.default_function, + Some("test_bench::test_fn".to_string()) + ); + assert_eq!(config.benchmarks.default_iterations, 50); + assert_eq!(config.benchmarks.default_warmup, 5); + } + + #[test] + fn test_discover_config() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("mobench.toml"); + + let toml_content = r#" +[project] +crate = "discovered-bench" +"#; + + std::fs::write(&config_path, toml_content).unwrap(); + + let result = MobenchConfig::discover_from(temp_dir.path()).unwrap(); + assert!(result.is_some()); + + let (config, path) = result.unwrap(); + assert_eq!( + config.project.crate_name, + Some("discovered-bench".to_string()) + ); + assert_eq!(path, config_path); + } + + #[test] + fn test_discover_no_config() { + let temp_dir = TempDir::new().unwrap(); + // Create a .git directory to stop the search + std::fs::create_dir(temp_dir.path().join(".git")).unwrap(); + + let result = MobenchConfig::discover_from(temp_dir.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_config_resolver() { + let config = MobenchConfig::starter("test-crate"); + let resolver = ConfigResolver { + config: Some(config), + config_path: None, + }; + + // CLI value takes precedence + let result = resolver.resolve(Some(200), |c| Some(c.benchmarks.default_iterations), 50); + assert_eq!(result, 200); + + // Config value used when CLI is None + let result: u32 = resolver.resolve(None, |c| Some(c.benchmarks.default_iterations), 50); + assert_eq!(result, 100); + } + + #[test] + fn test_generate_starter_toml() { + let toml = MobenchConfig::generate_starter_toml("my-bench"); + assert!(toml.contains("crate = \"my-bench\"")); + assert!(toml.contains("library_name = \"my_bench\"")); + assert!(toml.contains("min_sdk = 24")); + assert!(toml.contains("target_sdk = 34")); + assert!(toml.contains("deployment_target = \"15.0\"")); + assert!(toml.contains("default_iterations = 100")); + assert!(toml.contains("default_warmup = 10")); + } +} diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index f76ea3a..d89cc2d 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -91,15 +91,14 @@ //! This crate is part of the mobench ecosystem: //! //! - **`mobench`** (this crate) - CLI tool -//! - **[`mobench-sdk`](https://crates.io/crates/mobench-sdk)** - Core SDK with build automation +//! - **[`mobench-sdk`](https://crates.io/crates/mobench-sdk)** - Core SDK with timing harness and build automation //! - **[`mobench-macros`](https://crates.io/crates/mobench-macros)** - `#[benchmark]` proc macro -//! - **[`mobench-runner`](https://crates.io/crates/mobench-runner)** - Timing harness use anyhow::{Context, Result, anyhow, bail}; use clap::{Parser, Subcommand, ValueEnum}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fmt::Write; use std::fs; @@ -111,11 +110,20 @@ use time::format_description::well_known::Rfc3339; use browserstack::{BrowserStackAuth, BrowserStackClient}; mod browserstack; +pub mod config; /// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. #[derive(Parser, Debug)] #[command(name = "mobench", author, version, about = "Mobile Rust benchmarking orchestrator", long_about = None)] struct Cli { + /// Print what would be done without actually doing it + #[arg(long, global = true)] + dry_run: bool, + + /// Print verbose output including all commands + #[arg(long, short = 'v', global = true)] + verbose: bool, + #[command(subcommand)] command: Command, } @@ -213,6 +221,8 @@ enum Command { release: bool, #[arg(long, help = "Output directory for mobile artifacts (default: target/mobench)")] output_dir: Option, + #[arg(long, help = "Path to the benchmark crate (default: auto-detect bench-mobile/ or crates/{crate})")] + crate_path: Option, }, /// Package iOS app as IPA for distribution or testing. PackageIpa { @@ -457,8 +467,9 @@ pub fn run() -> Result<()> { } else { match spec.target { MobileTarget::Android => { - let ndk = std::env::var("ANDROID_NDK_HOME") - .context("ANDROID_NDK_HOME must be set for Android builds")?; + let ndk = std::env::var("ANDROID_NDK_HOME").context( + "ANDROID_NDK_HOME must be set for Android builds. Example: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/", + )?; let build = run_android_build(&ndk)?; let apk = build.app_path; println!("Built Android APK at {:?}", apk); @@ -467,7 +478,7 @@ pub fn run() -> Result<()> { Some(MobileArtifacts::Android { apk }) } else { let test_apk = build.test_suite_path.as_ref().context( - "Android test suite APK missing; run ./gradlew assembleDebugAndroidTest", + "Android test suite APK missing. Run `cargo mobench build --target android` or `./gradlew assembleDebugAndroidTest` in target/mobench/android", )?; let run = trigger_browserstack_espresso(&spec, &apk, test_apk)?; remote_run = Some(run); @@ -676,8 +687,9 @@ pub fn run() -> Result<()> { target, release, output_dir, + crate_path, } => { - cmd_build(target, release, output_dir)?; + cmd_build(target, release, output_dir, crate_path, cli.dry_run, cli.verbose)?; } Command::PackageIpa { scheme, method } => { cmd_package_ipa(&scheme, method)?; @@ -1005,13 +1017,13 @@ fn resolve_run_spec( } if function.trim().is_empty() { - bail!("function must not be empty"); + bail!("function must not be empty; pass --function or set function in the config file"); } let ios_xcuitest = match (ios_app, ios_test_suite) { (Some(app), Some(test_suite)) => Some(IosXcuitestArtifacts { app, test_suite }), (None, None) => None, - _ => bail!("both --ios-app and --ios-test-suite must be provided together"), + _ => bail!("both --ios-app and --ios-test-suite must be provided together; omit both to let mobench package iOS artifacts when running against devices"), }; let ios_xcuitest = if target == MobileTarget::Ios @@ -1058,10 +1070,17 @@ fn filter_devices_by_tags(devices: Vec, tags: &[String]) -> Result< } let mut matched = Vec::new(); + let mut available_tags = BTreeSet::new(); for device in devices { let Some(device_tags) = device.tags.as_ref() else { continue; }; + for tag in device_tags { + let normalized = tag.trim().to_lowercase(); + if !normalized.is_empty() { + available_tags.insert(normalized); + } + } let has_match = device_tags.iter().any(|tag| { let candidate = tag.trim().to_lowercase(); wanted.iter().any(|wanted_tag| wanted_tag == &candidate) @@ -1072,9 +1091,17 @@ fn filter_devices_by_tags(devices: Vec, tags: &[String]) -> Result< } if matched.is_empty() { + if available_tags.is_empty() { + bail!( + "no devices matched tags [{}] in device matrix; no tag metadata found in the matrix", + wanted.join(", ") + ); + } + let available = available_tags.into_iter().collect::>().join(", "); bail!( - "no devices matched tags [{}] in device matrix", - wanted.join(", ") + "no devices matched tags [{}] in device matrix. Available tags: {}", + wanted.join(", "), + available ); } Ok(matched) @@ -1281,7 +1308,7 @@ fn run_local_smoke(spec: &RunSpec) -> Result { }; let report = - mobench_sdk::run_benchmark(bench_spec).map_err(|e| anyhow!("benchmark failed: {:?}", e))?; + mobench_sdk::run_benchmark(bench_spec).map_err(|e| anyhow!("benchmark failed: {e}"))?; serde_json::to_value(&report).context("serializing benchmark report") } @@ -1849,37 +1876,79 @@ fn cmd_init_sdk( println!(" Target: {:?}", target); println!(" Output directory: {:?}", output_dir); - let config = mobench_sdk::InitConfig { + let sdk_config = mobench_sdk::InitConfig { target: target.into(), project_name: project_name.clone(), output_dir: output_dir.clone(), generate_examples, }; - mobench_sdk::codegen::generate_project(&config).context("Failed to generate project")?; + mobench_sdk::codegen::generate_project(&sdk_config).context("Failed to generate project")?; + + // Generate mobench.toml configuration file + let mobench_toml_path = output_dir.join(config::CONFIG_FILE_NAME); + if !mobench_toml_path.exists() { + let toml_content = config::MobenchConfig::generate_starter_toml(&project_name); + fs::write(&mobench_toml_path, toml_content) + .with_context(|| format!("Failed to write {:?}", mobench_toml_path))?; + println!(" Generated mobench.toml configuration file"); + } - println!("\n✓ Project initialized successfully!"); + println!("\n[checkmark] Project initialized successfully!"); println!("\nNext steps:"); println!(" 1. Add benchmark functions to your code with #[benchmark]"); - println!(" 2. Run 'cargo build --target ' to build"); - println!(" 3. Run benchmarks with 'cargo mobench build --target '"); + println!(" 2. Edit mobench.toml to customize your project settings"); + println!(" 3. Run 'cargo mobench build --target ' to build"); Ok(()) } /// Build mobile artifacts using mobench-sdk (Phase 1 MVP) -fn cmd_build(target: SdkTarget, release: bool, output_dir: Option) -> Result<()> { +fn cmd_build( + target: SdkTarget, + release: bool, + output_dir: Option, + crate_path: Option, + dry_run: bool, + verbose: bool, +) -> Result<()> { + // Load config file if present (mobench.toml) + let config_resolver = config::ConfigResolver::new().unwrap_or_default(); + if let Some(config_path) = &config_resolver.config_path { + println!("Using config file: {:?}", config_path); + } + println!("Building mobile artifacts..."); println!(" Target: {:?}", target); println!(" Profile: {}", if release { "release" } else { "debug" }); + if dry_run { + println!(" Mode: dry-run (no changes will be made)"); + } + if verbose { + println!(" Verbose: enabled"); + } let project_root = std::env::current_dir().context("Failed to get current directory")?; + + // Use crate name from config if not auto-detected let crate_name = detect_bench_mobile_crate_name(&project_root) + .or_else(|_| { + config_resolver + .crate_name() + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("Could not detect crate name")) + }) .unwrap_or_else(|_| "bench-mobile".to_string()); // Fallback for legacy layouts - if let Some(ref dir) = output_dir { + // CLI flags override config file values + let effective_output_dir = output_dir.or_else(|| config_resolver.output_dir().map(|p| p.to_path_buf())); + + if let Some(ref dir) = effective_output_dir { println!(" Output: {:?}", dir); } + if let Some(ref path) = crate_path { + println!(" Crate: {:?}", path); + } let build_config = mobench_sdk::BuildConfig { target: target.into(), @@ -1895,49 +1964,78 @@ fn cmd_build(target: SdkTarget, release: bool, output_dir: Option) -> R SdkTarget::Android => { let mut builder = mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) - .verbose(true); - if let Some(ref dir) = output_dir { + .verbose(verbose) + .dry_run(dry_run); + if let Some(ref dir) = effective_output_dir { builder = builder.output_dir(dir); } + if let Some(ref path) = crate_path { + builder = builder.crate_dir(path); + } let result = builder.build(&build_config)?; - println!("\n✓ Android build completed!"); - println!(" APK: {:?}", result.app_path); + if !dry_run { + println!("\n[checkmark] Android build completed!"); + println!(" APK: {:?}", result.app_path); + } } SdkTarget::Ios => { let mut builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name.clone()) - .verbose(true); - if let Some(ref dir) = output_dir { + .verbose(verbose) + .dry_run(dry_run); + if let Some(ref dir) = effective_output_dir { builder = builder.output_dir(dir); } + if let Some(ref path) = crate_path { + builder = builder.crate_dir(path); + } let result = builder.build(&build_config)?; - println!("\n✓ iOS build completed!"); - println!(" Framework: {:?}", result.app_path); + if !dry_run { + println!("\n[checkmark] iOS build completed!"); + println!(" Framework: {:?}", result.app_path); + } } SdkTarget::Both => { // Build Android let mut android_builder = mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) - .verbose(true); - if let Some(ref dir) = output_dir { + .verbose(verbose) + .dry_run(dry_run); + if let Some(ref dir) = effective_output_dir { android_builder = android_builder.output_dir(dir); } + if let Some(ref path) = crate_path { + android_builder = android_builder.crate_dir(path); + } let android_result = android_builder.build(&build_config)?; - println!("\n✓ Android build completed!"); - println!(" APK: {:?}", android_result.app_path); + if !dry_run { + println!("\n[checkmark] Android build completed!"); + println!(" APK: {:?}", android_result.app_path); + } // Build iOS let mut ios_builder = - mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); - if let Some(ref dir) = output_dir { + mobench_sdk::builders::IosBuilder::new(&project_root, crate_name) + .verbose(verbose) + .dry_run(dry_run); + if let Some(ref dir) = effective_output_dir { ios_builder = ios_builder.output_dir(dir); } + if let Some(ref path) = crate_path { + ios_builder = ios_builder.crate_dir(path); + } let ios_result = ios_builder.build(&build_config)?; - println!("\n✓ iOS build completed!"); - println!(" Framework: {:?}", ios_result.app_path); + if !dry_run { + println!("\n[checkmark] iOS build completed!"); + println!(" Framework: {:?}", ios_result.app_path); + } } } + if dry_run { + println!("\n[dry-run] Build simulation completed. No changes were made."); + } + Ok(()) } @@ -1977,7 +2075,9 @@ fn detect_bench_mobile_crate_name(root: &Path) -> Result { return Ok(name.to_string()); } - bail!("No benchmark crate found. Expected 'bench-mobile/' or 'crates/sample-fns/'") + bail!( + "No benchmark crate found. Expected bench-mobile/Cargo.toml or crates/sample-fns/Cargo.toml under the project root. Run from the project root or set crate_name in mobench.toml." + ) } /// List all discovered benchmark functions (Phase 1 MVP) diff --git a/crates/sample-fns/Cargo.toml b/crates/sample-fns/Cargo.toml index 8301837..3697ba2 100644 --- a/crates/sample-fns/Cargo.toml +++ b/crates/sample-fns/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["lib", "cdylib", "staticlib"] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -mobench-runner = { path = "../mobench-runner" } +mobench-sdk = { path = "../mobench-sdk", default-features = false, features = ["runner-only"] } uniffi = { workspace = true, features = ["cli"] } thiserror.workspace = true uniffi_bindgen = { version = "0.28", optional = true } diff --git a/crates/sample-fns/src/lib.rs b/crates/sample-fns/src/lib.rs index 67c1335..81cdcbe 100644 --- a/crates/sample-fns/src/lib.rs +++ b/crates/sample-fns/src/lib.rs @@ -1,6 +1,6 @@ //! Sample benchmark functions for mobile testing using UniFFI (proc macro mode). -use mobench_runner::{run_closure, BenchError as BenchRunnerError}; +use mobench_sdk::timing::{run_closure, TimingError}; const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; @@ -42,9 +42,9 @@ pub enum BenchError { // Generate UniFFI scaffolding from proc macros uniffi::setup_scaffolding!(); -// Conversion from mobench-runner types -impl From for BenchSpec { - fn from(spec: mobench_runner::BenchSpec) -> Self { +// Conversion from mobench-sdk timing types +impl From for BenchSpec { + fn from(spec: mobench_sdk::timing::BenchSpec) -> Self { Self { name: spec.name, iterations: spec.iterations, @@ -53,7 +53,7 @@ impl From for BenchSpec { } } -impl From for mobench_runner::BenchSpec { +impl From for mobench_sdk::timing::BenchSpec { fn from(spec: BenchSpec) -> Self { Self { name: spec.name, @@ -63,16 +63,16 @@ impl From for mobench_runner::BenchSpec { } } -impl From for BenchSample { - fn from(sample: mobench_runner::BenchSample) -> Self { +impl From for BenchSample { + fn from(sample: mobench_sdk::timing::BenchSample) -> Self { Self { duration_ns: sample.duration_ns, } } } -impl From for BenchReport { - fn from(report: mobench_runner::BenchReport) -> Self { +impl From for BenchReport { + fn from(report: mobench_sdk::timing::BenchReport) -> Self { Self { spec: report.spec.into(), samples: report.samples.into_iter().map(Into::into).collect(), @@ -80,11 +80,11 @@ impl From for BenchReport { } } -impl From for BenchError { - fn from(err: BenchRunnerError) -> Self { +impl From for BenchError { + fn from(err: TimingError) -> Self { match err { - BenchRunnerError::NoIterations => BenchError::InvalidIterations, - BenchRunnerError::Execution(msg) => BenchError::ExecutionFailed { reason: msg }, + TimingError::NoIterations => BenchError::InvalidIterations, + TimingError::Execution(msg) => BenchError::ExecutionFailed { reason: msg }, } } } @@ -92,20 +92,20 @@ impl From for BenchError { /// Run a benchmark by name with the given specification. #[uniffi::export] pub fn run_benchmark(spec: BenchSpec) -> Result { - let runner_spec: mobench_runner::BenchSpec = spec.into(); + let timing_spec: mobench_sdk::timing::BenchSpec = spec.into(); - let report = match runner_spec.name.as_str() { + let report = match timing_spec.name.as_str() { "fibonacci" | "fib" | "sample_fns::fibonacci" => { - run_closure(runner_spec, || { + run_closure(timing_spec, || { let result = fibonacci_batch(30, 1000); // Use the result to prevent optimization std::hint::black_box(result); Ok(()) }) - .map_err(|e: BenchRunnerError| -> BenchError { e.into() })? + .map_err(|e: TimingError| -> BenchError { e.into() })? } "checksum" | "checksum_1k" | "sample_fns::checksum" => { - run_closure(runner_spec, || { + run_closure(timing_spec, || { // Run checksum 10000 times to make it measurable let mut sum = 0u64; for _ in 0..10000 { @@ -115,11 +115,11 @@ pub fn run_benchmark(spec: BenchSpec) -> Result { std::hint::black_box(sum); Ok(()) }) - .map_err(|e: BenchRunnerError| -> BenchError { e.into() })? + .map_err(|e: TimingError| -> BenchError { e.into() })? } _ => { return Err(BenchError::UnknownFunction { - name: runner_spec.name.clone(), + name: timing_spec.name.clone(), }) } }; From ba2b3a9cffa94b67743a6d736e03abd949a98541 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 16:42:56 +0100 Subject: [PATCH 023/196] Fix iOS test failure in auto-build scaffolding The test `ios_requires_artifacts_for_browserstack` was failing because iOS project scaffolding was not being generated correctly during auto-build for BrowserStack. Root causes and fixes: 1. render_dir() was incorrectly joining prefix with file.path(), but file.path() from include_dir already returns the full relative path. Removed the prefix parameter entirely. 2. project.yml lacked .template extension, so template variables like {{PROJECT_NAME_PASCAL}} were not being substituted. Renamed to project.yml.template. 3. xcframework path in project.yml.template was wrong (../../target/ios/). Fixed to ../{{LIBRARY_NAME}}.xcframework since the iOS project is at target/mobench/ios/BenchRunner/. 4. ensure_ios_project now uses fixed "BenchRunner" for PROJECT_NAME_PASCAL to match the template directory structure, while deriving LIBRARY_NAME from the actual crate name. All 37 tests now pass. --- crates/mobench-sdk/src/codegen.rs | 21 ++++++++++--------- .../{project.yml => project.yml.template} | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) rename crates/mobench-sdk/templates/ios/BenchRunner/{project.yml => project.yml.template} (94%) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index b9a80d8..2215fe7 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -295,7 +295,7 @@ pub fn generate_android_project(output_dir: &Path, project_slug: &str) -> Result value: "example_fibonacci".to_string(), }, ]; - render_dir(&ANDROID_TEMPLATES, Path::new(""), &target_dir, &vars)?; + render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?; Ok(()) } @@ -339,7 +339,7 @@ pub fn generate_ios_project( value: project_slug.replace('-', "_"), }, ]; - render_dir(&IOS_TEMPLATES, Path::new(""), &target_dir, &vars)?; + render_dir(&IOS_TEMPLATES, &target_dir, &vars)?; Ok(()) } @@ -431,7 +431,6 @@ fn fibonacci(n: u32) -> u64 { fn render_dir( dir: &Dir, - prefix: &Path, out_root: &Path, vars: &[TemplateVar], ) -> Result<(), BenchError> { @@ -442,14 +441,14 @@ fn render_dir( if sub.path().components().any(|c| c.as_os_str() == ".gradle") { continue; } - let next_prefix = prefix.join(sub.path()); - render_dir(sub, &next_prefix, out_root, vars)?; + render_dir(sub, out_root, vars)?; } DirEntry::File(file) => { if file.path().components().any(|c| c.as_os_str() == ".gradle") { continue; } - let mut relative = prefix.join(file.path()); + // file.path() returns the full relative path from the embedded dir root + let mut relative = file.path().to_path_buf(); let mut contents = file.contents().to_vec(); if let Some(ext) = relative.extension() && ext == "template" @@ -565,10 +564,12 @@ pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), Ben } println!("iOS project not found, generating scaffolding..."); - let project_slug = crate_name.replace('-', "_"); - let project_pascal = to_pascal_case(&project_slug); - let bundle_prefix = format!("dev.world.{}", project_slug.replace('_', "-")); - generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix)?; + // Use fixed "BenchRunner" for project/scheme name to match template directory structure + let project_pascal = "BenchRunner"; + // Derive library name and bundle prefix from crate name + let library_name = crate_name.replace('-', "_"); + let bundle_prefix = format!("dev.world.{}", library_name.replace('_', "-")); + generate_ios_project(output_dir, &library_name, project_pascal, &bundle_prefix)?; println!(" Generated iOS project at {:?}", output_dir.join("ios")); Ok(()) } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template similarity index 94% rename from crates/mobench-sdk/templates/ios/BenchRunner/project.yml rename to crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template index 11f416b..e3ed4f3 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml +++ b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template @@ -24,7 +24,7 @@ targets: SWIFT_OBJC_BRIDGING_HEADER: {{PROJECT_NAME_PASCAL}}/{{PROJECT_NAME_PASCAL}}-Bridging-Header.h HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/{{PROJECT_NAME_PASCAL}}/Generated" dependencies: - - framework: ../../target/ios/{{LIBRARY_NAME}}.xcframework + - framework: ../{{LIBRARY_NAME}}.xcframework embed: true link: true {{PROJECT_NAME_PASCAL}}UITests: From 30ca95bd0a2a627df45f9acf2cf9d54b1b036dfa Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 16:52:15 +0100 Subject: [PATCH 024/196] Update documentation for consolidated crates and new features README updates: - Document mobench-runner consolidation into mobench-sdk - Add mobench.toml configuration file documentation - Document new CLI flags: --dry-run, --verbose, --crate-path - Update all artifact paths to target/mobench/ - Add examples for new features Rust doc comments: - Add comprehensive module docs to builders/{mod,android,ios,common}.rs - Document build pipelines, requirements, and examples - Add dry-run mode documentation - Note mobench-runner consolidation in timing.rs docs.rs configuration: - Add targets = ["x86_64-unknown-linux-gnu"] for docs.rs builds - Add #![cfg_attr(docsrs, feature(doc_cfg))] attributes - Add crates.io/docs.rs/license badges to crate roots - Add #[doc(cfg(...))] annotations for feature-gated items --- README.md | 31 ++++++- android/README.md | 25 ++++- crates/mobench-macros/Cargo.toml | 2 + crates/mobench-macros/README.md | 2 +- crates/mobench-macros/src/lib.rs | 6 ++ crates/mobench-sdk/Cargo.toml | 8 +- crates/mobench-sdk/README.md | 44 ++++++++- crates/mobench-sdk/src/builders/android.rs | 84 ++++++++++++++++- crates/mobench-sdk/src/builders/common.rs | 22 ++++- crates/mobench-sdk/src/builders/ios.rs | 102 ++++++++++++++++++++- crates/mobench-sdk/src/builders/mod.rs | 60 +++++++++++- crates/mobench-sdk/src/lib.rs | 18 +++- crates/mobench-sdk/src/timing.rs | 7 +- crates/mobench/Cargo.toml | 2 + crates/mobench/README.md | 86 ++++++++++++++--- crates/mobench/src/lib.rs | 19 ++++ 16 files changed, 470 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index bb6a772..8ea4808 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ mobench provides a Rust API and a CLI for running benchmarks on real mobile devi ## How mobench works - `#[benchmark]` marks functions and registers them via `inventory` -- `mobench-sdk` builds mobile artifacts and generates app templates from embedded assets +- `mobench-sdk` builds mobile artifacts, provides the timing harness, and generates app templates from embedded assets - UniFFI proc macros generate Kotlin and Swift bindings directly from Rust types - The CLI writes a benchmark spec (function, iterations, warmup) and packages it into the app - Mobile apps call `run_benchmark` via the generated bindings and return timing samples @@ -18,7 +18,7 @@ mobench provides a Rust API and a CLI for running benchmarks on real mobile devi ## Workspace crates - `crates/mobench` ([mobench](https://crates.io/crates/mobench)): CLI tool that builds, runs, and fetches benchmarks -- `crates/mobench-sdk` ([mobench-sdk](https://crates.io/crates/mobench-sdk)): core SDK (timing harness, builders, registry, codegen) +- `crates/mobench-sdk` ([mobench-sdk](https://crates.io/crates/mobench-sdk)): core SDK with timing harness, builders, registry, and codegen - `crates/mobench-macros` ([mobench-macros](https://crates.io/crates/mobench-macros)): `#[benchmark]` proc macro - `crates/sample-fns`: sample benchmarks and UniFFI bindings - `examples/basic-benchmark`: minimal SDK integration example @@ -33,7 +33,7 @@ cargo install mobench # Add the SDK to your project cargo add mobench-sdk inventory -# Build artifacts +# Build artifacts (outputs to target/mobench/ by default) cargo mobench build --target android cargo mobench build --target ios @@ -41,6 +41,31 @@ cargo mobench build --target ios cargo mobench run --target android --function sample_fns::fibonacci ``` +## Configuration + +mobench supports a `mobench.toml` configuration file for project settings: + +```toml +[project] +crate = "bench-mobile" +library_name = "bench_mobile" + +[android] +package = "com.example.bench" +min_sdk = 24 + +[ios] +bundle_id = "com.example.bench" +deployment_target = "15.0" + +[benchmarks] +default_function = "my_crate::my_benchmark" +default_iterations = 100 +default_warmup = 10 +``` + +CLI flags override config file values when provided. + ## Project docs - `BENCH_SDK_INTEGRATION.md`: SDK integration guide diff --git a/android/README.md b/android/README.md index 81346c1..b74e311 100644 --- a/android/README.md +++ b/android/README.md @@ -3,16 +3,31 @@ Minimal Android app that loads the Rust `sample-fns` cdylib and calls exported functions. This is a thin wrapper meant for BrowserStack AppAutomate and CI smoke tests. ## Build steps -1. Build Rust libs for Android: + +1. Build Rust libs for Android (outputs to `target/mobench/android/`): ```bash cargo mobench build --target android ``` + 2. Assemble the APK (requires Java + Gradle + Android SDK/NDK on PATH): ```bash - cd android - gradle :app:assembleDebug + cd target/mobench/android + ./gradlew :app:assembleDebug ``` -Artifacts will be under `android/app/build/outputs/apk/debug/`. +Artifacts will be under `target/mobench/android/app/build/outputs/apk/debug/`. + +## Additional CLI options + +```bash +# Preview build without making changes +cargo mobench build --target android --dry-run + +# Build with verbose output +cargo mobench build --target android --verbose + +# Build to custom output directory +cargo mobench build --target android --output-dir ./my-output +``` -> Note: Gradle/AGP versions are pinned in `android/build.gradle`. Update as needed. +> Note: Gradle/AGP versions are pinned in the generated `build.gradle`. Update as needed. diff --git a/crates/mobench-macros/Cargo.toml b/crates/mobench-macros/Cargo.toml index 71b02f3..945b0e8 100644 --- a/crates/mobench-macros/Cargo.toml +++ b/crates/mobench-macros/Cargo.toml @@ -16,6 +16,8 @@ maintenance = { status = "actively-developed" } [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] +# Build on default target for docs.rs +targets = ["x86_64-unknown-linux-gnu"] [lib] proc-macro = true diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index cb5ed9e..e7b1e6d 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -177,7 +177,7 @@ fn hash_and_encode() { This crate is part of the mobench ecosystem for mobile benchmarking: - **[mobench](https://crates.io/crates/mobench)** - CLI tool for running benchmarks -- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - SDK with timing harness for integrating benchmarks +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK with timing harness, build automation, and codegen - **[mobench-macros](https://crates.io/crates/mobench-macros)** - This crate (proc macros) ## See Also diff --git a/crates/mobench-macros/src/lib.rs b/crates/mobench-macros/src/lib.rs index 418f911..b00f242 100644 --- a/crates/mobench-macros/src/lib.rs +++ b/crates/mobench-macros/src/lib.rs @@ -1,5 +1,9 @@ //! # mobench-macros //! +//! [![Crates.io](https://img.shields.io/crates/v/mobench-macros.svg)](https://crates.io/crates/mobench-macros) +//! [![Documentation](https://docs.rs/mobench-macros/badge.svg)](https://docs.rs/mobench-macros) +//! [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/worldcoin/mobile-bench-rs/blob/main/LICENSE) +//! //! Procedural macros for the mobench mobile benchmarking SDK. //! //! This crate provides the [`#[benchmark]`](macro@benchmark) attribute macro @@ -71,6 +75,8 @@ //! - **[`mobench-sdk`](https://crates.io/crates/mobench-sdk)** - Core SDK with timing harness (re-exports this macro) //! - **[`mobench`](https://crates.io/crates/mobench)** - CLI tool //! - **`mobench-macros`** (this crate) - Proc macros +//! +//! Note: The `mobench-runner` crate has been consolidated into `mobench-sdk` as the `timing` module. use proc_macro::TokenStream; use quote::quote; diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index f19ea58..8f2cf28 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -17,12 +17,8 @@ maintenance = { status = "actively-developed" } [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] -include = [ - "src/**/*", - "templates/**/*", - "Cargo.toml", - "README.md" -] +# Build on default target for docs.rs +targets = ["x86_64-unknown-linux-gnu"] [lib] crate-type = ["lib"] diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index 0c4b1aa..026ed8b 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -8,11 +8,13 @@ Transform your Rust project into a mobile benchmarking suite. This SDK provides - **`#[benchmark]` macro**: Mark functions for mobile benchmarking - **Automatic registry**: Compile-time function discovery +- **Built-in timing harness**: Lightweight timing infrastructure with warmup and iteration support - **Mobile app generation**: Create Android/iOS apps from templates - **Build automation**: Cross-compile and package for mobile platforms - **Statistical analysis**: Mean, median, stddev, percentiles - **BrowserStack integration**: Test on real devices in the cloud - **UniFFI bindings**: Automatic FFI generation for mobile platforms +- **Configuration file support**: `mobench.toml` for project settings ## Quick Start @@ -97,7 +99,14 @@ fn my_benchmark() { ### 3. Build for Mobile ```bash +# Build to default output directory (target/mobench/) cargo mobench build --target android + +# Or with verbose output +cargo mobench build --target android --verbose + +# Or preview what would be built +cargo mobench build --target android --dry-run ``` ### 4. Run on Devices @@ -349,7 +358,32 @@ fn btreemap_insert_1000() { ## Configuration -### `bench-config.toml` +### `mobench.toml` (Project Configuration) + +mobench automatically loads `mobench.toml` from the current directory or parent directories: + +```toml +[project] +crate = "bench-mobile" +library_name = "bench_mobile" +# output_dir = "target/mobench" # default + +[android] +package = "com.example.bench" +min_sdk = 24 +target_sdk = 34 + +[ios] +bundle_id = "com.example.bench" +deployment_target = "15.0" + +[benchmarks] +default_function = "my_crate::my_benchmark" +default_iterations = 100 +default_warmup = 10 +``` + +### `bench-config.toml` (Run Configuration) ```toml target = "android" @@ -365,8 +399,8 @@ app_automate_access_key = "${BROWSERSTACK_ACCESS_KEY}" project = "my-project-benchmarks" [ios_xcuitest] -app = "target/ios/BenchRunner.ipa" -test_suite = "target/ios/BenchRunnerUITests.zip" +app = "target/mobench/ios/BenchRunner.ipa" +test_suite = "target/mobench/ios/BenchRunnerUITests.zip" ``` ### `device-matrix.yaml` @@ -402,8 +436,8 @@ devices: This is the core SDK of the mobench ecosystem: - **[mobench](https://crates.io/crates/mobench)** - CLI tool (recommended for most users) -- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - This crate (SDK library with timing harness) -- **[mobench-macros](https://crates.io/crates/mobench-macros)** - Proc macros +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - This crate (SDK library with timing harness, build automation, and codegen) +- **[mobench-macros](https://crates.io/crates/mobench-macros)** - `#[benchmark]` proc macro ## See Also diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index e4b860f..f46d67c 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -1,7 +1,59 @@ -//! Android build automation +//! Android build automation. //! -//! This module provides functionality to build Rust libraries for Android and -//! package them into an APK using Gradle. +//! This module provides [`AndroidBuilder`] which handles the complete pipeline for +//! building Rust libraries for Android and packaging them into an APK using Gradle. +//! +//! ## Build Pipeline +//! +//! The builder performs these steps: +//! +//! 1. **Project scaffolding** - Auto-generates Android project if missing +//! 2. **Rust compilation** - Builds native `.so` libraries for Android ABIs using `cargo-ndk` +//! 3. **Binding generation** - Generates UniFFI Kotlin bindings +//! 4. **Library packaging** - Copies `.so` files to `jniLibs/` directories +//! 5. **APK building** - Runs Gradle to build the app APK +//! 6. **Test APK building** - Builds the androidTest APK for BrowserStack Espresso +//! +//! ## Requirements +//! +//! - Android NDK (set `ANDROID_NDK_HOME` environment variable) +//! - `cargo-ndk` (`cargo install cargo-ndk`) +//! - Rust targets: `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` +//! - Java JDK (for Gradle) +//! +//! ## Example +//! +//! ```ignore +//! use mobench_sdk::builders::AndroidBuilder; +//! use mobench_sdk::{BuildConfig, BuildProfile, Target}; +//! +//! let builder = AndroidBuilder::new(".", "my-bench-crate") +//! .verbose(true) +//! .dry_run(false); // Set to true to preview without building +//! +//! let config = BuildConfig { +//! target: Target::Android, +//! profile: BuildProfile::Release, +//! incremental: true, +//! }; +//! +//! let result = builder.build(&config)?; +//! println!("APK at: {:?}", result.app_path); +//! println!("Test APK at: {:?}", result.test_suite_path); +//! # Ok::<(), mobench_sdk::BenchError>(()) +//! ``` +//! +//! ## Dry-Run Mode +//! +//! Use `dry_run(true)` to preview the build plan without making changes: +//! +//! ```ignore +//! let builder = AndroidBuilder::new(".", "my-bench") +//! .dry_run(true); +//! +//! // This will print the build plan but not execute anything +//! builder.build(&config)?; +//! ``` use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; use super::common::{get_cargo_target_dir, host_lib_path, run_command}; @@ -10,7 +62,31 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -/// Android builder that handles the complete build pipeline +/// Android builder that handles the complete build pipeline. +/// +/// This builder automates the process of compiling Rust code to Android native +/// libraries, generating UniFFI Kotlin bindings, and packaging everything into +/// an APK ready for deployment. +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::builders::AndroidBuilder; +/// use mobench_sdk::{BuildConfig, BuildProfile, Target}; +/// +/// let builder = AndroidBuilder::new(".", "my-bench") +/// .verbose(true) +/// .output_dir("target/mobench"); +/// +/// let config = BuildConfig { +/// target: Target::Android, +/// profile: BuildProfile::Release, +/// incremental: true, +/// }; +/// +/// let result = builder.build(&config)?; +/// # Ok::<(), mobench_sdk::BenchError>(()) +/// ``` pub struct AndroidBuilder { /// Root directory of the project project_root: PathBuf, diff --git a/crates/mobench-sdk/src/builders/common.rs b/crates/mobench-sdk/src/builders/common.rs index e9ff888..4b878e4 100644 --- a/crates/mobench-sdk/src/builders/common.rs +++ b/crates/mobench-sdk/src/builders/common.rs @@ -1,9 +1,21 @@ -//! Common utilities shared between Android and iOS builders +//! Common utilities shared between Android and iOS builders. //! -//! This module provides helper functions for: -//! - Detecting Cargo target directories (workspace-aware) -//! - Finding host libraries for UniFFI binding generation -//! - Running external commands with consistent error handling +//! This module provides helper functions that are used by both [`super::AndroidBuilder`] +//! and [`super::IosBuilder`] to ensure consistent behavior and error handling. +//! +//! ## Features +//! +//! - **Workspace-aware target detection** - Correctly handles Cargo workspaces where +//! the target directory is at the workspace root +//! - **Host library resolution** - Finds compiled libraries for UniFFI binding generation +//! - **Consistent error handling** - All errors include actionable fix suggestions +//! +//! ## Error Messages +//! +//! All functions in this module provide detailed, actionable error messages that include: +//! - What went wrong +//! - Where it happened (paths, commands) +//! - How to fix it (specific commands or configuration changes) use std::env; use std::path::{Path, PathBuf}; diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 986ab52..233e114 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -1,7 +1,74 @@ -//! iOS build automation +//! iOS build automation. //! -//! This module provides functionality to build Rust libraries for iOS and -//! create an xcframework that can be used in Xcode projects. +//! This module provides [`IosBuilder`] which handles the complete pipeline for +//! building Rust libraries for iOS and packaging them into an xcframework that +//! can be used in Xcode projects. +//! +//! ## Build Pipeline +//! +//! The builder performs these steps: +//! +//! 1. **Project scaffolding** - Auto-generates iOS project if missing +//! 2. **Rust compilation** - Builds static libraries for device and simulator targets +//! 3. **Binding generation** - Generates UniFFI Swift bindings and C headers +//! 4. **XCFramework creation** - Creates properly structured xcframework with slices +//! 5. **Code signing** - Signs the xcframework for Xcode acceptance +//! 6. **Xcode project generation** - Runs xcodegen if `project.yml` exists +//! +//! ## Requirements +//! +//! - Xcode with command line tools (`xcode-select --install`) +//! - Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim` +//! - `uniffi-bindgen` for Swift binding generation +//! - `xcodegen` (optional, `brew install xcodegen`) +//! +//! ## Example +//! +//! ```ignore +//! use mobench_sdk::builders::{IosBuilder, SigningMethod}; +//! use mobench_sdk::{BuildConfig, BuildProfile, Target}; +//! +//! let builder = IosBuilder::new(".", "my-bench-crate") +//! .verbose(true) +//! .dry_run(false); +//! +//! let config = BuildConfig { +//! target: Target::Ios, +//! profile: BuildProfile::Release, +//! incremental: true, +//! }; +//! +//! let result = builder.build(&config)?; +//! println!("XCFramework at: {:?}", result.app_path); +//! +//! // Package IPA for BrowserStack or device testing +//! let ipa_path = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?; +//! # Ok::<(), mobench_sdk::BenchError>(()) +//! ``` +//! +//! ## Dry-Run Mode +//! +//! Use `dry_run(true)` to preview the build plan without making changes: +//! +//! ```ignore +//! let builder = IosBuilder::new(".", "my-bench") +//! .dry_run(true); +//! +//! // This will print the build plan but not execute anything +//! builder.build(&config)?; +//! ``` +//! +//! ## IPA Packaging +//! +//! After building the xcframework, you can package an IPA for device testing: +//! +//! ```ignore +//! // Ad-hoc signing (works for BrowserStack, no Apple ID needed) +//! let ipa = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?; +//! +//! // Development signing (requires Apple Developer account) +//! let ipa = builder.package_ipa("BenchRunner", SigningMethod::Development)?; +//! ``` use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; use super::common::{get_cargo_target_dir, host_lib_path, run_command}; @@ -10,7 +77,34 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -/// iOS builder that handles the complete build pipeline +/// iOS builder that handles the complete build pipeline. +/// +/// This builder automates the process of compiling Rust code to iOS static +/// libraries, generating UniFFI Swift bindings, creating an xcframework, +/// and optionally packaging an IPA for device deployment. +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::builders::{IosBuilder, SigningMethod}; +/// use mobench_sdk::{BuildConfig, BuildProfile, Target}; +/// +/// let builder = IosBuilder::new(".", "my-bench") +/// .verbose(true) +/// .output_dir("target/mobench"); +/// +/// let config = BuildConfig { +/// target: Target::Ios, +/// profile: BuildProfile::Release, +/// incremental: true, +/// }; +/// +/// let result = builder.build(&config)?; +/// +/// // Optional: Package IPA for device testing +/// let ipa = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?; +/// # Ok::<(), mobench_sdk::BenchError>(()) +/// ``` pub struct IosBuilder { /// Root directory of the project project_root: PathBuf, diff --git a/crates/mobench-sdk/src/builders/mod.rs b/crates/mobench-sdk/src/builders/mod.rs index 124e214..1aa9919 100644 --- a/crates/mobench-sdk/src/builders/mod.rs +++ b/crates/mobench-sdk/src/builders/mod.rs @@ -1,7 +1,65 @@ -//! Build automation for mobile platforms +//! Build automation for mobile platforms. //! //! This module provides builders for Android and iOS that automate the process //! of compiling Rust code to mobile libraries and packaging them into mobile apps. +//! +//! ## Overview +//! +//! The builders handle the complete build pipeline: +//! +//! 1. **Rust compilation** - Cross-compile Rust to mobile targets +//! 2. **Binding generation** - Generate UniFFI Kotlin/Swift bindings +//! 3. **Native library packaging** - Copy `.so` files to Android or create xcframework for iOS +//! 4. **App building** - Run Gradle (Android) or xcodebuild (iOS) +//! +//! ## Builders +//! +//! | Builder | Platform | Output | +//! |---------|----------|--------| +//! | [`AndroidBuilder`] | Android | APK with native `.so` libraries | +//! | [`IosBuilder`] | iOS | xcframework with static libraries | +//! +//! ## Common Utilities +//! +//! The `common` module (internal) provides shared functionality: +//! +//! - Workspace-aware Cargo target directory detection +//! - Host library path resolution for UniFFI binding generation +//! - Consistent command execution with actionable error messages +//! +//! ## Builder Options +//! +//! Both builders support the following configuration: +//! +//! - **`verbose(bool)`** - Enable detailed output showing each build step +//! - **`dry_run(bool)`** - Preview build steps without making changes +//! - **`output_dir(path)`** - Customize output location (default: `target/mobench/`) +//! - **`crate_dir(path)`** - Override auto-detected crate location +//! +//! ## Example +//! +//! ```ignore +//! use mobench_sdk::builders::{AndroidBuilder, IosBuilder}; +//! use mobench_sdk::{BuildConfig, BuildProfile, Target}; +//! +//! // Build for Android with dry-run +//! let android = AndroidBuilder::new(".", "my-bench") +//! .verbose(true) +//! .dry_run(true); // Preview only +//! +//! // Build for iOS +//! let ios = IosBuilder::new(".", "my-bench") +//! .verbose(true); +//! +//! let config = BuildConfig { +//! target: Target::Android, +//! profile: BuildProfile::Release, +//! incremental: true, +//! }; +//! +//! android.build(&config)?; +//! # Ok::<(), mobench_sdk::BenchError>(()) +//! ``` pub mod android; pub mod ios; diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 7418967..2dabc3b 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -1,5 +1,9 @@ //! # mobench-sdk //! +//! [![Crates.io](https://img.shields.io/crates/v/mobench-sdk.svg)](https://crates.io/crates/mobench-sdk) +//! [![Documentation](https://docs.rs/mobench-sdk/badge.svg)](https://docs.rs/mobench-sdk) +//! [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/worldcoin/mobile-bench-rs/blob/main/LICENSE) +//! //! A mobile benchmarking SDK for Rust that enables running performance benchmarks //! on real Android and iOS devices via BrowserStack App Automate. //! @@ -74,12 +78,14 @@ //! //! ## Crate Ecosystem //! -//! The mobench ecosystem consists of three crates: +//! The mobench ecosystem consists of three published crates: //! //! - **`mobench-sdk`** (this crate) - Core SDK library with timing harness and build automation //! - **[`mobench`](https://crates.io/crates/mobench)** - CLI tool for building and running benchmarks //! - **[`mobench-macros`](https://crates.io/crates/mobench-macros)** - `#[benchmark]` proc macro //! +//! Note: The `mobench-runner` crate has been consolidated into this crate as the [`timing`] module. +//! //! ## Feature Flags //! //! | Feature | Default | Description | @@ -273,28 +279,37 @@ //! //! MIT License - see repository for details. +#![cfg_attr(docsrs, feature(doc_cfg))] + // Core timing module - always available pub mod timing; pub mod types; // Full SDK modules - only with "full" feature #[cfg(feature = "full")] +#[cfg_attr(docsrs, doc(cfg(feature = "full")))] pub mod builders; #[cfg(feature = "full")] +#[cfg_attr(docsrs, doc(cfg(feature = "full")))] pub mod codegen; #[cfg(feature = "full")] +#[cfg_attr(docsrs, doc(cfg(feature = "full")))] pub mod registry; #[cfg(feature = "full")] +#[cfg_attr(docsrs, doc(cfg(feature = "full")))] pub mod runner; // Re-export the benchmark macro from bench-macros (only with full feature) #[cfg(feature = "full")] +#[cfg_attr(docsrs, doc(cfg(feature = "full")))] pub use mobench_macros::benchmark; // Re-export key types for convenience (full feature) #[cfg(feature = "full")] +#[cfg_attr(docsrs, doc(cfg(feature = "full")))] pub use registry::{BenchFunction, discover_benchmarks, find_benchmark, list_benchmark_names}; #[cfg(feature = "full")] +#[cfg_attr(docsrs, doc(cfg(feature = "full")))] pub use runner::{BenchmarkBuilder, run_benchmark}; // Re-export types that are always available @@ -302,6 +317,7 @@ pub use types::{BenchError, BenchSample, BenchSpec, RunnerReport}; // Re-export types that require full feature #[cfg(feature = "full")] +#[cfg_attr(docsrs, doc(cfg(feature = "full")))] pub use types::{BuildConfig, BuildProfile, BuildResult, InitConfig, Target}; // Re-export timing types at the crate root for convenience diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index f07fa44..c949508 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -1,8 +1,11 @@ //! Lightweight benchmarking harness for mobile platforms. //! //! This module provides the core timing infrastructure for the mobench ecosystem. -//! It's designed to be minimal and portable, with no platform-specific dependencies, -//! making it suitable for compilation to Android and iOS targets. +//! It was previously a separate crate (`mobench-runner`) but has been consolidated +//! into `mobench-sdk` for a simpler dependency graph. +//! +//! The module is designed to be minimal and portable, with no platform-specific +//! dependencies, making it suitable for compilation to Android and iOS targets. //! //! ## Overview //! diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index d557878..3bd8b2b 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -17,6 +17,8 @@ maintenance = { status = "actively-developed" } [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] +# Build on default target for docs.rs +targets = ["x86_64-unknown-linux-gnu"] default-run = "mobench" diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 26feb2e..ef6c5be 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -34,9 +34,10 @@ cargo mobench init --target both This creates: - `bench-mobile/` - FFI wrapper crate with UniFFI bindings -- `android/` or `ios/` - Platform-specific app projects -- `bench-config.toml` - Configuration file -- `benches/example.rs` - Example benchmarks (with `--generate-examples`) +- `android/` or `ios/` - Platform-specific app projects (generated to output directory) +- `bench-config.toml` - Run configuration file +- `mobench.toml` - Project configuration file (when using `init-sdk`) +- `benches/example.rs` - Example benchmarks (with `--examples`) ### 2. Write Benchmarks @@ -119,6 +120,10 @@ cargo mobench build --target [OPTIONS] **Options:** - `--target ` - Platform to build for (required) - `--release` - Build in release mode (default: debug) +- `--output-dir ` - Output directory for mobile artifacts (default: `target/mobench/`) +- `--crate-path ` - Path to the benchmark crate (default: auto-detect) +- `--dry-run` - Print what would be done without making changes +- `--verbose` / `-v` - Print verbose output including all commands **Examples:** ```bash @@ -127,11 +132,20 @@ cargo mobench build --target android --release # Build iOS xcframework cargo mobench build --target ios + +# Preview build without making changes +cargo mobench build --target android --dry-run + +# Build with verbose output +cargo mobench build --target ios --verbose + +# Build to custom output directory +cargo mobench build --target android --output-dir ./my-output ``` **Outputs:** -- Android: `android/app/build/outputs/apk/debug/app-debug.apk` -- iOS: `target/ios/sample_fns.xcframework` +- Android: `target/mobench/android/app/build/outputs/apk/debug/app-debug.apk` +- iOS: `target/mobench/ios/sample_fns.xcframework` ### `run` - Run Benchmarks @@ -198,7 +212,7 @@ cargo mobench package-ipa [OPTIONS] cargo mobench package-ipa --method adhoc ``` -**Output:** `target/ios/BenchRunner.ipa` +**Output:** `target/mobench/ios/BenchRunner.ipa` ### `plan` - Generate Device Matrix @@ -269,7 +283,57 @@ cargo mobench compare \ ## Configuration -### Config File Format (`bench-config.toml`) +### Project Configuration (`mobench.toml`) + +mobench automatically loads `mobench.toml` from the current directory or any parent directory: + +```toml +[project] +# Name of the benchmark crate +crate = "bench-mobile" + +# Rust library name (typically crate name with hyphens replaced by underscores) +library_name = "bench_mobile" + +# Output directory for build artifacts (default: target/mobench/) +# output_dir = "target/mobench" + +[android] +# Android package name +package = "com.example.bench" + +# Minimum Android SDK version (default: 24) +min_sdk = 24 + +# Target Android SDK version (default: 34) +target_sdk = 34 + +[ios] +# iOS bundle identifier +bundle_id = "com.example.bench" + +# iOS deployment target version (default: 15.0) +deployment_target = "15.0" + +# Development team ID for code signing (optional) +# team_id = "YOUR_TEAM_ID" + +[benchmarks] +# Default benchmark function to run +default_function = "my_crate::my_benchmark" + +# Default number of benchmark iterations +default_iterations = 100 + +# Default number of warmup iterations +default_warmup = 10 +``` + +CLI flags always override config file values when provided. + +### Run Config File Format (`bench-config.toml`) + +For BrowserStack runs, you can also use a separate run configuration: ```toml target = "android" @@ -285,8 +349,8 @@ app_automate_access_key = "${BROWSERSTACK_ACCESS_KEY}" project = "my-project-benchmarks" [ios_xcuitest] -app = "target/ios/BenchRunner.ipa" -test_suite = "target/ios/BenchRunnerUITests.zip" +app = "target/mobench/ios/BenchRunner.ipa" +test_suite = "target/mobench/ios/BenchRunnerUITests.zip" ``` ### Device Matrix Format (`device-matrix.yaml`) @@ -517,8 +581,8 @@ Ensure: This CLI is part of the mobench ecosystem: - **[mobench](https://crates.io/crates/mobench)** - This crate (CLI tool) -- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - SDK library with timing harness and build automation -- **[mobench-macros](https://crates.io/crates/mobench-macros)** - Proc macros +- **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK with timing harness, build automation, and codegen +- **[mobench-macros](https://crates.io/crates/mobench-macros)** - `#[benchmark]` proc macro ## See Also diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index d89cc2d..882d343 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -1,5 +1,9 @@ //! # mobench //! +//! [![Crates.io](https://img.shields.io/crates/v/mobench.svg)](https://crates.io/crates/mobench) +//! [![Documentation](https://docs.rs/mobench/badge.svg)](https://docs.rs/mobench) +//! [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/worldcoin/mobile-bench-rs/blob/main/LICENSE) +//! //! Command-line tool for building and running Rust benchmarks on mobile devices. //! //! ## Overview @@ -93,6 +97,21 @@ //! - **`mobench`** (this crate) - CLI tool //! - **[`mobench-sdk`](https://crates.io/crates/mobench-sdk)** - Core SDK with timing harness and build automation //! - **[`mobench-macros`](https://crates.io/crates/mobench-macros)** - `#[benchmark]` proc macro +//! +//! Note: The `mobench-runner` crate has been consolidated into `mobench-sdk` as its `timing` module. +//! +//! ## CLI Flags +//! +//! Global flags available on all commands: +//! +//! - **`--dry-run`** - Preview what would be done without making changes +//! - **`--verbose` / `-v`** - Enable detailed output showing all commands +//! +//! ## Modules +//! +//! - [`config`] - Configuration file support for `mobench.toml` + +#![cfg_attr(docsrs, feature(doc_cfg))] use anyhow::{Context, Result, anyhow, bail}; use clap::{Parser, Subcommand, ValueEnum}; From d6f2da46a7860e9d8f0c19264fc93380316fb9ba Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 16:58:00 +0100 Subject: [PATCH 025/196] Bump version to 0.1.9 for release - Update workspace version from 0.1.8 to 0.1.9 - Update mobench-sdk dependency in mobench crate - Update mobench-macros dependency in mobench-sdk crate - All mobench-runner versions have been yanked on crates.io --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd8547c..663080e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "clap", @@ -820,7 +820,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.8" +version = "0.1.9" dependencies = [ "proc-macro2", "quote", @@ -829,7 +829,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "include_dir", @@ -1154,7 +1154,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.8" +version = "0.1.9" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 90d32aa..e81691d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.8" +version = "0.1.9" [workspace.dependencies] anyhow = "1" diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 8f2cf28..db1fa9c 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.8", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.9", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 3bd8b2b..03aa66f 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.8", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.9", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true From c1dab027e0307ba0478613521c13f936718c22df Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 17:09:11 +0100 Subject: [PATCH 026/196] Remove IMPROVEMENTS.md after completing all tasks --- IMPROVEMENTS.md | 656 ------------------------------------------------ 1 file changed, 656 deletions(-) delete mode 100644 IMPROVEMENTS.md diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md deleted file mode 100644 index bcae93b..0000000 --- a/IMPROVEMENTS.md +++ /dev/null @@ -1,656 +0,0 @@ -# Mobench Developer Experience Improvements - -This document captures improvements needed for mobench based on real-world integration testing with the world-id-protocol project (ZK proof benchmarking on mobile devices). - -## Summary - -| Priority | Task | Description | Status | -|----------|------|-------------|--------| -| P0 | [#1](#task-1-fix-aws-lc-rs-android-ndk-incompatibility) | Fix aws-lc-rs Android NDK incompatibility | DONE | -| P0 | [#2](#task-2-fix-workspace-target-directory-detection) | Fix workspace target directory detection | DONE | -| P0 | [#3](#task-3-auto-generate-project-scaffolding-during-build) | Auto-generate project scaffolding during build | DONE | -| P1 | [#4](#task-4-process-template-variables-during-build) | Process template variables during build | DONE | -| P1 | [#5](#task-5-improve-uniffi-bindgen-handling) | Improve uniffi-bindgen handling | DONE | -| P1 | [#6](#task-6-generate-error-handling-code-dynamically) | Generate error handling code dynamically | DONE | -| P2 | [#7](#task-7-add-configuration-file-support-mobenchtoml) | Add configuration file support (mobench.toml) | DONE | -| P2 | [#8](#task-8-improve-error-messages) | Improve error messages | DONE | -| P2 | [#9](#task-9-add---crate-path-flag) | Add --crate-path flag | DONE | -| P3 | [#10](#task-10-add---dry-run-and---verbose-modes) | Add --dry-run and --verbose modes | DONE | -| P3 | [#11](#task-11-auto-generate-localproperties-for-android) | Auto-generate local.properties for Android | DONE | - ---- - -## P0 - Critical (Blocking Issues) - -### Task 1: Fix aws-lc-rs Android NDK Incompatibility - -**Problem** - -The default `rustls` 0.23+ uses `aws-lc-rs` as the crypto backend, which fails to compile for Android NDK targets. Users see cryptic C compilation errors: - -``` -error occurred in cc-rs: command did not execute successfully -.../clang ... --target=aarch64-linux-android24 ... getentropy.c -``` - -**Root Cause** - -`aws-lc-sys` contains C code that doesn't compile correctly with the Android NDK toolchain. - -**Solution** - -Update the generated `Cargo.toml` templates to configure rustls with the `ring` crypto backend: - -```toml -[workspace.dependencies] -rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/codegen.rs` - Update Cargo.toml template generation -- `crates/mobench-sdk/templates/` - Update any hardcoded rustls dependencies -- `CLAUDE.md` / `BUILD.md` - Document the issue and workaround - -**Acceptance Criteria** - -- [ ] New projects generated with `init-sdk` compile for Android without rustls errors -- [ ] Documentation explains the aws-lc-rs issue and how to fix existing projects - ---- - -### Task 2: Fix Workspace Target Directory Detection - -**Problem** - -mobench looks for the host library at `/target/debug/lib*.dylib` but Cargo workspaces use a shared `/target/` directory. - -``` -build error: host library for UniFFI not found at -"/path/to/bench-mobile/target/debug/libbench_mobile.dylib" -``` - -**Root Cause** - -Hardcoded path assumption: `self.project_root.join("target")` instead of querying Cargo for the actual target directory. - -**Solution** - -Use `cargo metadata` to detect the actual target directory: - -```rust -fn get_target_dir(crate_dir: &Path) -> Result { - let output = Command::new("cargo") - .args(["metadata", "--format-version", "1", "--no-deps"]) - .current_dir(crate_dir) - .output()?; - let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)?; - Ok(PathBuf::from(metadata["target_directory"].as_str().unwrap())) -} -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/builders/android.rs` - - `generate_uniffi_bindings()` - Use correct target dir for host library - - `copy_native_libraries()` - Use correct target dir for .so files -- `crates/mobench-sdk/src/builders/ios.rs` - Similar changes -- Consider adding `cargo_metadata` crate or manual JSON parsing - -**Acceptance Criteria** - -- [ ] `mobench build` works in Cargo workspace projects -- [ ] `mobench build` still works in standalone crate projects - ---- - -### Task 3: Auto-generate Project Scaffolding During Build - -**Problem** - -`mobench build` assumes Android/iOS project files already exist. When missing, it fails with confusing errors: - -``` -build error: Failed to run Gradle: No such file or directory (os error 2) -``` - -Users don't know they need to run `init-sdk` first. - -**Solution** - -In `cmd_build()`, check if project exists and auto-generate if missing: - -```rust -fn cmd_build(target: SdkTarget, ...) -> Result<()> { - let output_dir = output_dir.unwrap_or_else(|| PathBuf::from("target/mobench")); - - if matches!(target, SdkTarget::Android | SdkTarget::Both) { - let android_dir = output_dir.join("android"); - if !android_dir.join("build.gradle").exists() { - println!("Android project not found, generating scaffolding..."); - generate_android_project(&android_dir, &crate_name, &template_context)?; - } - } - - if matches!(target, SdkTarget::Ios | SdkTarget::Both) { - let ios_dir = output_dir.join("ios").join("BenchRunner"); - if !ios_dir.join("project.yml").exists() { - println!("iOS project not found, generating scaffolding..."); - generate_ios_project(&ios_dir, &crate_name, &template_context)?; - } - } - - // Continue with normal build... -} -``` - -**Files to Modify** - -- `crates/mobench/src/lib.rs` - Update `cmd_build()` function -- `crates/mobench-sdk/src/codegen.rs` - Extract `generate_android_project()` and `generate_ios_project()` to be callable separately from full `generate_project()` - -**Acceptance Criteria** - -- [ ] Running `mobench build --target android` in a fresh project generates Android scaffolding automatically -- [ ] Running `mobench build --target ios` generates iOS scaffolding automatically -- [ ] Existing projects are not overwritten - ---- - -## P1 - High Priority - -### Task 4: Process Template Variables During Build - -**Problem** - -Template files contain `{{VARIABLE}}` placeholders that are only processed during `init-sdk`. Users who run `build` on a project without `init-sdk` get broken files with literal `{{VAR}}` strings. - -**Variables Used** - -| Variable | Example Value | Description | -|----------|---------------|-------------| -| `{{PACKAGE_NAME}}` | `dev.world.bench` | Android package / iOS bundle prefix | -| `{{LIBRARY_NAME}}` | `bench_mobile` | Rust library name | -| `{{UNIFFI_NAMESPACE}}` | `bench_mobile` | UniFFI namespace (usually same as library) | -| `{{PROJECT_NAME_PASCAL}}` | `BenchRunner` | PascalCase project name | -| `{{DEFAULT_FUNCTION}}` | `my_crate::my_func` | Default benchmark function | -| `{{APP_NAME}}` | `My Bench App` | Display name | -| `{{BUNDLE_ID}}` | `dev.world.bench` | iOS bundle identifier | -| `{{BUNDLE_ID_PREFIX}}` | `dev.world` | iOS bundle prefix | - -**Solution** - -1. Create a `TemplateContext` struct with all variables -2. Extract template processing into a shared function -3. Call it from both `init-sdk` and `build` -4. Derive values from crate metadata when not explicitly configured - -```rust -pub struct TemplateContext { - pub package_name: String, - pub library_name: String, - pub uniffi_namespace: String, - pub project_name_pascal: String, - pub default_function: String, - pub app_name: String, - pub bundle_id: String, - pub bundle_id_prefix: String, -} - -impl TemplateContext { - pub fn from_crate(crate_dir: &Path) -> Result { - // Read Cargo.toml, extract [lib] name, derive other values - } -} - -pub fn process_templates(dir: &Path, ctx: &TemplateContext) -> Result<()> { - for entry in WalkDir::new(dir) { - let path = entry?.path(); - if path.is_file() { - let content = fs::read_to_string(path)?; - let processed = content - .replace("{{PACKAGE_NAME}}", &ctx.package_name) - .replace("{{LIBRARY_NAME}}", &ctx.library_name) - // ... etc - fs::write(path, processed)?; - } - } - Ok(()) -} -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/codegen.rs` - Add `TemplateContext` and `process_templates()` -- `crates/mobench-sdk/src/builders/android.rs` - Call `process_templates()` after generating scaffolding -- `crates/mobench-sdk/src/builders/ios.rs` - Same - -**Acceptance Criteria** - -- [ ] All `{{VAR}}` placeholders are replaced during `build` -- [ ] Values are derived from crate metadata when not configured -- [ ] Custom values can be provided via config file (see Task 7) - ---- - -### Task 5: Improve uniffi-bindgen Handling - -**Problem** - -Users must manually add a `[[bin]]` target for uniffi-bindgen and install it globally. This is undocumented and confusing. - -**Current Workaround (manual)** - -```toml -# In bench-mobile/Cargo.toml -[[bin]] -name = "uniffi-bindgen" -path = "src/bin/uniffi-bindgen.rs" - -[dependencies] -uniffi = { version = "0.28", features = ["cli"] } -``` - -```rust -// src/bin/uniffi-bindgen.rs -fn main() { uniffi::uniffi_bindgen_main() } -``` - -**Solution** - -1. Generate the uniffi-bindgen binary target during project scaffolding -2. Use `cargo run -p --bin uniffi-bindgen` instead of global binary - -```rust -fn run_uniffi_bindgen(crate_dir: &Path, crate_name: &str, args: &[&str]) -> Result<()> { - // Try running via cargo first (works if crate has the binary) - let cargo_result = Command::new("cargo") - .args(["run", "-p", crate_name, "--bin", "uniffi-bindgen", "--"]) - .args(args) - .current_dir(crate_dir) - .status(); - - if cargo_result.is_ok() && cargo_result.unwrap().success() { - return Ok(()); - } - - // Fall back to global uniffi-bindgen - let global_result = Command::new("uniffi-bindgen") - .args(args) - .current_dir(crate_dir) - .status()?; - - if !global_result.success() { - return Err(BenchError::Build( - "uniffi-bindgen failed. Ensure your crate has a uniffi-bindgen binary \ - or install globally with: cargo install uniffi_bindgen".into() - )); - } - - Ok(()) -} -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/codegen.rs` - Generate uniffi-bindgen binary in Cargo.toml template -- `crates/mobench-sdk/src/builders/android.rs` - Use new `run_uniffi_bindgen()` function -- `crates/mobench-sdk/src/builders/ios.rs` - Same - -**Acceptance Criteria** - -- [ ] New projects have uniffi-bindgen binary generated automatically -- [ ] Build works without global uniffi-bindgen installation -- [ ] Clear error message if uniffi-bindgen can't be found - ---- - -### Task 6: Generate Error Handling Code Dynamically - -**Problem** - -Mobile app templates hardcode error variants like `BenchException.InvalidIterations` that may not exist in the user's UniFFI schema, causing compilation failures. - -**Current Template (broken)** - -```kotlin -// MainActivity.kt -} catch (e: BenchException.InvalidIterations) { - "Error: ${e.message}" -} catch (e: BenchException.UnknownFunction) { - "Error: ${e.message}" -``` - -```swift -// BenchRunnerFFI.swift -case .InvalidIterations(let message): - return "Error: \(message)" -case .UnknownFunction(let message): - return "Error: \(message)" -``` - -**Solution** - -Use generic catch pattern that works with any error type: - -```kotlin -// MainActivity.kt -} catch (e: BenchException) { - "Error: ${e.message}" -} catch (e: Exception) { - "Unexpected error: ${e.message}" -} -``` - -```swift -// BenchRunnerFFI.swift -private static func formatBenchError(_ error: BenchError) -> String { - return "Error: \(error.localizedDescription)" -} -``` - -**Files to Modify** - -- `crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template` -- `crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template` - -**Acceptance Criteria** - -- [ ] Generated Android app compiles with any BenchError variant set -- [ ] Generated iOS app compiles with any BenchError variant set -- [ ] Error messages are still descriptive - ---- - -## P2 - Medium Priority - -### Task 7: Add Configuration File Support (mobench.toml) - -**Problem** - -Users must pass many CLI flags repeatedly. No way to persist project configuration. - -**Solution** - -Support `mobench.toml` at project root: - -```toml -[project] -crate = "bench-mobile" -library_name = "bench_mobile" - -[android] -package = "com.example.bench" -min_sdk = 24 -target_sdk = 34 - -[ios] -bundle_id = "com.example.bench" -deployment_target = "15.0" - -[benchmarks] -default_function = "my_crate::my_benchmark" -default_iterations = 100 -default_warmup = 10 -``` - -**Files to Modify** - -- `crates/mobench/src/lib.rs` - Add config file loading at startup -- `crates/mobench-sdk/src/types.rs` - Add `MobenchConfig` struct -- Create `crates/mobench/src/config.rs` module - -**Acceptance Criteria** - -- [ ] Config file is loaded automatically if present -- [ ] CLI flags override config file values -- [ ] `mobench init` can generate a starter config file - ---- - -### Task 8: Improve Error Messages - -**Problem** - -Error messages don't explain what's expected or how to fix issues. - -**Current** - -``` -build error: Benchmark crate 'bench-mobile' not found. Tried: - - "/path/bench-mobile/bench-mobile" - - "/path/bench-mobile/crates/bench-mobile" -``` - -**Improved** - -``` -build error: Benchmark crate 'bench-mobile' not found. - -Searched locations: - ✗ /path/project/bench-mobile/Cargo.toml - ✗ /path/project/crates/bench-mobile/Cargo.toml - -To fix this: - 1. Create a bench-mobile/ directory with your benchmark crate, or - 2. Use --crate-path to specify the benchmark crate location: - mobench build --target android --crate-path ./my-benchmarks - -Run 'mobench init-sdk --help' to generate a new benchmark project. -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/builders/android.rs` - All error returns -- `crates/mobench-sdk/src/builders/ios.rs` - All error returns -- `crates/mobench-sdk/src/types.rs` - Enhance `BenchError` variants with more context - -**Acceptance Criteria** - -- [x] All error messages include actionable fix suggestions -- [x] Searched paths are listed when file not found -- [x] Links to documentation where appropriate - ---- - -### Task 9: Add --crate-path Flag - -**Problem** - -mobench hardcodes looking for `bench-mobile/` or `crates/sample-fns/`. Real projects have different structures like `crates/benchmarks/`, `benches/mobile/`, etc. - -**Solution** - -Add `--crate-path` flag to `build` command: - -```rust -#[derive(Parser)] -struct Build { - #[arg(long)] - target: SdkTarget, - - /// Path to the benchmark crate (default: auto-detect bench-mobile/ or crates/sample-fns/) - #[arg(long)] - crate_path: Option, - - #[arg(long)] - release: bool, - - #[arg(long)] - output_dir: Option, -} -``` - -**Files to Modify** - -- `crates/mobench/src/lib.rs` - Add CLI argument, pass to builders -- `crates/mobench-sdk/src/builders/android.rs` - Accept optional `crate_path` in constructor -- `crates/mobench-sdk/src/builders/ios.rs` - Same - -**Acceptance Criteria** - -- [ ] `mobench build --target android --crate-path ./my-bench` works -- [ ] Auto-detection still works when `--crate-path` not provided -- [ ] Error message suggests `--crate-path` when auto-detection fails - ---- - -## P3 - Nice to Have - -### Task 10: Add --dry-run and --verbose Modes - -**Problem** - -Hard to debug what mobench is doing. Users can't preview changes before they happen. - -**Solution** - -Add global flags: - -```rust -#[derive(Parser)] -#[command(name = "mobench")] -struct Cli { - /// Print what would be done without actually doing it - #[arg(long, global = true)] - dry_run: bool, - - /// Print verbose output including all commands - #[arg(long, short = 'v', global = true)] - verbose: bool, - - #[command(subcommand)] - command: Command, -} -``` - -**Behavior** - -- `--dry-run`: Print commands that would be executed, files that would be created/modified -- `--verbose`: Print all commands as they run, show full output - -**Files to Modify** - -- `crates/mobench/src/lib.rs` - Add global flags, thread through to all functions -- `crates/mobench-sdk/src/builders/*.rs` - Respect dry_run/verbose flags - -**Acceptance Criteria** - -- [ ] `mobench build --target android --dry-run` shows what would happen without making changes -- [ ] `mobench build --target android --verbose` shows all commands being run - ---- - -### Task 11: Auto-generate local.properties for Android - -**Problem** - -Gradle fails without `sdk.dir` being set. Users must manually create `local.properties`: - -``` -SDK location not found. Define a valid SDK location with an ANDROID_HOME -environment variable or by setting the sdk.dir path in your project's -local properties file. -``` - -**Solution** - -Auto-detect Android SDK and generate `local.properties`: - -```rust -fn ensure_local_properties(android_dir: &Path) -> Result<()> { - let local_props = android_dir.join("local.properties"); - if local_props.exists() { - return Ok(()); - } - - let sdk_dir = detect_android_sdk()?; - fs::write(&local_props, format!("sdk.dir={}\n", sdk_dir.display()))?; - println!(" Generated local.properties with SDK at {}", sdk_dir.display()); - Ok(()) -} - -fn detect_android_sdk() -> Result { - // Check environment variables - if let Ok(sdk) = std::env::var("ANDROID_HOME") { - return Ok(PathBuf::from(sdk)); - } - if let Ok(sdk) = std::env::var("ANDROID_SDK_ROOT") { - return Ok(PathBuf::from(sdk)); - } - - // Check common locations - let home = dirs::home_dir().ok_or_else(|| BenchError::Build("Cannot find home directory".into()))?; - - let candidates = [ - home.join("Library/Android/sdk"), // macOS default - home.join("Android/Sdk"), // Linux default - PathBuf::from("/usr/local/android-sdk"), // Common Linux location - ]; - - for candidate in &candidates { - if candidate.exists() { - return Ok(candidate.clone()); - } - } - - Err(BenchError::Build( - "Android SDK not found. Set ANDROID_HOME environment variable or install Android Studio.".into() - )) -} -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/builders/android.rs` - Add `ensure_local_properties()`, call before Gradle - -**Acceptance Criteria** - -- [ ] `local.properties` is auto-generated if missing -- [ ] Existing `local.properties` is not overwritten -- [ ] Clear error if SDK cannot be found - ---- - -## Implementation Order - -Recommended order based on dependencies and impact: - -1. **Task 2** - Target directory detection (unblocks workspace projects) -2. **Task 3** - Auto-generate scaffolding (simplifies getting started) -3. **Task 4** - Template processing (makes generated projects work) -4. **Task 1** - aws-lc-rs fix (unblocks Android builds) -5. **Task 6** - Error handling (makes generated code compile) -6. **Task 11** - local.properties (removes manual step) -7. **Task 5** - uniffi-bindgen (removes manual step) -8. **Task 9** - --crate-path (flexibility for real projects) -9. **Task 8** - Error messages (better debugging) -10. **Task 7** - Config file (power users) -11. **Task 10** - dry-run/verbose (debugging) - -## Testing Strategy - -After implementing, verify with: - -1. **Fresh project test:** - ```bash - cargo new my-bench && cd my-bench - cargo add mobench-sdk - # Add a #[benchmark] function - mobench build --target android - # Should produce working APK - ``` - -2. **Workspace project test:** - ```bash - git clone https://github.com/worldcoin/world-id-protocol - cd world-id-protocol - mobench build --target android --crate-path ./bench-mobile - # Should produce working APK - ``` - -3. **Both platforms test:** - ```bash - mobench build --target both - # Should produce both APK and xcframework/IPA - ``` From f3cca452a9f0d7674015b58b369b076719c79575 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 20:40:17 +0100 Subject: [PATCH 027/196] Fix all DX issues from mobench 0.1.9 testing Critical bugs fixed (12): - Bugs 1-5: Template variables (PROJECT_NAME, PACKAGE_NAME, LIBRARY_NAME, PROJECT_NAME_PASCAL, APP_NAME) now properly substituted - Bug 6: Added gradle.properties template with AndroidX settings - Bug 7: Auto-generate Gradle wrapper if missing - Bug 8: Fixed AGP version from 8.13.2 to 8.2.2 - Bug 9: Package name in Kotlin templates now uses {{PACKAGE_NAME}} - Bug 10: Added x86_64-apple-ios target for Intel Mac simulators - Bug 11: Fixed path handling by canonicalizing project root - Bug 12: DEFAULT_FUNCTION now auto-detected from #[benchmark] functions High severity issues fixed (8): - Issue 1: Added post-template validation for unreplaced {{...}} patterns - Issue 2: codesign_xcframework now returns Err on failure - Issue 3: generate_xcode_project now returns Err on failure - Issue 4: Missing native libraries always warn (not just verbose) - Issue 5: Added validate_project_root() for early validation - Issue 6: cargo metadata fallback now warns when used - Issue 7: Kotlin catch block logs exceptions instead of swallowing - Issue 8: Added validate_build_artifacts() post-build validation Medium/Low issues fixed (4): - Added .gitignore templates for Android and iOS - Added README.md templates for Android and iOS New files: - templates/android/.gitignore - templates/android/README.md - templates/android/gradle.properties - templates/ios/BenchRunner/.gitignore - templates/ios/BenchRunner/README.md --- crates/mobench-sdk/src/builders/android.rs | 176 +++++- crates/mobench-sdk/src/builders/common.rs | 79 ++- crates/mobench-sdk/src/builders/ios.rs | 391 ++++++++++++-- crates/mobench-sdk/src/codegen.rs | 509 +++++++++++++++++- .../mobench-sdk/templates/android/.gitignore | 20 + .../mobench-sdk/templates/android/README.md | 24 + .../src/main/java/MainActivity.kt.template | 3 +- .../app/src/main/res/values/themes.xml | 2 +- .../templates/android/build.gradle | 2 +- .../templates/android/gradle.properties | 7 + .../templates/ios/BenchRunner/.gitignore | 18 + .../templates/ios/BenchRunner/README.md | 28 + mobench-0.1.9-dx-report.md | 388 +++++++++++++ 13 files changed, 1551 insertions(+), 96 deletions(-) create mode 100644 crates/mobench-sdk/templates/android/.gitignore create mode 100644 crates/mobench-sdk/templates/android/README.md create mode 100644 crates/mobench-sdk/templates/android/gradle.properties create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/.gitignore create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/README.md create mode 100644 mobench-0.1.9-dx-report.md diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index f46d67c..c985555 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -56,7 +56,7 @@ //! ``` use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; -use super::common::{get_cargo_target_dir, host_lib_path, run_command}; +use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -171,6 +171,11 @@ impl AndroidBuilder { /// * `Ok(BuildResult)` containing the path to the built APK /// * `Err(BenchError)` if the build fails pub fn build(&self, config: &BuildConfig) -> Result { + // Validate project root before starting build + if self.crate_dir.is_none() { + validate_project_root(&self.project_root, &self.crate_name)?; + } + let android_dir = self.output_dir.join("android"); let profile_name = match config.profile { BuildProfile::Debug => "debug", @@ -180,6 +185,7 @@ impl AndroidBuilder { if self.dry_run { println!("\n[dry-run] Android build plan:"); println!(" Step 0: Check/generate Android project scaffolding at {:?}", android_dir); + println!(" Step 0.5: Ensure Gradle wrapper exists (run 'gradle wrapper' if needed)"); println!(" Step 1: Build Rust libraries for Android ABIs (arm64-v8a, armeabi-v7a, x86_64)"); println!(" Command: cargo ndk --target --platform 24 build {}", if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); @@ -202,7 +208,16 @@ impl AndroidBuilder { } // Step 0: Ensure Android project scaffolding exists - crate::codegen::ensure_android_project(&self.output_dir, &self.crate_name)?; + // Pass project_root and crate_dir for better benchmark function detection + crate::codegen::ensure_android_project_with_options( + &self.output_dir, + &self.crate_name, + Some(&self.project_root), + self.crate_dir.as_deref(), + )?; + + // Step 0.5: Ensure Gradle wrapper exists + self.ensure_gradle_wrapper(&android_dir)?; // Step 1: Build Rust libraries println!("Building Rust libraries for Android..."); @@ -224,11 +239,70 @@ impl AndroidBuilder { println!("Building Android test APK..."); let test_suite_path = self.build_test_apk(config)?; - Ok(BuildResult { + // Step 6: Validate all expected artifacts exist + let result = BuildResult { platform: Target::Android, app_path: apk_path, test_suite_path: Some(test_suite_path), - }) + }; + self.validate_build_artifacts(&result, config)?; + + Ok(result) + } + + /// Validates that all expected build artifacts exist after a successful build + fn validate_build_artifacts(&self, result: &BuildResult, config: &BuildConfig) -> Result<(), BenchError> { + let mut missing = Vec::new(); + let profile_dir = match config.profile { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + }; + + // Check main APK + if !result.app_path.exists() { + missing.push(format!("Main APK: {}", result.app_path.display())); + } + + // Check test APK + if let Some(ref test_path) = result.test_suite_path { + if !test_path.exists() { + missing.push(format!("Test APK: {}", test_path.display())); + } + } + + // Check that at least one native library exists in jniLibs + let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs"); + let lib_name = format!("lib{}.so", self.crate_name.replace("-", "_")); + let required_abis = ["arm64-v8a", "armeabi-v7a", "x86_64"]; + let mut found_libs = 0; + for abi in &required_abis { + let lib_path = jni_libs_dir.join(abi).join(&lib_name); + if lib_path.exists() { + found_libs += 1; + } else { + missing.push(format!("Native library ({} {}): {}", abi, profile_dir, lib_path.display())); + } + } + + if found_libs == 0 { + return Err(BenchError::Build(format!( + "Build validation failed: No native libraries found.\n\n\ + Expected at least one .so file in jniLibs directories.\n\ + Missing artifacts:\n{}\n\n\ + This usually means the Rust build step failed. Check the cargo-ndk output above.", + missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + ))); + } + + if !missing.is_empty() { + eprintln!( + "Warning: Some build artifacts are missing:\n{}\n\ + The build may still work but some features might be unavailable.", + missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + ); + } + + Ok(()) } /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) @@ -559,8 +633,15 @@ impl AndroidBuilder { if self.verbose { println!(" Copied {} -> {}", src.display(), dest.display()); } - } else if self.verbose { - println!(" Warning: {} not found, skipping", src.display()); + } else { + // Always warn about missing native libraries - this will cause runtime crashes + eprintln!( + "Warning: Native library for {} not found at {}.\n\ + This will cause a runtime crash when the app tries to load the library.\n\ + Ensure cargo-ndk build completed successfully for this ABI.", + android_abi, + src.display() + ); } } @@ -657,6 +738,89 @@ impl AndroidBuilder { ))) } + /// Ensures the Gradle wrapper (gradlew) exists in the Android project + /// + /// If gradlew doesn't exist, this runs `gradle wrapper --gradle-version 8.5` + /// to generate the wrapper files. + fn ensure_gradle_wrapper(&self, android_dir: &Path) -> Result<(), BenchError> { + let gradlew = android_dir.join("gradlew"); + + // If gradlew already exists, we're good + if gradlew.exists() { + return Ok(()); + } + + println!("Gradle wrapper not found, generating..."); + + // Check if gradle is available + let gradle_available = Command::new("gradle") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !gradle_available { + return Err(BenchError::Build( + "Gradle wrapper (gradlew) not found and 'gradle' command is not available.\n\n\ + The Android project requires Gradle to build. You have two options:\n\n\ + 1. Install Gradle globally and run the build again (it will auto-generate the wrapper):\n\ + - macOS: brew install gradle\n\ + - Linux: sudo apt install gradle\n\ + - Or download from https://gradle.org/install/\n\n\ + 2. Or generate the wrapper manually in the Android project directory:\n\ + cd target/mobench/android && gradle wrapper --gradle-version 8.5" + .to_string(), + )); + } + + // Run gradle wrapper to generate gradlew + let mut cmd = Command::new("gradle"); + cmd.arg("wrapper") + .arg("--gradle-version") + .arg("8.5") + .current_dir(android_dir); + + let output = cmd.output().map_err(|e| { + BenchError::Build(format!( + "Failed to run 'gradle wrapper' command: {}\n\n\ + Ensure Gradle is installed and on your PATH.", + e + )) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "Failed to generate Gradle wrapper.\n\n\ + Command: gradle wrapper --gradle-version 8.5\n\ + Working directory: {}\n\ + Exit status: {}\n\ + Stderr: {}\n\n\ + Try running this command manually in the Android project directory.", + android_dir.display(), + output.status, + stderr + ))); + } + + // Make gradlew executable on Unix systems + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(metadata) = fs::metadata(&gradlew) { + let mut perms = metadata.permissions(); + perms.set_mode(0o755); + let _ = fs::set_permissions(&gradlew, perms); + } + } + + if self.verbose { + println!(" Generated Gradle wrapper at {:?}", gradlew); + } + + Ok(()) + } + /// Builds the Android APK using Gradle fn build_apk(&self, config: &BuildConfig) -> Result { let android_dir = self.output_dir.join("android"); diff --git a/crates/mobench-sdk/src/builders/common.rs b/crates/mobench-sdk/src/builders/common.rs index 4b878e4..5b19309 100644 --- a/crates/mobench-sdk/src/builders/common.rs +++ b/crates/mobench-sdk/src/builders/common.rs @@ -23,6 +23,60 @@ use std::process::Command; use crate::types::BenchError; +/// Validates that the project root is a valid directory for building. +/// +/// This function checks that: +/// - The path exists +/// - The path is a directory +/// - The directory contains a Cargo.toml file (or has a crate directory with one) +/// +/// # Arguments +/// * `project_root` - The project root directory to validate +/// * `crate_name` - The name of the crate being built (used to check crate directories) +/// +/// # Returns +/// `Ok(())` if validation passes, or a descriptive `BenchError` if it fails. +pub fn validate_project_root(project_root: &Path, crate_name: &str) -> Result<(), BenchError> { + // Check if path exists + if !project_root.exists() { + return Err(BenchError::Build(format!( + "Project root does not exist: {}\n\n\ + Ensure you are running from the correct directory or specify --project-root.", + project_root.display() + ))); + } + + // Check if path is a directory + if !project_root.is_dir() { + return Err(BenchError::Build(format!( + "Project root is not a directory: {}\n\n\ + Expected a directory containing your Rust project.", + project_root.display() + ))); + } + + // Check for Cargo.toml in project root or standard crate locations + let root_cargo = project_root.join("Cargo.toml"); + let bench_mobile_cargo = project_root.join("bench-mobile/Cargo.toml"); + let crates_cargo = project_root.join(format!("crates/{}/Cargo.toml", crate_name)); + + if !root_cargo.exists() && !bench_mobile_cargo.exists() && !crates_cargo.exists() { + return Err(BenchError::Build(format!( + "No Cargo.toml found in project root or expected crate locations.\n\n\ + Searched:\n\ + - {}\n\ + - {}\n\ + - {}\n\n\ + Ensure you are in a Rust project directory or use --crate-path to specify the crate location.", + root_cargo.display(), + bench_mobile_cargo.display(), + crates_cargo.display() + ))); + } + + Ok(()) +} + /// Detects the actual Cargo target directory using `cargo metadata`. /// /// This correctly handles Cargo workspaces where the target directory @@ -33,6 +87,10 @@ use crate::types::BenchError; /// /// # Returns /// The path to the target directory, or falls back to `crate_dir/target` if detection fails. +/// +/// # Warnings +/// Prints a warning to stderr if falling back to the default target directory due to +/// cargo metadata failures or parsing issues. pub fn get_cargo_target_dir(crate_dir: &Path) -> Result { let output = Command::new("cargo") .args(["metadata", "--format-version", "1", "--no-deps"]) @@ -51,7 +109,17 @@ pub fn get_cargo_target_dir(crate_dir: &Path) -> Result { if !output.status.success() { // Fall back to crate_dir/target if cargo metadata fails - return Ok(crate_dir.join("target")); + let fallback = crate_dir.join("target"); + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!( + "Warning: cargo metadata failed (exit {}), falling back to {}.\n\ + Stderr: {}\n\ + This may cause build issues if you are in a Cargo workspace.", + output.status, + fallback.display(), + stderr.lines().take(3).collect::>().join("\n") + ); + return Ok(fallback); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -69,7 +137,14 @@ pub fn get_cargo_target_dir(crate_dir: &Path) -> Result { } // Fall back to crate_dir/target if parsing fails - Ok(crate_dir.join("target")) + let fallback = crate_dir.join("target"); + eprintln!( + "Warning: Failed to parse target_directory from cargo metadata output, \ + falling back to {}.\n\ + This may cause build issues if you are in a Cargo workspace.", + fallback.display() + ); + Ok(fallback) } /// Finds the host library path for UniFFI binding generation. diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 233e114..626c08c 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -18,7 +18,7 @@ //! ## Requirements //! //! - Xcode with command line tools (`xcode-select --install`) -//! - Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim` +//! - Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios` //! - `uniffi-bindgen` for Swift binding generation //! - `xcodegen` (optional, `brew install xcodegen`) //! @@ -71,7 +71,7 @@ //! ``` use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; -use super::common::{get_cargo_target_dir, host_lib_path, run_command}; +use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -125,10 +125,14 @@ impl IosBuilder { /// /// # Arguments /// - /// * `project_root` - Root directory containing the bench-mobile crate + /// * `project_root` - Root directory containing the bench-mobile crate. This path + /// will be canonicalized to ensure consistent behavior regardless of the current + /// working directory. /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile") pub fn new(project_root: impl Into, crate_name: impl Into) -> Self { - let root = project_root.into(); + let root_input = project_root.into(); + // Canonicalize the path to handle relative paths correctly, regardless of cwd + let root = root_input.canonicalize().unwrap_or(root_input); Self { output_dir: root.join("target/mobench"), project_root: root, @@ -190,6 +194,11 @@ impl IosBuilder { /// * `Ok(BuildResult)` containing the path to the xcframework /// * `Err(BenchError)` if the build fails pub fn build(&self, config: &BuildConfig) -> Result { + // Validate project root before starting build + if self.crate_dir.is_none() { + validate_project_root(&self.project_root, &self.crate_name)?; + } + let framework_name = self.crate_name.replace("-", "_"); let ios_dir = self.output_dir.join("ios"); let xcframework_path = ios_dir.join(format!("{}.xcframework", framework_name)); @@ -202,11 +211,13 @@ impl IosBuilder { if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); println!(" Command: cargo build --target aarch64-apple-ios-sim --lib {}", if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); + println!(" Command: cargo build --target x86_64-apple-ios --lib {}", + if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); println!(" Step 2: Generate UniFFI Swift bindings"); println!(" Output: {:?}", ios_dir.join("BenchRunner/BenchRunner/Generated")); println!(" Step 3: Create xcframework at {:?}", xcframework_path); println!(" - ios-arm64/{}.framework (device)", framework_name); - println!(" - ios-simulator-arm64/{}.framework (simulator)", framework_name); + println!(" - ios-arm64_x86_64-simulator/{}.framework (simulator - arm64 + x86_64 lipo)", framework_name); println!(" Step 4: Code-sign xcframework"); println!(" Command: codesign --force --deep --sign - {:?}", xcframework_path); println!(" Step 5: Generate Xcode project with xcodegen (if project.yml exists)"); @@ -221,7 +232,13 @@ impl IosBuilder { } // Step 0: Ensure iOS project scaffolding exists - crate::codegen::ensure_ios_project(&self.output_dir, &self.crate_name)?; + // Pass project_root and crate_dir for better benchmark function detection + crate::codegen::ensure_ios_project_with_options( + &self.output_dir, + &self.crate_name, + Some(&self.project_root), + self.crate_dir.as_deref(), + )?; // Step 1: Build Rust libraries println!("Building Rust libraries for iOS..."); @@ -267,11 +284,92 @@ impl IosBuilder { // Step 5: Generate Xcode project if needed self.generate_xcode_project()?; - Ok(BuildResult { + // Step 6: Validate all expected artifacts exist + let result = BuildResult { platform: Target::Ios, app_path: xcframework_path, test_suite_path: None, - }) + }; + self.validate_build_artifacts(&result, config)?; + + Ok(result) + } + + /// Validates that all expected build artifacts exist after a successful build + fn validate_build_artifacts(&self, result: &BuildResult, config: &BuildConfig) -> Result<(), BenchError> { + let mut missing = Vec::new(); + let framework_name = self.crate_name.replace("-", "_"); + let profile_dir = match config.profile { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + }; + + // Check xcframework exists + if !result.app_path.exists() { + missing.push(format!("XCFramework: {}", result.app_path.display())); + } + + // Check framework slices exist within xcframework + let xcframework_path = &result.app_path; + let device_slice = xcframework_path.join(format!("ios-arm64/{}.framework", framework_name)); + // Combined simulator slice with arm64 + x86_64 + let sim_slice = xcframework_path.join(format!("ios-arm64_x86_64-simulator/{}.framework", framework_name)); + + if xcframework_path.exists() { + if !device_slice.exists() { + missing.push(format!("Device framework slice: {}", device_slice.display())); + } + if !sim_slice.exists() { + missing.push(format!("Simulator framework slice (arm64+x86_64): {}", sim_slice.display())); + } + } + + // Check that static libraries were built + let crate_dir = self.find_crate_dir()?; + let target_dir = get_cargo_target_dir(&crate_dir)?; + let lib_name = format!("lib{}.a", framework_name); + + let device_lib = target_dir.join("aarch64-apple-ios").join(profile_dir).join(&lib_name); + let sim_arm64_lib = target_dir.join("aarch64-apple-ios-sim").join(profile_dir).join(&lib_name); + let sim_x86_64_lib = target_dir.join("x86_64-apple-ios").join(profile_dir).join(&lib_name); + + if !device_lib.exists() { + missing.push(format!("Device static library: {}", device_lib.display())); + } + if !sim_arm64_lib.exists() { + missing.push(format!("Simulator (arm64) static library: {}", sim_arm64_lib.display())); + } + if !sim_x86_64_lib.exists() { + missing.push(format!("Simulator (x86_64) static library: {}", sim_x86_64_lib.display())); + } + + // Check Swift bindings + let swift_bindings = self.output_dir + .join("ios/BenchRunner/BenchRunner/Generated") + .join(format!("{}.swift", framework_name)); + if !swift_bindings.exists() { + missing.push(format!("Swift bindings: {}", swift_bindings.display())); + } + + if !missing.is_empty() { + let critical = missing.iter().any(|m| m.contains("XCFramework") || m.contains("static library")); + if critical { + return Err(BenchError::Build(format!( + "Build validation failed: Critical artifacts are missing.\n\n\ + Missing artifacts:\n{}\n\n\ + This usually means the Rust build step failed. Check the cargo build output above.", + missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + ))); + } else { + eprintln!( + "Warning: Some build artifacts are missing:\n{}\n\ + The build may still work but some features might be unavailable.", + missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + ); + } + } + + Ok(()) } /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) @@ -326,10 +424,11 @@ impl IosBuilder { fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> { let crate_dir = self.find_crate_dir()?; - // iOS targets: device and simulator + // iOS targets: device and simulator (both arm64 and x86_64 for Intel Macs) let targets = vec![ "aarch64-apple-ios", // Device (ARM64) - "aarch64-apple-ios-sim", // Simulator (M1+ Macs) + "aarch64-apple-ios-sim", // Simulator (Apple Silicon Macs) + "x86_64-apple-ios", // Simulator (Intel Macs) ]; // Check if targets are installed @@ -426,9 +525,10 @@ impl IosBuilder { This target is required to compile for iOS.\n\n\ To install:\n\ rustup target add {}\n\n\ - For a complete iOS setup, you need both:\n\ + For a complete iOS setup, you need all three:\n\ rustup target add aarch64-apple-ios # Device\n\ - rustup target add aarch64-apple-ios-sim # Simulator (Apple Silicon)", + rustup target add aarch64-apple-ios-sim # Simulator (Apple Silicon)\n\ + rustup target add x86_64-apple-ios # Simulator (Intel Macs)", target, target ))); } @@ -576,6 +676,7 @@ impl IosBuilder { })?; // Build framework structure for each platform + // Device slice (arm64 only) self.create_framework_slice( &target_dir.join("aarch64-apple-ios").join(profile_dir), &xcframework_path.join("ios-arm64"), @@ -583,11 +684,12 @@ impl IosBuilder { "ios", )?; - self.create_framework_slice( - &target_dir.join("aarch64-apple-ios-sim").join(profile_dir), - &xcframework_path.join("ios-simulator-arm64"), + // Simulator slice (arm64 + x86_64 combined via lipo for both Apple Silicon and Intel Macs) + self.create_simulator_framework_slice( + &target_dir, + profile_dir, + &xcframework_path.join("ios-arm64_x86_64-simulator"), framework_name, - "ios-simulator", )?; // Create xcframework Info.plist @@ -676,6 +778,137 @@ impl IosBuilder { Ok(()) } + /// Creates a combined simulator framework slice with arm64 + x86_64 using lipo + fn create_simulator_framework_slice( + &self, + target_dir: &Path, + profile_dir: &str, + output_dir: &Path, + framework_name: &str, + ) -> Result<(), BenchError> { + let framework_dir = output_dir.join(format!("{}.framework", framework_name)); + let headers_dir = framework_dir.join("Headers"); + + // Create directories + fs::create_dir_all(&headers_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to create framework directories at {}: {}. Check output directory permissions.", + headers_dir.display(), + e + )) + })?; + + // Paths to the simulator libraries + let arm64_lib = target_dir + .join("aarch64-apple-ios-sim") + .join(profile_dir) + .join(format!("lib{}.a", framework_name)); + let x86_64_lib = target_dir + .join("x86_64-apple-ios") + .join(profile_dir) + .join(format!("lib{}.a", framework_name)); + + // Check that both libraries exist + if !arm64_lib.exists() { + return Err(BenchError::Build(format!( + "Simulator library (arm64) not found at {}.\n\n\ + Expected output from cargo build --target aarch64-apple-ios-sim --lib.\n\ + Ensure your crate has [lib] crate-type = [\"staticlib\"].", + arm64_lib.display() + ))); + } + if !x86_64_lib.exists() { + return Err(BenchError::Build(format!( + "Simulator library (x86_64) not found at {}.\n\n\ + Expected output from cargo build --target x86_64-apple-ios --lib.\n\ + Ensure your crate has [lib] crate-type = [\"staticlib\"].", + x86_64_lib.display() + ))); + } + + // Use lipo to combine arm64 and x86_64 into a universal binary + let dest_lib = framework_dir.join(framework_name); + let output = Command::new("lipo") + .arg("-create") + .arg(&arm64_lib) + .arg(&x86_64_lib) + .arg("-output") + .arg(&dest_lib) + .output() + .map_err(|e| { + BenchError::Build(format!( + "Failed to run lipo to create universal simulator binary.\n\n\ + Command: lipo -create {} {} -output {}\n\ + Error: {}\n\n\ + Ensure Xcode command line tools are installed: xcode-select --install", + arm64_lib.display(), + x86_64_lib.display(), + dest_lib.display(), + e + )) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "lipo failed to create universal simulator binary.\n\n\ + Command: lipo -create {} {} -output {}\n\ + Exit status: {}\n\ + Stderr: {}\n\n\ + Ensure both libraries are valid static libraries.", + arm64_lib.display(), + x86_64_lib.display(), + dest_lib.display(), + output.status, + stderr + ))); + } + + if self.verbose { + println!( + " Created universal simulator binary (arm64 + x86_64) at {:?}", + dest_lib + ); + } + + // Copy UniFFI-generated header into the framework + let header_name = format!("{}FFI.h", framework_name); + let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| { + BenchError::Build(format!( + "UniFFI header {} not found; run binding generation before building", + header_name + )) + })?; + let dest_header = headers_dir.join(&header_name); + fs::copy(&header_path, &dest_header).map_err(|e| { + BenchError::Build(format!( + "Failed to copy UniFFI header from {} to {}: {}. Check output directory permissions.", + header_path.display(), + dest_header.display(), + e + )) + })?; + + // Create module.modulemap + let modulemap_content = format!( + "framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}", + framework_name, framework_name + ); + let modulemap_path = headers_dir.join("module.modulemap"); + fs::write(&modulemap_path, modulemap_content).map_err(|e| { + BenchError::Build(format!( + "Failed to write module.modulemap at {}: {}. Check output directory permissions.", + modulemap_path.display(), + e + )) + })?; + + // Create framework Info.plist (uses "ios-simulator" platform) + self.create_framework_plist(&framework_dir, framework_name, "ios-simulator")?; + + Ok(()) + } + /// Creates Info.plist for a framework slice fn create_framework_plist( &self, @@ -758,12 +991,13 @@ impl IosBuilder { LibraryIdentifier - ios-simulator-arm64 + ios-arm64_x86_64-simulator LibraryPath {}.framework SupportedArchitectures arm64 + x86_64 SupportedPlatform ios @@ -793,6 +1027,11 @@ impl IosBuilder { } /// Code-signs the xcframework + /// + /// # Errors + /// + /// Returns an error if codesign is not available or if signing fails. + /// The xcframework must be signed for Xcode to accept it. fn codesign_xcframework(&self, xcframework_path: &Path) -> Result<(), BenchError> { let output = Command::new("codesign") .arg("--force") @@ -800,30 +1039,52 @@ impl IosBuilder { .arg("--sign") .arg("-") .arg(xcframework_path) - .output(); + .output() + .map_err(|e| { + BenchError::Build(format!( + "Failed to run codesign.\n\n\ + XCFramework: {}\n\ + Error: {}\n\n\ + Ensure Xcode command line tools are installed:\n\ + xcode-select --install\n\n\ + The xcframework must be signed for Xcode to accept it.", + xcframework_path.display(), + e + )) + })?; - match output { - Ok(output) if output.status.success() => { - if self.verbose { - println!(" Successfully code-signed xcframework"); - } - Ok(()) - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - println!("Warning: Code signing failed: {}", stderr); - println!("You may need to manually sign the xcframework"); - Ok(()) // Don't fail the build for signing issues - } - Err(e) => { - println!("Warning: Could not run codesign: {}", e); - println!("You may need to manually sign the xcframework"); - Ok(()) // Don't fail the build if codesign is not available + if output.status.success() { + if self.verbose { + println!(" Successfully code-signed xcframework"); } + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(BenchError::Build(format!( + "codesign failed to sign xcframework.\n\n\ + XCFramework: {}\n\ + Exit status: {}\n\ + Stderr: {}\n\n\ + Ensure you have valid signing credentials:\n\ + security find-identity -v -p codesigning\n\n\ + For ad-hoc signing (most common), the '-' identity should work.\n\ + If signing continues to fail, check that the xcframework structure is valid.", + xcframework_path.display(), + output.status, + stderr + ))) } } /// Generates Xcode project using xcodegen if project.yml exists + /// + /// # Errors + /// + /// Returns an error if: + /// - xcodegen is not installed and project.yml exists + /// - xcodegen execution fails + /// + /// If project.yml does not exist, this function returns Ok(()) silently. fn generate_xcode_project(&self) -> Result<(), BenchError> { let ios_dir = self.output_dir.join("ios"); let project_yml = ios_dir.join("BenchRunner/project.yml"); @@ -843,32 +1104,46 @@ impl IosBuilder { let output = Command::new("xcodegen") .arg("generate") .current_dir(&project_dir) - .output(); - - match output { - Ok(output) if output.status.success() => Ok(()), - Ok(output) => { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - Err(BenchError::Build(format!( - "xcodegen failed.\n\n\ - Command: xcodegen generate\n\ + .output() + .map_err(|e| { + BenchError::Build(format!( + "Failed to run xcodegen.\n\n\ + project.yml found at: {}\n\ Working directory: {}\n\ - Exit status: {}\n\n\ - Stdout:\n{}\n\n\ - Stderr:\n{}\n\n\ - Tip: install xcodegen with: brew install xcodegen", + Error: {}\n\n\ + xcodegen is required to generate the Xcode project.\n\ + Install it with:\n\ + brew install xcodegen\n\n\ + After installation, re-run the build.", + project_yml.display(), project_dir.display(), - output.status, - stdout, - stderr - ))) - } - Err(e) => { - println!("Warning: xcodegen not found or failed: {}", e); - println!("Install xcodegen with: brew install xcodegen"); - Ok(()) // Don't fail if xcodegen is not available + e + )) + })?; + + if output.status.success() { + if self.verbose { + println!(" Successfully generated Xcode project"); } + Ok(()) + } else { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + Err(BenchError::Build(format!( + "xcodegen failed.\n\n\ + Command: xcodegen generate\n\ + Working directory: {}\n\ + Exit status: {}\n\n\ + Stdout:\n{}\n\n\ + Stderr:\n{}\n\n\ + Check that project.yml is valid YAML and has correct xcodegen syntax.\n\ + Try running 'xcodegen generate' manually in {} for more details.", + project_dir.display(), + output.status, + stdout, + stderr, + project_dir.display() + ))) } } diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 2215fe7..f2889d3 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -5,6 +5,7 @@ use crate::types::{BenchError, InitConfig, Target}; use std::fs; +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use include_dir::{Dir, DirEntry, include_dir}; @@ -47,17 +48,21 @@ pub fn generate_project(config: &InitConfig) -> Result { // Generate bench-mobile FFI wrapper crate generate_bench_mobile_crate(output_dir, &project_slug)?; + // For full project generation (init), use "example_fibonacci" as the default + // since the generated example benchmarks include this function + let default_function = "example_fibonacci"; + // Generate platform-specific projects match config.target { Target::Android => { - generate_android_project(output_dir, &project_slug)?; + generate_android_project(output_dir, &project_slug, default_function)?; } Target::Ios => { - generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix)?; + generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?; } Target::Both => { - generate_android_project(output_dir, &project_slug)?; - generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix)?; + generate_android_project(output_dir, &project_slug, default_function)?; + generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?; } } @@ -275,24 +280,43 @@ uniffi::setup_scaffolding!(); /// /// * `output_dir` - Directory to write the `android/` project into /// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile") -pub fn generate_android_project(output_dir: &Path, project_slug: &str) -> Result<(), BenchError> { +/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark") +pub fn generate_android_project( + output_dir: &Path, + project_slug: &str, + default_function: &str, +) -> Result<(), BenchError> { let target_dir = output_dir.join("android"); + let library_name = project_slug.replace('-', "_"); + let project_pascal = to_pascal_case(project_slug); let vars = vec![ + TemplateVar { + name: "PROJECT_NAME", + value: project_slug.to_string(), + }, + TemplateVar { + name: "PROJECT_NAME_PASCAL", + value: project_pascal.clone(), + }, + TemplateVar { + name: "APP_NAME", + value: format!("{} Benchmark", project_pascal), + }, TemplateVar { name: "PACKAGE_NAME", value: format!("dev.world.{}", project_slug), }, TemplateVar { name: "UNIFFI_NAMESPACE", - value: project_slug.replace('-', "_"), + value: library_name.clone(), }, TemplateVar { name: "LIBRARY_NAME", - value: project_slug.replace('-', "_"), + value: library_name, }, TemplateVar { name: "DEFAULT_FUNCTION", - value: "example_fibonacci".to_string(), + value: default_function.to_string(), }, ]; render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?; @@ -310,17 +334,19 @@ pub fn generate_android_project(output_dir: &Path, project_slug: &str) -> Result /// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile") /// * `project_pascal` - PascalCase version of project name (e.g., "BenchMobile") /// * `bundle_prefix` - iOS bundle ID prefix (e.g., "dev.world.bench") +/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark") pub fn generate_ios_project( output_dir: &Path, project_slug: &str, project_pascal: &str, bundle_prefix: &str, + default_function: &str, ) -> Result<(), BenchError> { let target_dir = output_dir.join("ios"); let vars = vec![ TemplateVar { name: "DEFAULT_FUNCTION", - value: "example_fibonacci".to_string(), + value: default_function.to_string(), }, TemplateVar { name: "PROJECT_NAME_PASCAL", @@ -429,6 +455,12 @@ fn fibonacci(n: u32) -> u64 { Ok(()) } +/// File extensions that should be processed for template variable substitution +const TEMPLATE_EXTENSIONS: &[&str] = &[ + "gradle", "xml", "kt", "java", "swift", "yml", "yaml", "json", "toml", "md", "txt", "h", "m", + "plist", "pbxproj", "xcscheme", "xcworkspacedata", "entitlements", "modulemap", +]; + fn render_dir( dir: &Dir, out_root: &Path, @@ -450,22 +482,30 @@ fn render_dir( // file.path() returns the full relative path from the embedded dir root let mut relative = file.path().to_path_buf(); let mut contents = file.contents().to_vec(); - if let Some(ext) = relative.extension() - && ext == "template" - { + + // Check if file has .template extension (explicit template) + let is_explicit_template = relative + .extension() + .map(|ext| ext == "template") + .unwrap_or(false); + + // Check if file is a text file that should be processed for templates + let should_render = is_explicit_template || is_template_file(&relative); + + if is_explicit_template { + // Remove .template extension from output filename relative.set_extension(""); - let rendered = render_template( - std::str::from_utf8(&contents).map_err(|e| { - BenchError::Build(format!( - "invalid UTF-8 in template {:?}: {}", - file.path(), - e - )) - })?, - vars, - ); - contents = rendered.into_bytes(); } + + if should_render { + if let Ok(text) = std::str::from_utf8(&contents) { + let rendered = render_template(text, vars); + // Validate that all template variables were replaced + validate_no_unreplaced_placeholders(&rendered, &relative)?; + contents = rendered.into_bytes(); + } + } + let out_path = out_root.join(relative); if let Some(parent) = out_path.parent() { fs::create_dir_all(parent)?; @@ -477,6 +517,66 @@ fn render_dir( Ok(()) } +/// Checks if a file should be processed for template variable substitution +/// based on its extension +fn is_template_file(path: &Path) -> bool { + // Check for .template extension on any file + if let Some(ext) = path.extension() { + if ext == "template" { + return true; + } + // Check if the base extension is in our list + if let Some(ext_str) = ext.to_str() { + return TEMPLATE_EXTENSIONS.contains(&ext_str); + } + } + // Also check the filename without the .template extension + if let Some(stem) = path.file_stem() { + let stem_path = Path::new(stem); + if let Some(ext) = stem_path.extension() { + if let Some(ext_str) = ext.to_str() { + return TEMPLATE_EXTENSIONS.contains(&ext_str); + } + } + } + false +} + +/// Validates that no unreplaced template placeholders remain in the rendered content +fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> { + // Find all {{...}} patterns + let mut pos = 0; + let mut unreplaced = Vec::new(); + + while let Some(start) = content[pos..].find("{{") { + let abs_start = pos + start; + if let Some(end) = content[abs_start..].find("}}") { + let placeholder = &content[abs_start..abs_start + end + 2]; + // Extract just the variable name + let var_name = &content[abs_start + 2..abs_start + end]; + // Skip placeholders that look like Gradle variable syntax (e.g., ${...}) + // or other non-template patterns + if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() { + unreplaced.push(placeholder.to_string()); + } + pos = abs_start + end + 2; + } else { + break; + } + } + + if !unreplaced.is_empty() { + return Err(BenchError::Build(format!( + "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\ + This is a bug in mobench-sdk. Please report it at:\n\ + https://github.com/worldcoin/mobile-bench-rs/issues", + file_path, unreplaced + ))); + } + + Ok(()) +} + fn render_template(input: &str, vars: &[TemplateVar]) -> String { let mut output = input.to_string(); for var in vars { @@ -528,37 +628,193 @@ pub fn ios_project_exists(output_dir: &Path) -> bool { output_dir.join("ios/BenchRunner/project.yml").exists() } +/// Detects the first benchmark function in a crate by scanning src/lib.rs for `#[benchmark]` +/// +/// This function looks for functions marked with the `#[benchmark]` attribute and returns +/// the first one found in the format `{crate_name}::{function_name}`. +/// +/// # Arguments +/// +/// * `crate_dir` - Path to the crate directory containing Cargo.toml +/// * `crate_name` - Name of the crate (used as prefix for the function name) +/// +/// # Returns +/// +/// * `Some(String)` - The detected function name in format `crate_name::function_name` +/// * `None` - If no benchmark functions are found or if the file cannot be read +pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option { + let lib_rs = crate_dir.join("src/lib.rs"); + if !lib_rs.exists() { + return None; + } + + let file = fs::File::open(&lib_rs).ok()?; + let reader = BufReader::new(file); + + let mut found_benchmark_attr = false; + let crate_name_normalized = crate_name.replace('-', "_"); + + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + + // Check for #[benchmark] attribute + if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") { + found_benchmark_attr = true; + continue; + } + + // If we found a benchmark attribute, look for the function definition + if found_benchmark_attr { + // Look for "fn function_name" or "pub fn function_name" + if let Some(fn_pos) = trimmed.find("fn ") { + let after_fn = &trimmed[fn_pos + 3..]; + // Extract function name (until '(' or whitespace) + let fn_name: String = after_fn + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + + if !fn_name.is_empty() { + return Some(format!("{}::{}", crate_name_normalized, fn_name)); + } + } + // Reset if we hit a line that's not a function definition + // (could be another attribute or comment) + if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() { + found_benchmark_attr = false; + } + } + } + + None +} + +/// Resolves the default benchmark function for a project +/// +/// This function attempts to auto-detect benchmark functions from the crate's source. +/// If no benchmarks are found, it falls back to a sensible default based on the crate name. +/// +/// # Arguments +/// +/// * `project_root` - Root directory of the project +/// * `crate_name` - Name of the benchmark crate +/// * `crate_dir` - Optional explicit crate directory (if None, will search standard locations) +/// +/// # Returns +/// +/// The default function name in format `crate_name::function_name` +pub fn resolve_default_function( + project_root: &Path, + crate_name: &str, + crate_dir: Option<&Path>, +) -> String { + let crate_name_normalized = crate_name.replace('-', "_"); + + // Try to find the crate directory + let search_dirs: Vec = if let Some(dir) = crate_dir { + vec![dir.to_path_buf()] + } else { + vec![ + project_root.join("bench-mobile"), + project_root.join("crates").join(crate_name), + project_root.to_path_buf(), + ] + }; + + // Try to detect benchmarks from each potential location + for dir in &search_dirs { + if dir.join("Cargo.toml").exists() { + if let Some(detected) = detect_default_function(dir, &crate_name_normalized) { + return detected; + } + } + } + + // Fallback: use a sensible default based on crate name + format!("{}::example_benchmark", crate_name_normalized) +} + /// Auto-generates Android project scaffolding from a crate name /// /// This is a convenience function that derives template variables from the -/// crate name and generates the Android project structure. +/// crate name and generates the Android project structure. It auto-detects +/// the default benchmark function from the crate's source code. /// /// # Arguments /// /// * `output_dir` - Directory to write the `android/` project into /// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile") pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> { + ensure_android_project_with_options(output_dir, crate_name, None, None) +} + +/// Auto-generates Android project scaffolding with additional options +/// +/// This is a more flexible version of `ensure_android_project` that allows +/// specifying a custom default function and/or crate directory. +/// +/// # Arguments +/// +/// * `output_dir` - Directory to write the `android/` project into +/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile") +/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent) +/// * `crate_dir` - Optional explicit crate directory for benchmark detection +pub fn ensure_android_project_with_options( + output_dir: &Path, + crate_name: &str, + project_root: Option<&Path>, + crate_dir: Option<&Path>, +) -> Result<(), BenchError> { if android_project_exists(output_dir) { return Ok(()); } println!("Android project not found, generating scaffolding..."); let project_slug = crate_name.replace('-', "_"); - generate_android_project(output_dir, &project_slug)?; + + // Resolve the default function by auto-detecting from source + let effective_root = project_root.unwrap_or_else(|| { + output_dir.parent().unwrap_or(output_dir) + }); + let default_function = resolve_default_function(effective_root, crate_name, crate_dir); + + generate_android_project(output_dir, &project_slug, &default_function)?; println!(" Generated Android project at {:?}", output_dir.join("android")); + println!(" Default benchmark function: {}", default_function); Ok(()) } /// Auto-generates iOS project scaffolding from a crate name /// /// This is a convenience function that derives template variables from the -/// crate name and generates the iOS project structure. +/// crate name and generates the iOS project structure. It auto-detects +/// the default benchmark function from the crate's source code. /// /// # Arguments /// /// * `output_dir` - Directory to write the `ios/` project into /// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile") pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> { + ensure_ios_project_with_options(output_dir, crate_name, None, None) +} + +/// Auto-generates iOS project scaffolding with additional options +/// +/// This is a more flexible version of `ensure_ios_project` that allows +/// specifying a custom default function and/or crate directory. +/// +/// # Arguments +/// +/// * `output_dir` - Directory to write the `ios/` project into +/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile") +/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent) +/// * `crate_dir` - Optional explicit crate directory for benchmark detection +pub fn ensure_ios_project_with_options( + output_dir: &Path, + crate_name: &str, + project_root: Option<&Path>, + crate_dir: Option<&Path>, +) -> Result<(), BenchError> { if ios_project_exists(output_dir) { return Ok(()); } @@ -569,8 +825,16 @@ pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), Ben // Derive library name and bundle prefix from crate name let library_name = crate_name.replace('-', "_"); let bundle_prefix = format!("dev.world.{}", library_name.replace('_', "-")); - generate_ios_project(output_dir, &library_name, project_pascal, &bundle_prefix)?; + + // Resolve the default function by auto-detecting from source + let effective_root = project_root.unwrap_or_else(|| { + output_dir.parent().unwrap_or(output_dir) + }); + let default_function = resolve_default_function(effective_root, crate_name, crate_dir); + + generate_ios_project(output_dir, &library_name, project_pascal, &bundle_prefix, &default_function)?; println!(" Generated iOS project at {:?}", output_dir.join("ios")); + println!(" Default benchmark function: {}", default_function); Ok(()) } @@ -595,4 +859,195 @@ mod tests { // Cleanup fs::remove_dir_all(&temp_dir).ok(); } + + #[test] + fn test_generate_android_project_no_unreplaced_placeholders() { + let temp_dir = env::temp_dir().join("mobench-sdk-android-test"); + // Clean up any previous test run + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let result = generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func"); + assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err()); + + // Verify key files exist + let android_dir = temp_dir.join("android"); + assert!(android_dir.join("settings.gradle").exists()); + assert!(android_dir.join("app/build.gradle").exists()); + assert!(android_dir.join("app/src/main/AndroidManifest.xml").exists()); + assert!(android_dir.join("app/src/main/res/values/strings.xml").exists()); + assert!(android_dir.join("app/src/main/res/values/themes.xml").exists()); + + // Verify no unreplaced placeholders remain in generated files + let files_to_check = [ + "settings.gradle", + "app/build.gradle", + "app/src/main/AndroidManifest.xml", + "app/src/main/res/values/strings.xml", + "app/src/main/res/values/themes.xml", + ]; + + for file in files_to_check { + let path = android_dir.join(file); + let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file)); + + // Check for unreplaced placeholders + let has_placeholder = contents.contains("{{") && contents.contains("}}"); + assert!( + !has_placeholder, + "File {} contains unreplaced template placeholders: {}", + file, + contents + ); + } + + // Verify specific substitutions were made + let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap(); + assert!( + settings.contains("my-bench-project-android") || settings.contains("my_bench_project-android"), + "settings.gradle should contain project name" + ); + + let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap(); + assert!( + build_gradle.contains("dev.world.my-bench-project") || build_gradle.contains("dev.world.my_bench_project"), + "build.gradle should contain package name" + ); + + let manifest = fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap(); + assert!( + manifest.contains("Theme.MyBenchProject"), + "AndroidManifest.xml should contain PascalCase theme name" + ); + + let strings = fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap(); + assert!( + strings.contains("Benchmark"), + "strings.xml should contain app name with Benchmark" + ); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_is_template_file() { + assert!(is_template_file(Path::new("settings.gradle"))); + assert!(is_template_file(Path::new("app/build.gradle"))); + assert!(is_template_file(Path::new("AndroidManifest.xml"))); + assert!(is_template_file(Path::new("strings.xml"))); + assert!(is_template_file(Path::new("MainActivity.kt.template"))); + assert!(is_template_file(Path::new("project.yml"))); + assert!(is_template_file(Path::new("Info.plist"))); + assert!(!is_template_file(Path::new("libfoo.so"))); + assert!(!is_template_file(Path::new("image.png"))); + } + + #[test] + fn test_validate_no_unreplaced_placeholders() { + // Should pass with no placeholders + assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok()); + + // Should pass with Gradle variables (not our placeholders) + assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok()); + + // Should fail with unreplaced template placeholders + let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt")); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("{{NAME}}")); + } + + #[test] + fn test_to_pascal_case() { + assert_eq!(to_pascal_case("my-project"), "MyProject"); + assert_eq!(to_pascal_case("my_project"), "MyProject"); + assert_eq!(to_pascal_case("myproject"), "Myproject"); + assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject"); + } + + #[test] + fn test_detect_default_function_finds_benchmark() { + let temp_dir = env::temp_dir().join("mobench-sdk-detect-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(temp_dir.join("src")).unwrap(); + + // Create a lib.rs with a benchmark function + let lib_content = r#" +use mobench_sdk::benchmark; + +/// Some docs +#[benchmark] +fn my_benchmark_func() { + // benchmark code +} + +fn helper_func() {} +"#; + fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap(); + fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); + + let result = detect_default_function(&temp_dir, "my_crate"); + assert_eq!(result, Some("my_crate::my_benchmark_func".to_string())); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_detect_default_function_no_benchmark() { + let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(temp_dir.join("src")).unwrap(); + + // Create a lib.rs without benchmark functions + let lib_content = r#" +fn regular_function() { + // no benchmark here +} +"#; + fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap(); + + let result = detect_default_function(&temp_dir, "my_crate"); + assert!(result.is_none()); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_detect_default_function_pub_fn() { + let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(temp_dir.join("src")).unwrap(); + + // Create a lib.rs with a public benchmark function + let lib_content = r#" +#[benchmark] +pub fn public_bench() { + // benchmark code +} +"#; + fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap(); + + let result = detect_default_function(&temp_dir, "test-crate"); + assert_eq!(result, Some("test_crate::public_bench".to_string())); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_resolve_default_function_fallback() { + let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + // No lib.rs exists, should fall back to default + let result = resolve_default_function(&temp_dir, "my-crate", None); + assert_eq!(result, "my_crate::example_benchmark"); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } } diff --git a/crates/mobench-sdk/templates/android/.gitignore b/crates/mobench-sdk/templates/android/.gitignore new file mode 100644 index 0000000..ad17687 --- /dev/null +++ b/crates/mobench-sdk/templates/android/.gitignore @@ -0,0 +1,20 @@ +# Gradle +.gradle/ +build/ +local.properties + +# IDE +.idea/ +*.iml + +# Android +*.apk +*.aab +*.dex +*.class + +# Kotlin +*.kotlin_module + +# Local configuration +local.properties diff --git a/crates/mobench-sdk/templates/android/README.md b/crates/mobench-sdk/templates/android/README.md new file mode 100644 index 0000000..d88d1d9 --- /dev/null +++ b/crates/mobench-sdk/templates/android/README.md @@ -0,0 +1,24 @@ +# {{PROJECT_NAME}} Android Benchmark App + +This is an auto-generated Android app for running Rust benchmarks on real devices. + +## Building + +```bash +# Build debug APK +./gradlew assembleDebug + +# Build release APK +./gradlew assembleRelease +``` + +## Running Benchmarks + +The app reads benchmark configuration from: +1. Intent extras (`bench_function`, `bench_iterations`, `bench_warmup`) +2. `assets/bench_spec.json` +3. Default values in code + +## Generated by + +[mobench](https://crates.io/crates/mobench) - Mobile benchmarking SDK for Rust diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index ca577dc..ee1f5c0 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -157,7 +157,8 @@ class MainActivity : AppCompatActivity() { json.optInt("warmup", DEFAULT_WARMUP.toInt()).toUInt(), ) } - } catch (_: Exception) { + } catch (e: Exception) { + android.util.Log.w("MainActivity", "Failed to load bench_spec.json from assets", e) null } } diff --git a/crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml b/crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml index fb5b86a..1cf1e00 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml +++ b/crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml @@ -1,5 +1,5 @@ - diff --git a/crates/mobench-sdk/templates/android/build.gradle b/crates/mobench-sdk/templates/android/build.gradle index c3e6c75..571ffeb 100644 --- a/crates/mobench-sdk/templates/android/build.gradle +++ b/crates/mobench-sdk/templates/android/build.gradle @@ -4,7 +4,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.13.2' + classpath 'com.android.tools.build:gradle:8.2.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22" } } diff --git a/crates/mobench-sdk/templates/android/gradle.properties b/crates/mobench-sdk/templates/android/gradle.properties new file mode 100644 index 0000000..b665e1a --- /dev/null +++ b/crates/mobench-sdk/templates/android/gradle.properties @@ -0,0 +1,7 @@ +android.useAndroidX=true +android.enableJetifier=true +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +kotlin.code.style=official diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore b/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore new file mode 100644 index 0000000..be86720 --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore @@ -0,0 +1,18 @@ +# Xcode +build/ +DerivedData/ +*.xcworkspace +xcuserdata/ +*.xcuserstate + +# CocoaPods (if used) +Pods/ + +# Generated +*.ipa +*.dSYM.zip +*.dSYM + +# IDE +.idea/ +*.swp diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/README.md b/crates/mobench-sdk/templates/ios/BenchRunner/README.md new file mode 100644 index 0000000..b739b03 --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/README.md @@ -0,0 +1,28 @@ +# {{PROJECT_NAME_PASCAL}} iOS Benchmark App + +This is an auto-generated iOS app for running Rust benchmarks on real devices. + +## Building + +```bash +# Generate Xcode project (if using xcodegen) +xcodegen generate + +# Build for simulator +xcodebuild -scheme BenchRunner -destination 'platform=iOS Simulator,name=iPhone 15' build + +# Build for device +xcodebuild -scheme BenchRunner -destination 'generic/platform=iOS' build +``` + +## Running Benchmarks + +The app reads benchmark configuration from: +1. Environment variables +2. Launch arguments +3. `bench_spec.json` in bundle +4. Default values in code + +## Generated by + +[mobench](https://crates.io/crates/mobench) - Mobile benchmarking SDK for Rust diff --git a/mobench-0.1.9-dx-report.md b/mobench-0.1.9-dx-report.md new file mode 100644 index 0000000..b3502ce --- /dev/null +++ b/mobench-0.1.9-dx-report.md @@ -0,0 +1,388 @@ +# mobench 0.1.9 DX (Developer Experience) Report + +**Date:** 2026-01-19 +**Tested Version:** mobench-sdk 0.1.9, mobench CLI 0.1.9 +**Platform:** macOS Darwin 25.1.0 (arm64) +**Crate:** bench-mobile (World ID ZK Proof Benchmarks) + +## Executive Summary + +End-to-end testing of mobench 0.1.9 for both Android and iOS builds revealed **12 critical bugs**, **8 high-severity issues**, and multiple DX improvement opportunities. The primary problems involve template substitution failures, missing configuration files, and silent failures that mask underlying errors. + +**Build Status:** +- Android: Built successfully after 4 manual fixes +- iOS: Built successfully for arm64 simulator; x86_64 not supported + +--- + +## Critical Bugs + +### Bug 1: `{{PROJECT_NAME}}` Placeholder Not Replaced +**Severity:** CRITICAL +**File:** `target/mobench/android/settings.gradle` + +The `PROJECT_NAME` template variable is **not defined** in the codegen template variable list, leaving the placeholder literally in the generated file: + +```gradle +rootProject.name = "{{PROJECT_NAME}}-android" // NOT REPLACED +``` + +**Impact:** Gradle shows the project as "{{PROJECT_NAME}}-android" in IDE. + +**Fix:** Add `PROJECT_NAME` to template variables in `codegen.rs`. + +--- + +### Bug 2: `{{PACKAGE_NAME}}` Placeholder Not Replaced +**Severity:** CRITICAL +**File:** `target/mobench/android/app/build.gradle` + +Multiple occurrences of `{{PACKAGE_NAME}}` not substituted: +- Line 5: `namespace = "{{PACKAGE_NAME}}"` +- Line 15: `applicationId "{{PACKAGE_NAME}}"` + +**Impact:** Gradle build fails with "{{PACKAGE_NAME}} is not a valid Java identifier". + +**Manual Fix Applied:** +```gradle +namespace = "dev.world.bench_mobile" +applicationId "dev.world.bench_mobile" +``` + +--- + +### Bug 3: `{{LIBRARY_NAME}}` Placeholder Not Replaced +**Severity:** CRITICAL +**File:** `target/mobench/android/app/build.gradle` (line 57) + +```gradle +keepDebugSymbols += ["**/lib{{LIBRARY_NAME}}.so"] // NOT REPLACED +``` + +**Manual Fix Applied:** +```gradle +keepDebugSymbols += ["**/libbench_mobile.so"] +``` + +--- + +### Bug 4: `{{PROJECT_NAME_PASCAL}}` Placeholder Not Replaced +**Severity:** CRITICAL +**File:** `target/mobench/android/app/src/main/AndroidManifest.xml` + +```xml +android:theme="@style/Theme.{{PROJECT_NAME_PASCAL}}" // NOT REPLACED +``` + +**Impact:** Android resource linking fails with "style/Theme.{{PROJECT_NAME_PASCAL}} not found". + +**Manual Fix Applied:** +```xml +android:theme="@style/Theme.MobileBench" +``` + +--- + +### Bug 5: `{{APP_NAME}}` Placeholder Not Replaced +**Severity:** CRITICAL +**File:** `target/mobench/android/app/src/main/res/values/strings.xml` + +```xml +{{APP_NAME}} // NOT REPLACED +``` + +**Impact:** App displays "{{APP_NAME}}" as its title. + +--- + +### Bug 6: Missing `gradle.properties` +**Severity:** CRITICAL +**Expected:** `target/mobench/android/gradle.properties` + +The file doesn't exist in the scaffolded output, causing: +``` +Configuration contains AndroidX dependencies, but android.useAndroidX property is not enabled +``` + +**Manual Fix Applied:** Created file with: +```properties +android.useAndroidX=true +android.enableJetifier=true +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +kotlin.code.style=official +``` + +--- + +### Bug 7: Missing Gradle Wrapper (gradlew) +**Severity:** CRITICAL +**Expected:** `target/mobench/android/gradlew` + +The scaffolded project doesn't include the Gradle wrapper files, causing: +``` +Command: ./gradlew assembleRelease +Error: No such file or directory +``` + +**Manual Fix Applied:** Generated wrapper with `gradle wrapper --gradle-version 8.5` + +--- + +### Bug 8: Invalid Android Gradle Plugin Version +**Severity:** CRITICAL +**File:** `target/mobench/android/build.gradle` + +Template uses: +```gradle +classpath 'com.android.tools.build:gradle:8.13.2' // DOES NOT EXIST +``` + +AGP version 8.13.2 doesn't exist. Latest stable is 8.2.x. + +**Manual Fix Applied:** +```gradle +classpath 'com.android.tools.build:gradle:8.2.2' +``` + +--- + +### Bug 9: Package Name Mismatch +**Severity:** HIGH +**Files:** build.gradle vs Kotlin sources + +- build.gradle template uses `{{PACKAGE_NAME}}` placeholder +- Kotlin sources are hardcoded to `dev.world.bench_mobile` + +These must match or builds fail. The Kotlin files don't use template variables. + +--- + +### Bug 10: Missing x86_64 iOS Simulator Architecture +**Severity:** HIGH +**File:** `target/mobench/ios/bench_mobile.xcframework/Info.plist` + +The xcframework only includes: +- `ios-arm64` (device) +- `ios-simulator-arm64` (Apple Silicon simulator) + +Missing: `ios-simulator-x86_64` (Intel Mac simulator) + +**Impact:** Build fails on Intel Macs and older CI runners: +``` +ld: symbol(s) not found for architecture x86_64 +``` + +--- + +### Bug 11: Path Handling Bug +**Severity:** HIGH + +Running `mobench build --target ios` from within `target/mobench/android/` causes iOS project to be generated at `target/mobench/android/target/mobench/ios/` instead of `target/mobench/ios/`. + +--- + +### Bug 12: Default Benchmark Function Mismatch +**Severity:** HIGH +**Files:** MainActivity.kt, BenchRunnerFFI.swift + +Both use `DEFAULT_FUNCTION = "example_fibonacci"` but the actual benchmarks are: +- `bench_mobile::bench_query_proof_generation` +- `bench_mobile::bench_nullifier_proof_generation` + +**Impact:** Fresh app launch fails with "unknown benchmark function" error. + +--- + +## High Severity Issues + +### Issue 1: No Post-Template Validation +Template rendering doesn't validate that all `{{PLACEHOLDER}}` patterns were replaced. Files are written with literal placeholder text. + +### Issue 2: Silent Codesign Failure +`codesign_xcframework` returns `Ok(())` even when signing fails, just printing a warning. + +### Issue 3: Silent xcodegen Failure +`generate_xcode_project` returns `Ok(())` when xcodegen fails, just printing a warning. + +### Issue 4: Silent Native Library Skip +Missing `.so` files are silently skipped without `--verbose`, leading to runtime crashes. + +### Issue 5: No Path Validation +No validation that the project root exists, is a directory, or contains Cargo.toml. + +### Issue 6: cargo metadata Fallback Hides Errors +Falls back to `crate_dir/target` silently when workspace metadata parsing fails. + +### Issue 7: Empty Catch Block in Kotlin +```kotlin +} catch (_: Exception) { + null // Swallows all exceptions +} +``` + +### Issue 8: No Build Completion Validation +No verification that all expected artifacts exist after build completes. + +--- + +## Medium/Low Severity Issues + +| Issue | Severity | Description | +|-------|----------|-------------| +| Missing .gitignore | MEDIUM | No .gitignore in scaffolded projects | +| local.properties committed | MEDIUM | Contains machine-specific SDK path | +| No README generated | LOW | No documentation in scaffold output | +| Benchmark naming inconsistency | MEDIUM | Tests use `world_id_mobile_bench::` prefix, examples use `bench_mobile::` | + +--- + +## Manual Fixes Applied During Testing + +### Android Fixes (in order): + +1. **build.gradle (root):** + ```diff + - classpath 'com.android.tools.build:gradle:8.13.2' + + classpath 'com.android.tools.build:gradle:8.2.2' + ``` + +2. **app/build.gradle:** + ```diff + - namespace = "{{PACKAGE_NAME}}" + + namespace = "dev.world.bench_mobile" + + - applicationId "{{PACKAGE_NAME}}" + + applicationId "dev.world.bench_mobile" + + - keepDebugSymbols += ["**/lib{{LIBRARY_NAME}}.so"] + + keepDebugSymbols += ["**/libbench_mobile.so"] + ``` + +3. **AndroidManifest.xml:** + ```diff + - android:theme="@style/Theme.{{PROJECT_NAME_PASCAL}}" + + android:theme="@style/Theme.MobileBench" + ``` + +4. **Created gradle.properties** (entire file) + +5. **Generated Gradle wrapper:** + ```bash + cd target/mobench/android && gradle wrapper --gradle-version 8.5 + ``` + +### iOS Fixes: +None required - build completed for arm64 simulator. Device builds require signing configuration. + +--- + +## Recommended Priority Fixes for mobench-sdk + +### P0 (Blocker - Fix Immediately) + +1. **Fix template variable substitution** + - Add `PROJECT_NAME`, `PROJECT_NAME_PASCAL`, `APP_NAME`, `PACKAGE_NAME`, `LIBRARY_NAME` to all template contexts + - Ensure all placeholders are defined before rendering + +2. **Add placeholder validation** + ```rust + // After render_template(), validate no {{...}} remain + if output.contains("{{") && output.contains("}}") { + return Err(BenchError::Build("Unreplaced placeholder found")); + } + ``` + +3. **Include gradle.properties in templates** + ```properties + android.useAndroidX=true + android.enableJetifier=true + ``` + +4. **Include Gradle wrapper files or generate them** + ```rust + // Generate wrapper if gradle available + Command::new("gradle").arg("wrapper").arg("--gradle-version").arg("8.5") + ``` + +5. **Fix AGP version to valid value (8.2.2)** + +### P1 (High - Fix Before Next Release) + +6. **Add x86_64 iOS simulator support** + ```rust + // Add to iOS build targets + "x86_64-apple-ios" + ``` + +7. **Make error handling explicit** + - Remove `Ok(())` returns on codesign/xcodegen failures + - Add `--skip-signing` and `--skip-xcodegen` flags instead + +8. **Add path validation** + - Verify project_root exists and contains Cargo.toml + - Warn if running from unexpected directory + +9. **Update default benchmark function** + - Generate from discovered benchmarks + - Or use a known-working default + +### P2 (Medium - Nice to Have) + +10. Generate .gitignore files +11. Generate README.md with usage instructions +12. Add build completion validation step +13. Improve error messages with actionable fixes + +--- + +## Test Commands Used + +```bash +# Update mobench CLI +cargo install mobench --version 0.1.9 --force + +# Update SDK dependency +mobench-sdk = "0.1.9" # in Cargo.toml +cargo update -p mobench-sdk + +# Clean and build Android +rm -rf target/mobench +mobench build --target android --release --verbose + +# Build iOS +mobench build --target ios --release --verbose + +# Build Android APK (after fixes) +cd target/mobench/android +gradle wrapper --gradle-version 8.5 +./gradlew assembleRelease + +# Build iOS app for simulator +cd target/mobench/ios/BenchRunner +xcodebuild -scheme BenchRunner -configuration Release \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +``` + +--- + +## Appendix: File Locations + +| File | Purpose | Status | +|------|---------|--------| +| `bench-mobile/Cargo.toml` | Benchmark crate config | OK | +| `bench-mobile/src/lib.rs` | Benchmark implementations | OK | +| `target/mobench/android/` | Generated Android project | Needs 5 fixes | +| `target/mobench/ios/` | Generated iOS project | OK for arm64 | +| `target/mobench/android/app/build/outputs/apk/release/app-release-unsigned.apk` | Android APK (133MB) | Built | +| `target/mobench/ios/bench_mobile.xcframework/` | iOS framework | Built | + +--- + +## Conclusion + +mobench 0.1.9 has significant DX issues with template substitution being the most critical. The tool generates project scaffolding but fails to replace 5+ template placeholders, omits required configuration files, and uses an invalid AGP version. After manual fixes, both platforms build successfully. + +**Recommendation:** Do not use 0.1.9 in CI/CD without the fixes documented above. Wait for 0.1.10 or later with these issues resolved. From 5f9ca6639fdd175b8bdb6ea9060beba92018a76c Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 20:53:08 +0100 Subject: [PATCH 028/196] Update documentation for v0.1.9 and fix inconsistencies - Fix all path references to use target/mobench/ as default output dir - Update CLAUDE.md version reference to v0.1.9 - Fix PROJECT_PLAN.md to reflect mobench-runner consolidation into mobench-sdk - Remove dead links to BROWSERSTACK_RUN_2.md (use BROWSERSTACK_METRICS.md) - Fix all init-sdk references to use correct init command - Fix duplicate command and step numbering in TESTING.md - Update iOS artifact paths in BENCH_SDK_INTEGRATION.md --- BENCH_SDK_INTEGRATION.md | 6 +-- BROWSERSTACK_CI_INTEGRATION.md | 2 +- BUILD.md | 20 ++++---- CLAUDE.md | 2 +- FETCH_RESULTS_GUIDE.md | 2 +- PROJECT_PLAN.md | 7 +-- TESTING.md | 53 +++++++++++----------- crates/mobench-sdk/src/builders/android.rs | 6 +-- crates/mobench-sdk/src/builders/ios.rs | 2 +- crates/mobench/README.md | 2 +- 10 files changed, 51 insertions(+), 51 deletions(-) diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index c831c42..ca6e304 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -58,7 +58,7 @@ Benchmarks are identified by name at runtime. You can call them by: From your repo root, create a mobile harness with the CLI: ```bash -cargo mobench init-sdk --target both --project-name my-bench --output-dir . +cargo mobench init --target android --output bench-config.toml ``` This generates: @@ -175,8 +175,8 @@ cargo mobench run \ --iterations 100 \ --warmup 10 \ --devices "iPhone 14-16" \ - --ios-app target/ios/BenchRunner.ipa \ - --ios-test-suite target/ios/BenchRunnerUITests.zip + --ios-app target/mobench/ios/BenchRunner.ipa \ + --ios-test-suite target/mobench/ios/BenchRunnerUITests.zip ``` **IPA Signing Methods:** diff --git a/BROWSERSTACK_CI_INTEGRATION.md b/BROWSERSTACK_CI_INTEGRATION.md index 4584884..8703f65 100644 --- a/BROWSERSTACK_CI_INTEGRATION.md +++ b/BROWSERSTACK_CI_INTEGRATION.md @@ -307,6 +307,6 @@ match client.wait_and_fetch_all_results(build_id, "espresso", Some(600)) { ## Next Steps -- See `BROWSERSTACK_RUN_2.md` for current test run results +- See `BROWSERSTACK_METRICS.md` for metrics and performance documentation - Check `crates/mobench/src/browserstack.rs` for full API documentation - Run `cargo doc --open -p mobench` for detailed API docs diff --git a/BUILD.md b/BUILD.md index d149016..3909f7c 100644 --- a/BUILD.md +++ b/BUILD.md @@ -79,7 +79,7 @@ xcodebuild -version cargo mobench build --target android # Install on connected device or emulator -adb install -r android/app/build/outputs/apk/debug/app-debug.apk +adb install -r target/mobench/android/app/build/outputs/apk/debug/app-debug.apk # Launch the app adb shell am start -n dev.world.bench/.MainActivity @@ -146,7 +146,7 @@ adb shell am start -n dev.world.bench/.MainActivity \ cargo mobench build --target android # If only Kotlin/Java changed -cd android && ./gradlew :app:assembleDebug +cd target/mobench/android && ./gradlew :app:assembleDebug # Full clean rebuild cargo clean @@ -161,7 +161,7 @@ cargo mobench build --target android cargo mobench build --target ios # Generate Xcode project -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate # Open in Xcode @@ -257,10 +257,10 @@ The app will launch and display benchmark results. #### Method 2: Command Line (Simulator) ```bash # Build for simulator -xcodebuild -project ios/BenchRunner/BenchRunner.xcodeproj \ +xcodebuild -project target/mobench/ios/BenchRunner/BenchRunner.xcodeproj \ -scheme BenchRunner \ -destination 'platform=iOS Simulator,name=iPhone 15' \ - -derivedDataPath ios/build + -derivedDataPath target/mobench/ios/build # Launch with arguments xcrun simctl launch booted dev.world.bench \ @@ -277,14 +277,14 @@ cargo mobench build --target ios # If Swift code changed, just rebuild in Xcode (⌘+B) # If project.yml changed -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate open BenchRunner.xcodeproj # Full clean rebuild cargo clean cargo mobench build --target ios -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate # Clean in Xcode (⌘+Shift+K) then build (⌘+B) ``` @@ -426,9 +426,9 @@ cargo build -p sample-fns cargo mobench build --target android # This updates: -# - android/app/src/main/java/uniffi/sample_fns/sample_fns.kt (Kotlin) -# - ios/BenchRunner/BenchRunner/Generated/sample_fns.swift (Swift) -# - ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h (C header) +# - target/mobench/android/app/src/main/java/uniffi/sample_fns/sample_fns.kt (Kotlin) +# - target/mobench/ios/BenchRunner/BenchRunner/Generated/sample_fns.swift (Swift) +# - target/mobench/ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h (C header) # Then rebuild mobile apps cargo mobench build --target android diff --git a/CLAUDE.md b/CLAUDE.md index 3e7ca09..aa3ae02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.8):** +**Published on crates.io as the mobench ecosystem (v0.1.9):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation diff --git a/FETCH_RESULTS_GUIDE.md b/FETCH_RESULTS_GUIDE.md index 8d25d8a..8185e7f 100644 --- a/FETCH_RESULTS_GUIDE.md +++ b/FETCH_RESULTS_GUIDE.md @@ -268,5 +268,5 @@ cargo mobench run \ ## See Also - `BROWSERSTACK_CI_INTEGRATION.md` - Programmatic API for custom workflows -- `BROWSERSTACK_RUN_2.md` - Example test run documentation +- `BROWSERSTACK_METRICS.md` - Metrics and performance documentation - `cargo mobench run --help` - Full CLI options diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 2463f71..7b2af74 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -15,8 +15,9 @@ ## Architecture Outline -- `mobench`: Orchestrates builds, packaging, upload, AppAutomate sessions, and result collation. -- `mobench-runner`: Minimal Rust harness compiled into mobile libs; exposes FFI entrypoints for target functions and collects timings. +- `mobench`: CLI tool that orchestrates builds, packaging, upload, AppAutomate sessions, and result collation. +- `mobench-sdk`: Core SDK library with timing harness (consolidated from the former `mobench-runner`), builders, registry, and codegen. Compiled into mobile libs; exposes FFI entrypoints for target functions and collects timings. +- `mobench-macros`: Proc macro crate providing the `#[benchmark]` attribute for marking functions. - Mobile bindings: - Android: Kotlin wrapper + APK test harness embedding Rust lib (cargo-ndk); uses Espresso/Appium-style entrypoints for AppAutomate. - iOS: Swift wrapper + test host app/xcframework; invokes Rust via C-ABI bindings. @@ -31,7 +32,7 @@ ## Task Backlog -- [x] Repo bootstrap: Cargo workspace, `mobench` binary crate, `mobench-runner` library crate, example `sample-fns` crate. +- [x] Repo bootstrap: Cargo workspace, `mobench` CLI crate, `mobench-sdk` library crate (timing module consolidated from former `mobench-runner`), `mobench-macros` proc macro crate, example `sample-fns` crate. - [x] Define FFI boundary: macro/attribute to mark benchmarkable Rust functions; export through C ABI; basic timing harness. - [x] Android packaging: cargo-ndk config, Kotlin wrapper module, minimal test/activity to trigger Rust bench entrypoint. - [x] iOS packaging: xcframework build script (cargo lipo or cargo-apple), C header generation (cbindgen), Swift wrapper, test host. diff --git a/TESTING.md b/TESTING.md index 9e8a87b..e5131ad 100644 --- a/TESTING.md +++ b/TESTING.md @@ -57,7 +57,6 @@ xcode-select --install # Install xcodegen brew install xcodegen # https://github.com/yonaskolb/XcodeGen -brew install xcodegen ``` ## Host Testing @@ -88,7 +87,7 @@ The `Mobile Bench (manual)` workflow uploads summary artifacts: cargo mobench build --target android # Install on connected device/emulator -adb install -r android/app/build/outputs/apk/debug/app-debug.apk +adb install -r target/mobench/android/app/build/outputs/apk/debug/app-debug.apk # Launch app adb shell am start -n dev.world.bench/.MainActivity @@ -101,12 +100,12 @@ adb shell am start -n dev.world.bench/.MainActivity cargo mobench build --target android # Step 2: Build APK -cd android +cd target/mobench/android ./gradlew :app:assembleDebug -cd .. +cd ../../.. -# Step 4: Install and launch -adb install -r android/app/build/outputs/apk/debug/app-debug.apk +# Step 3: Install and launch +adb install -r target/mobench/android/app/build/outputs/apk/debug/app-debug.apk adb shell am start -n dev.world.bench/.MainActivity ``` @@ -117,7 +116,7 @@ adb shell am start -n dev.world.bench/.MainActivity cargo mobench build --target android ``` -2. Open `android/` directory in Android Studio +2. Open `target/mobench/android/` directory in Android Studio 3. Let Gradle sync complete @@ -185,7 +184,7 @@ cargo mobench build --target ios # This script: # - Compiles Rust for aarch64-apple-ios (device) and aarch64-apple-ios-sim (simulator) # - Creates xcframework with proper structure: -# target/ios/sample_fns.xcframework/ +# target/mobench/ios/sample_fns.xcframework/ # ├── Info.plist # ├── ios-arm64/ # │ └── sample_fns.framework/ @@ -206,7 +205,7 @@ cargo mobench build --target ios # - Automatically code-signs the xcframework # Step 2: Generate Xcode project from project.yml -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate # Step 3: Open in Xcode @@ -244,10 +243,10 @@ In Xcode: First, build and install to simulator: ```bash # Build for simulator -xcodebuild -project ios/BenchRunner/BenchRunner.xcodeproj \ +xcodebuild -project target/mobench/ios/BenchRunner/BenchRunner.xcodeproj \ -scheme BenchRunner \ -destination 'platform=iOS Simulator,name=iPhone 15' \ - -derivedDataPath ios/build + -derivedDataPath target/mobench/ios/build # Launch with arguments xcrun simctl launch booted dev.world.bench.BenchRunner \ @@ -259,7 +258,7 @@ xcrun simctl launch booted dev.world.bench.BenchRunner \ #### Method 3: Edit bench_spec.json Bundle Resource Add `bench_spec.json` to the app bundle: -1. Create `ios/BenchRunner/BenchRunner/Resources/bench_spec.json`: +1. Create `target/mobench/ios/BenchRunner/BenchRunner/Resources/bench_spec.json`: ```json { "function": "sample_fns::checksum", @@ -320,7 +319,7 @@ cargo mobench build --target android ```bash # Solution: Ensure .so files are in the APK cargo mobench build --target android -cd android && ./gradlew clean assembleDebug +cd target/mobench/android && ./gradlew clean assembleDebug ``` **Problem**: App shows "Error: UnknownFunction" @@ -338,11 +337,11 @@ brew install xcodegen **Problem**: "The Framework 'sample_fns.xcframework' is unsigned" ```bash # Solution: Code-sign the xcframework -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework # The build step includes signing, but if you built manually: cargo mobench build --target ios -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate # Clean build in Xcode (⌘+Shift+K) then build (⌘+B) ``` @@ -358,10 +357,10 @@ xcodegen generate ```bash # Solution: Ensure the bridging header is configured # Check that BenchRunner-Bridging-Header.h exists at: -# ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h +# target/mobench/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h # If missing, create it with: -cat > ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h << 'EOF' +cat > target/mobench/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h << 'EOF' // // BenchRunner-Bridging-Header.h // BenchRunner @@ -373,19 +372,19 @@ cat > ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h << 'EOF' EOF # Then regenerate the Xcode project: -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate ``` **Problem**: Build fails with "library not found for -lsample_fns" or "framework 'ios-simulator-arm64' not found" ```bash # Solution: Ensure xcframework was built correctly with proper structure -rm -rf target/ios/sample_fns.xcframework +rm -rf target/mobench/ios/sample_fns.xcframework cargo mobench build --target ios -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework # Verify structure: -ls -la target/ios/sample_fns.xcframework/ +ls -la target/mobench/ios/sample_fns.xcframework/ # Should show: # ios-arm64/sample_fns.framework/ # ios-simulator-arm64/sample_fns.framework/ @@ -395,12 +394,12 @@ ls -la target/ios/sample_fns.xcframework/ **Problem**: "While building for iOS Simulator, no library for this platform was found" ```bash # Solution: Rebuild the xcframework - the structure may be incorrect -rm -rf target/ios/sample_fns.xcframework +rm -rf target/mobench/ios/sample_fns.xcframework cargo mobench build --target ios -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework # Clean Xcode build folder -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodebuild clean -project BenchRunner.xcodeproj -scheme BenchRunner # Then build in Xcode ``` @@ -411,7 +410,7 @@ xcodebuild clean -project BenchRunner.xcodeproj -scheme BenchRunner # Check the iOS builder uses `dev.world.sample-fns` for the framework # Rebuild: cargo mobench build --target ios -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework ``` **Problem**: Simulator crashes with "Symbol not found" @@ -419,7 +418,7 @@ codesign --force --deep --sign - target/ios/sample_fns.xcframework # Solution: Clean and rebuild for simulator architecture cargo clean cargo mobench build --target ios -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework # In Xcode, clean (⌘+Shift+K) then build (⌘+B) ``` @@ -428,7 +427,7 @@ codesign --force --deep --sign - target/ios/sample_fns.xcframework - Ensure proper code signing is configured in Xcode - Select your development team in Xcode → Project Settings → Signing & Capabilities - Trust developer certificate on device: Settings → General → VPN & Device Management -- The xcframework must be signed: `codesign --force --deep --sign - target/ios/sample_fns.xcframework` +- The xcframework must be signed: `codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework` ### UniFFI Bindings (Proc Macros) diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index c985555..d70d602 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -346,7 +346,7 @@ impl AndroidBuilder { - Typo in crate name (check Cargo.toml [package] name)\n\ - Wrong working directory (run from project root)\n\ - Missing Cargo.toml in the crate directory\n\n\ - Run 'cargo mobench init-sdk --help' to generate a new benchmark project.", + Run 'cargo mobench init --help' to generate a new benchmark project.", self.crate_name, bench_mobile_manifest.display(), crates_manifest.display() @@ -829,7 +829,7 @@ impl AndroidBuilder { return Err(BenchError::Build(format!( "Android project not found at {}.\n\n\ Expected a Gradle project under the output directory.\n\ - Run `cargo mobench init-sdk --target android` or `cargo mobench build --target android` from the project root to generate it.", + Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.", android_dir.display() ))); } @@ -922,7 +922,7 @@ impl AndroidBuilder { return Err(BenchError::Build(format!( "Android project not found at {}.\n\n\ Expected a Gradle project under the output directory.\n\ - Run `cargo mobench init-sdk --target android` or `cargo mobench build --target android` from the project root to generate it.", + Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.", android_dir.display() ))); } diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 626c08c..131fa0d 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -413,7 +413,7 @@ impl IosBuilder { - Typo in crate name (check Cargo.toml [package] name)\n\ - Wrong working directory (run from project root)\n\ - Missing Cargo.toml in the crate directory\n\n\ - Run 'cargo mobench init-sdk --help' to generate a new benchmark project.", + Run 'cargo mobench init --help' to generate a new benchmark project.", self.crate_name, bench_mobile_manifest.display(), crates_manifest.display() diff --git a/crates/mobench/README.md b/crates/mobench/README.md index ef6c5be..10dfd50 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -36,7 +36,7 @@ This creates: - `bench-mobile/` - FFI wrapper crate with UniFFI bindings - `android/` or `ios/` - Platform-specific app projects (generated to output directory) - `bench-config.toml` - Run configuration file -- `mobench.toml` - Project configuration file (when using `init-sdk`) +- `mobench.toml` - Project configuration file (when using `init`) - `benches/example.rs` - Example benchmarks (with `--examples`) ### 2. Write Benchmarks From cd82475e78203ec7ffac18ffcaaea6cff7f07371 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 20:57:59 +0100 Subject: [PATCH 029/196] Bump version to 0.1.10 for release --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 663080e..49ea0f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.9" +version = "0.1.10" dependencies = [ "anyhow", "clap", @@ -820,7 +820,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.9" +version = "0.1.10" dependencies = [ "proc-macro2", "quote", @@ -829,7 +829,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.9" +version = "0.1.10" dependencies = [ "anyhow", "include_dir", @@ -1154,7 +1154,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.9" +version = "0.1.10" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index e81691d..36c50f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.9" +version = "0.1.10" [workspace.dependencies] anyhow = "1" diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index db1fa9c..3d62153 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.9", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.10", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 03aa66f..164ad3b 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.9", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.10", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true From a7598066afb52a5eda0b69088a5d516f133ecb5a Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 21:03:33 +0100 Subject: [PATCH 030/196] Fix CI workflow paths to use target/mobench/ output directory --- .github/workflows/mobile-bench.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index 2d55817..b8b46b5 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -86,14 +86,14 @@ jobs: gradle-version: 8.7 - name: Assemble APK - working-directory: android + working-directory: target/mobench/android run: gradle :app:assembleDebug - name: Upload APK artifact uses: actions/upload-artifact@v4 with: name: mobile-bench-android-apk - path: android/app/build/outputs/apk/debug/*.apk + path: target/mobench/android/app/build/outputs/apk/debug/*.apk ios: if: ${{ github.event.inputs.platform == 'ios' || github.event.inputs.platform == 'both' || github.event.inputs.platform == '' }} @@ -116,9 +116,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: mobile-bench-ios - path: | - target/ios/sample_fns.xcframework - target/ios/include/sample_fns.h + path: target/mobench/ios/sample_fns.xcframework browserstack: name: BrowserStack run From 2e2e2806f58ceba7ca2919d444d86d3b68895216 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 21:25:24 +0100 Subject: [PATCH 031/196] Fix DX issues from 0.1.10 report Android fixes: - Add APK detection for both signed and unsigned builds - Parse output-metadata.json for actual APK filename - Add proguard-rules.pro template for release builds - Update .gitignore to exclude jniLibs and uniffi bindings iOS fixes: - Sanitize bundle identifiers (remove hyphens/underscores) - Framework bundle IDs now use alphanumeric chars only - Add logging for config loading failures - Update .gitignore to exclude xcodeproj, frameworks, bindings Config fixes: - Add logging for missing/invalid JSON keys in templates - Android MainActivity logs warnings for missing config values - iOS BenchRunnerFFI prints errors when JSON parsing fails --- crates/mobench-sdk/src/builders/android.rs | 217 ++++++++-- crates/mobench-sdk/src/builders/ios.rs | 8 +- crates/mobench-sdk/src/codegen.rs | 58 ++- .../mobench-sdk/templates/android/.gitignore | 7 +- .../templates/android/app/proguard-rules.pro | 22 + .../src/main/java/MainActivity.kt.template | 46 ++- .../templates/ios/BenchRunner/.gitignore | 9 + .../BenchRunner/BenchRunnerFFI.swift.template | 4 + mobench-0.1.10-dx-report.md | 242 +++++++++++ mobench-0.1.9-dx-report.md | 388 ------------------ 10 files changed, 571 insertions(+), 430 deletions(-) create mode 100644 crates/mobench-sdk/templates/android/app/proguard-rules.pro create mode 100644 mobench-0.1.10-dx-report.md delete mode 100644 mobench-0.1.9-dx-report.md diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index d70d602..d7ecd8f 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -894,26 +894,118 @@ impl AndroidBuilder { BuildProfile::Release => "release", }; - let apk_path = android_dir - .join("app/build/outputs/apk") - .join(profile_name) - .join(format!("app-{}.apk", profile_name)); + let apk_dir = android_dir.join("app/build/outputs/apk").join(profile_name); - if !apk_path.exists() { - return Err(BenchError::Build(format!( - "APK not found at expected location: {}.\n\n\ - Gradle task {} reported success but no APK was produced.\n\ - Check app/build/outputs/apk/{} and rerun ./gradlew {} if needed.", - apk_path.display(), - gradle_task, - profile_name, - gradle_task - ))); - } + // Try to find APK - check multiple possible filenames + // Gradle produces different names depending on signing configuration: + // - app-release.apk (signed) + // - app-release-unsigned.apk (unsigned release) + // - app-debug.apk (debug) + let apk_path = self.find_apk(&apk_dir, profile_name, gradle_task)?; Ok(apk_path) } + /// Finds the APK file in the build output directory + /// + /// Gradle produces different APK filenames depending on signing configuration: + /// - `app-release.apk` - signed release build + /// - `app-release-unsigned.apk` - unsigned release build + /// - `app-debug.apk` - debug build + /// + /// This method also checks for `output-metadata.json` which contains the actual + /// output filename when present. + fn find_apk(&self, apk_dir: &Path, profile_name: &str, gradle_task: &str) -> Result { + // First, try to read output-metadata.json for the actual APK name + let metadata_path = apk_dir.join("output-metadata.json"); + if metadata_path.exists() { + if let Ok(metadata_content) = fs::read_to_string(&metadata_path) { + // Parse the JSON to find the outputFile + // Format: {"elements":[{"outputFile":"app-release-unsigned.apk",...}]} + if let Some(apk_name) = self.parse_output_metadata(&metadata_content) { + let apk_path = apk_dir.join(&apk_name); + if apk_path.exists() { + if self.verbose { + println!(" Found APK from output-metadata.json: {}", apk_path.display()); + } + return Ok(apk_path); + } + } + } + } + + // Define candidates in order of preference + let candidates = if profile_name == "release" { + vec![ + format!("app-{}.apk", profile_name), // Signed release + format!("app-{}-unsigned.apk", profile_name), // Unsigned release + ] + } else { + vec![ + format!("app-{}.apk", profile_name), // Debug + ] + }; + + // Check each candidate + for candidate in &candidates { + let apk_path = apk_dir.join(candidate); + if apk_path.exists() { + if self.verbose { + println!(" Found APK: {}", apk_path.display()); + } + return Ok(apk_path); + } + } + + // No APK found - provide helpful error message + Err(BenchError::Build(format!( + "APK not found in {}.\n\n\ + Gradle task {} reported success but no APK was produced.\n\ + Searched for:\n{}\n\n\ + Check the build output directory and rerun ./gradlew {} if needed.", + apk_dir.display(), + gradle_task, + candidates.iter().map(|c| format!(" - {}", c)).collect::>().join("\n"), + gradle_task + ))) + } + + /// Parses output-metadata.json to extract the APK filename + /// + /// The JSON format is: + /// ```json + /// { + /// "elements": [ + /// { + /// "outputFile": "app-release-unsigned.apk", + /// ... + /// } + /// ] + /// } + /// ``` + fn parse_output_metadata(&self, content: &str) -> Option { + // Simple JSON parsing without external dependencies + // Look for "outputFile":"" + let pattern = "\"outputFile\""; + if let Some(pos) = content.find(pattern) { + let after_key = &content[pos + pattern.len()..]; + // Skip whitespace and colon + let after_colon = after_key.trim_start().strip_prefix(':')?; + let after_ws = after_colon.trim_start(); + // Extract the string value + if after_ws.starts_with('"') { + let value_start = &after_ws[1..]; + if let Some(end_quote) = value_start.find('"') { + let filename = &value_start[..end_quote]; + if filename.ends_with(".apk") { + return Some(filename.to_string()); + } + } + } + } + None + } + /// Builds the Android test APK using Gradle fn build_test_apk(&self, config: &BuildConfig) -> Result { let android_dir = self.output_dir.join("android"); @@ -981,25 +1073,60 @@ impl AndroidBuilder { BuildProfile::Release => "release", }; - let apk_path = android_dir + let test_apk_dir = android_dir .join("app/build/outputs/apk/androidTest") - .join(profile_name) - .join(format!("app-{}-androidTest.apk", profile_name)); + .join(profile_name); - if !apk_path.exists() { - return Err(BenchError::Build(format!( - "Android test APK not found at expected location: {}.\n\n\ - Gradle task {} reported success but no test APK was produced.\n\ - Check app/build/outputs/apk/androidTest/{} and rerun ./gradlew {} if needed.", - apk_path.display(), - gradle_task, - profile_name, - gradle_task - ))); - } + // Find the test APK - use similar logic to main APK + let apk_path = self.find_test_apk(&test_apk_dir, profile_name, gradle_task)?; Ok(apk_path) } + + /// Finds the test APK file in the build output directory + /// + /// Test APKs can have different naming patterns depending on the build: + /// - `app-debug-androidTest.apk` + /// - `app-release-androidTest.apk` + fn find_test_apk(&self, apk_dir: &Path, profile_name: &str, gradle_task: &str) -> Result { + // First, try to read output-metadata.json for the actual APK name + let metadata_path = apk_dir.join("output-metadata.json"); + if metadata_path.exists() { + if let Ok(metadata_content) = fs::read_to_string(&metadata_path) { + if let Some(apk_name) = self.parse_output_metadata(&metadata_content) { + let apk_path = apk_dir.join(&apk_name); + if apk_path.exists() { + if self.verbose { + println!(" Found test APK from output-metadata.json: {}", apk_path.display()); + } + return Ok(apk_path); + } + } + } + } + + // Check standard naming pattern + let apk_path = apk_dir.join(format!("app-{}-androidTest.apk", profile_name)); + if apk_path.exists() { + if self.verbose { + println!(" Found test APK: {}", apk_path.display()); + } + return Ok(apk_path); + } + + // No test APK found + Err(BenchError::Build(format!( + "Android test APK not found in {}.\n\n\ + Gradle task {} reported success but no test APK was produced.\n\ + Expected: app-{}-androidTest.apk\n\n\ + Check app/build/outputs/apk/androidTest/{} and rerun ./gradlew {} if needed.", + apk_dir.display(), + gradle_task, + profile_name, + profile_name, + gradle_task + ))) + } } #[cfg(test)] @@ -1028,4 +1155,36 @@ mod tests { .output_dir("/custom/output"); assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); } + + #[test] + fn test_parse_output_metadata_unsigned() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + let metadata = r#"{"version":3,"artifactType":{"type":"APK","kind":"Directory"},"applicationId":"dev.world.bench","variantName":"release","elements":[{"type":"SINGLE","filters":[],"attributes":[],"versionCode":1,"versionName":"0.1","outputFile":"app-release-unsigned.apk"}],"elementType":"File"}"#; + let result = builder.parse_output_metadata(metadata); + assert_eq!(result, Some("app-release-unsigned.apk".to_string())); + } + + #[test] + fn test_parse_output_metadata_signed() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + let metadata = r#"{"version":3,"elements":[{"outputFile":"app-release.apk"}]}"#; + let result = builder.parse_output_metadata(metadata); + assert_eq!(result, Some("app-release.apk".to_string())); + } + + #[test] + fn test_parse_output_metadata_no_apk() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + let metadata = r#"{"version":3,"elements":[]}"#; + let result = builder.parse_output_metadata(metadata); + assert_eq!(result, None); + } + + #[test] + fn test_parse_output_metadata_invalid_json() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + let metadata = "not valid json"; + let result = builder.parse_output_metadata(metadata); + assert_eq!(result, None); + } } diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 131fa0d..874b380 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -916,7 +916,13 @@ impl IosBuilder { framework_name: &str, platform: &str, ) -> Result<(), BenchError> { - let bundle_id = framework_name.replace('_', "-"); + // Sanitize bundle ID to only contain alphanumeric characters (no hyphens or underscores) + // iOS bundle identifiers should be alphanumeric with dots separating components + let bundle_id: String = framework_name + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::() + .to_lowercase(); let plist_content = format!( r#" diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index f2889d3..1efd6e7 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -40,7 +40,9 @@ pub fn generate_project(config: &InitConfig) -> Result { let output_dir = &config.output_dir; let project_slug = sanitize_package_name(&config.project_name); let project_pascal = to_pascal_case(&project_slug); - let bundle_prefix = format!("dev.world.{}", project_slug); + // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues + let bundle_id_component = sanitize_bundle_id_component(&project_slug); + let bundle_prefix = format!("dev.world.{}", bundle_id_component); // Create base directories fs::create_dir_all(output_dir)?; @@ -343,6 +345,16 @@ pub fn generate_ios_project( default_function: &str, ) -> Result<(), BenchError> { let target_dir = output_dir.join("ios"); + // Sanitize bundle ID components to ensure they only contain alphanumeric characters + // iOS bundle identifiers should not contain hyphens or underscores + let sanitized_bundle_prefix = { + let parts: Vec<&str> = bundle_prefix.split('.').collect(); + parts.iter() + .map(|part| sanitize_bundle_id_component(part)) + .collect::>() + .join(".") + }; + let sanitized_project_slug = sanitize_bundle_id_component(project_slug); let vars = vec![ TemplateVar { name: "DEFAULT_FUNCTION", @@ -354,11 +366,11 @@ pub fn generate_ios_project( }, TemplateVar { name: "BUNDLE_ID_PREFIX", - value: bundle_prefix.to_string(), + value: sanitized_bundle_prefix.clone(), }, TemplateVar { name: "BUNDLE_ID", - value: format!("{}.{}", bundle_prefix, project_slug), + value: format!("{}.{}", sanitized_bundle_prefix, sanitized_project_slug), }, TemplateVar { name: "LIBRARY_NAME", @@ -585,6 +597,23 @@ fn render_template(input: &str, vars: &[TemplateVar]) -> String { output } +/// Sanitizes a string to be a valid iOS bundle identifier component +/// +/// Bundle identifiers can only contain alphanumeric characters (A-Z, a-z, 0-9), +/// hyphens (-), and dots (.). However, to avoid issues and maintain consistency, +/// this function converts all non-alphanumeric characters to lowercase letters only. +/// +/// Examples: +/// - "bench-mobile" -> "benchmobile" +/// - "bench_mobile" -> "benchmobile" +/// - "my-project_name" -> "myprojectname" +pub fn sanitize_bundle_id_component(name: &str) -> String { + name.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::() + .to_lowercase() +} + fn sanitize_package_name(name: &str) -> String { name.chars() .map(|c| { @@ -824,7 +853,10 @@ pub fn ensure_ios_project_with_options( let project_pascal = "BenchRunner"; // Derive library name and bundle prefix from crate name let library_name = crate_name.replace('-', "_"); - let bundle_prefix = format!("dev.world.{}", library_name.replace('_', "-")); + // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues + // e.g., "bench-mobile" or "bench_mobile" -> "benchmobile" + let bundle_id_component = sanitize_bundle_id_component(crate_name); + let bundle_prefix = format!("dev.world.{}", bundle_id_component); // Resolve the default function by auto-detecting from source let effective_root = project_root.unwrap_or_else(|| { @@ -1050,4 +1082,22 @@ pub fn public_bench() { // Cleanup fs::remove_dir_all(&temp_dir).ok(); } + + #[test] + fn test_sanitize_bundle_id_component() { + // Hyphens should be removed + assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile"); + // Underscores should be removed + assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile"); + // Mixed separators should all be removed + assert_eq!(sanitize_bundle_id_component("my-project_name"), "myprojectname"); + // Already valid should remain unchanged (but lowercase) + assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile"); + // Numbers should be preserved + assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile"); + // Uppercase should be lowercased + assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile"); + // Complex case + assert_eq!(sanitize_bundle_id_component("My-Complex_Project-123"), "mycomplexproject123"); + } } diff --git a/crates/mobench-sdk/templates/android/.gitignore b/crates/mobench-sdk/templates/android/.gitignore index ad17687..8154a1d 100644 --- a/crates/mobench-sdk/templates/android/.gitignore +++ b/crates/mobench-sdk/templates/android/.gitignore @@ -16,5 +16,8 @@ local.properties # Kotlin *.kotlin_module -# Local configuration -local.properties +# Native libraries (copied by mobench build) +app/src/main/jniLibs/ + +# Generated bindings (regenerated by mobench build) +app/src/main/java/uniffi/ diff --git a/crates/mobench-sdk/templates/android/app/proguard-rules.pro b/crates/mobench-sdk/templates/android/app/proguard-rules.pro new file mode 100644 index 0000000..f0aea7c --- /dev/null +++ b/crates/mobench-sdk/templates/android/app/proguard-rules.pro @@ -0,0 +1,22 @@ +# ProGuard rules for mobench Android benchmark app +# These rules ensure UniFFI and JNA work correctly when minification is enabled. + +# Keep JNA classes for UniFFI +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } + +# Keep UniFFI generated bindings +-keep class uniffi.** { *; } + +# Keep application benchmark classes +-keepclassmembers class * { + @uniffi.* ; +} + +# Keep native method names (required for JNI) +-keepclasseswithmembernames class * { + native ; +} + +# Keep Kotlin metadata for reflection +-keep class kotlin.Metadata { *; } diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index ee1f5c0..c334df6 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -148,17 +148,51 @@ class MainActivity : AppCompatActivity() { return try { val raw = assets.open(SPEC_ASSET).bufferedReader().use { it.readText() } if (raw.isBlank()) { + android.util.Log.w("BenchRunner", "bench_spec.json exists but is empty, using defaults") null } else { val json = JSONObject(raw) - BenchParams( - json.optString("function", DEFAULT_FUNCTION), - json.optInt("iterations", DEFAULT_ITERATIONS.toInt()).toUInt(), - json.optInt("warmup", DEFAULT_WARMUP.toInt()).toUInt(), - ) + + // Log warnings for missing or invalid config values + val function = if (json.has("function")) { + json.getString("function") + } else { + android.util.Log.w("BenchRunner", "Config missing 'function' key, using default: $DEFAULT_FUNCTION") + DEFAULT_FUNCTION + } + + val iterations = if (json.has("iterations")) { + try { + json.getInt("iterations").toUInt() + } catch (e: Exception) { + android.util.Log.w("BenchRunner", "Config 'iterations' is not a valid integer: ${json.opt("iterations")}, using default: $DEFAULT_ITERATIONS") + DEFAULT_ITERATIONS + } + } else { + android.util.Log.w("BenchRunner", "Config missing 'iterations' key, using default: $DEFAULT_ITERATIONS") + DEFAULT_ITERATIONS + } + + val warmup = if (json.has("warmup")) { + try { + json.getInt("warmup").toUInt() + } catch (e: Exception) { + android.util.Log.w("BenchRunner", "Config 'warmup' is not a valid integer: ${json.opt("warmup")}, using default: $DEFAULT_WARMUP") + DEFAULT_WARMUP + } + } else { + android.util.Log.w("BenchRunner", "Config missing 'warmup' key, using default: $DEFAULT_WARMUP") + DEFAULT_WARMUP + } + + android.util.Log.i("BenchRunner", "Loaded config from bench_spec.json: function=$function, iterations=$iterations, warmup=$warmup") + BenchParams(function, iterations, warmup) } + } catch (e: java.io.FileNotFoundException) { + android.util.Log.d("BenchRunner", "No bench_spec.json in assets, will use intent extras or defaults") + null } catch (e: Exception) { - android.util.Log.w("MainActivity", "Failed to load bench_spec.json from assets", e) + android.util.Log.e("BenchRunner", "Failed to parse bench_spec.json from assets", e) null } } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore b/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore index be86720..b4f2cba 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore +++ b/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore @@ -4,6 +4,7 @@ DerivedData/ *.xcworkspace xcuserdata/ *.xcuserstate +*.xcodeproj/ # CocoaPods (if used) Pods/ @@ -16,3 +17,11 @@ Pods/ # IDE .idea/ *.swp + +# Native libraries and frameworks (copied/built by mobench) +*.xcframework/ +*.framework/ +*.a + +# Generated bindings (regenerated by mobench build) +BenchRunner/Generated/ diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index e52509d..3ae7978 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -17,13 +17,17 @@ struct BenchParams { static func fromBundle() -> BenchParams? { guard let url = Bundle.main.url(forResource: "bench_spec", withExtension: "json") else { + print("[BenchRunner] No bench_spec.json found in bundle, will use process info or defaults") return nil } do { let data = try Data(contentsOf: url) let decoded = try JSONDecoder().decode(EncodedBenchSpec.self, from: data) + print("[BenchRunner] Loaded config from bench_spec.json: function=\(decoded.function), iterations=\(decoded.iterations), warmup=\(decoded.warmup)") return BenchParams(function: decoded.function, iterations: decoded.iterations, warmup: decoded.warmup) } catch { + print("[BenchRunner] ERROR: Failed to parse bench_spec.json: \(error)") + print("[BenchRunner] Will fall back to process info or defaults") return nil } } diff --git a/mobench-0.1.10-dx-report.md b/mobench-0.1.10-dx-report.md new file mode 100644 index 0000000..5e34e91 --- /dev/null +++ b/mobench-0.1.10-dx-report.md @@ -0,0 +1,242 @@ +# mobench 0.1.10 DX (Developer Experience) Report + +**Date:** 2026-01-19 +**Tested Version:** mobench-sdk 0.1.10, mobench CLI 0.1.10 +**Platform:** macOS Darwin 25.1.0 (arm64) +**Previous Version Tested:** 0.1.9 + +## Executive Summary + +mobench 0.1.10 represents a **major improvement** over 0.1.9, fixing all 12 critical template placeholder bugs identified in the previous version. Both Android and iOS builds now complete successfully without manual intervention. + +**Build Status:** +- Android: APK built successfully (133MB) +- iOS: xcframework and app built successfully with universal simulator support + +**Remaining Issues:** 2 critical, 4 high, 5 medium severity + +--- + +## Improvements from 0.1.9 to 0.1.10 + +### Fixed Issues (12 total from 0.1.9) + +| # | Issue | Status in 0.1.10 | +|---|-------|------------------| +| 1 | `{{PACKAGE_NAME}}` not replaced | ✅ FIXED - Uses `dev.world.bench_mobile` | +| 2 | `{{LIBRARY_NAME}}` not replaced | ✅ FIXED - Uses `libbench_mobile.so` | +| 3 | `{{PROJECT_NAME}}` not replaced | ✅ FIXED - Uses `bench_mobile-android` | +| 4 | `{{PROJECT_NAME_PASCAL}}` not replaced | ✅ FIXED - Theme uses `Theme.BenchMobile` | +| 5 | `{{APP_NAME}}` not replaced | ✅ FIXED - Uses "BenchMobile Benchmark" | +| 6 | Missing `gradle.properties` | ✅ FIXED - Now generated with AndroidX settings | +| 7 | Missing Gradle wrapper | ✅ FIXED - Now auto-generated | +| 8 | Invalid AGP version 8.13.2 | ✅ FIXED - Uses valid 8.2.2 | +| 9 | Package name mismatch | ✅ FIXED - Consistent naming | +| 10 | Missing x86_64 iOS simulator | ✅ FIXED - Universal binary created | +| 11 | Path handling bug | ✅ FIXED - Proper path resolution | +| 12 | Default benchmark function mismatch | ✅ FIXED - Uses actual benchmark name | + +--- + +## New/Remaining Issues in 0.1.10 + +### Critical Issues (2) + +#### Issue 1: APK Filename Mismatch (NEW) +**Severity:** CRITICAL +**Type:** Silent Failure + +The Android build creates `app-release-unsigned.apk` but mobench expects `app-release.apk`, causing a false build failure message. + +**Actual output:** +``` +target/mobench/android/app/build/outputs/apk/release/app-release-unsigned.apk (133MB) +``` + +**mobench error:** +``` +build error: APK not found at expected location: .../app-release.apk +``` + +**Impact:** Build succeeds but mobench reports failure. APK exists and is usable. + +**Fix Required:** Either add signing config to produce `app-release.apk`, or update mobench to check for `app-release-unsigned.apk` fallback. + +--- + +#### Issue 2: iOS Bundle Identifier Contains Invalid Characters +**Severity:** CRITICAL +**File:** `target/mobench/ios/BenchRunner/BenchRunner.xcodeproj/project.pbxproj` + +Bundle identifier `dev.world.bench-mobile.bench_mobile` contains both hyphens and underscores. + +**Impact:** +- App Store submission will be rejected +- Code signing issues on physical devices +- Xcode warning: "invalid character in Bundle Identifier" + +**Fix Required:** Use `dev.world.benchmobile.benchmobile` (no hyphens or underscores). + +--- + +### High Severity Issues (4) + +#### Issue 3: Missing ProGuard Configuration +**File:** `target/mobench/android/app/proguard-rules.pro` (missing) + +The `build.gradle` references `proguard-rules.pro` but the file doesn't exist. Builds fail if ProGuard is enabled. + +**Recommended content:** +```proguard +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } +-keep class uniffi.bench_mobile.** { *; } +-keep class dev.world.bench_mobile.** { *; } +``` + +--- + +#### Issue 4: Silent Config Loading Failures (iOS) +**File:** `target/mobench/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift` (lines 18-29) + +`BenchParams.fromBundle()` silently returns `nil` on JSON parse errors, falling back to defaults without logging. + +--- + +#### Issue 5: Silent Config Loading Failures (Android) +**File:** `target/mobench/android/app/src/main/java/MainActivity.kt` (lines 147-164) + +`optString`/`optInt` methods silently use defaults for missing/mistyped JSON keys. + +--- + +#### Issue 6: Machine-Specific Path in local.properties +**File:** `target/mobench/android/local.properties` + +Contains hardcoded developer-specific SDK path that won't work on other machines. + +--- + +### Medium Severity Issues (5) + +| # | Issue | Location | +|---|-------|----------| +| 7 | Bundle ID inconsistency between platforms | Android: `dev.world.bench_mobile`, iOS: `dev.world.bench-mobile` | +| 8 | Version string format mismatch | Android: "0.1", iOS: "0.1.0" | +| 9 | Deployment target gap | Android minSdk 24 (2016), iOS 15.0 (2021) | +| 10 | UniFFI dispose errors swallowed | `bench_mobile.kt` line 895-905 | +| 11 | Incomplete .gitignore files | Missing native artifact patterns | + +--- + +## Build Output Summary + +### Android +``` +Location: target/mobench/android/ +APK: app/build/outputs/apk/release/app-release-unsigned.apk +Size: 133,561,304 bytes (127 MB) +Architectures: arm64-v8a, armeabi-v7a, x86_64 +``` + +### iOS +``` +Location: target/mobench/ios/ +Framework: bench_mobile.xcframework/ +App: BenchRunner.app (built in DerivedData) +Architectures: ios-arm64, ios-arm64_x86_64-simulator +Size: 267 MB total +``` + +--- + +## Comparison: 0.1.9 vs 0.1.10 + +| Metric | 0.1.9 | 0.1.10 | +|--------|-------|--------| +| **Critical Bugs** | 12 | 2 | +| **Manual Fixes Required** | 5 | 0 | +| **Android Build** | Fails without fixes | Builds (reports false failure) | +| **iOS Build** | Builds (arm64 only) | Builds (universal) | +| **x86_64 Simulator** | ❌ Missing | ✅ Included | +| **Gradle Wrapper** | ❌ Missing | ✅ Generated | +| **gradle.properties** | ❌ Missing | ✅ Generated | +| **Template Placeholders** | 5 unreplaced | All replaced | +| **Default Benchmark** | Wrong name | Correct name | + +--- + +## Test Commands Used + +```bash +# Upgrade mobench CLI +cargo install mobench --version 0.1.10 --force + +# Update SDK dependency +# In bench-mobile/Cargo.toml: mobench-sdk = "0.1.10" +cargo update -p mobench-sdk + +# Clean and build +rm -rf target/mobench +mobench build --target android --release --verbose +mobench build --target ios --release --verbose + +# Verify Android APK (despite mobench error) +ls -la target/mobench/android/app/build/outputs/apk/release/ + +# Build iOS app with Xcode +cd target/mobench/ios/BenchRunner +xcodebuild -scheme BenchRunner -configuration Release \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +``` + +--- + +## Recommended Priority Fixes for 0.1.11 + +### P0 (Critical - Fix Immediately) + +1. **APK filename detection** - Check for both `app-release.apk` and `app-release-unsigned.apk`, or parse `output-metadata.json` + +2. **Bundle identifier fix** - Use consistent naming without hyphens/underscores: `dev.world.benchmobile.benchmobile` + +### P1 (High - Fix Before Release) + +3. **Add proguard-rules.pro** to Android templates + +4. **Log config loading errors** instead of silently falling back to defaults + +5. **Don't generate local.properties** with hardcoded SDK paths + +### P2 (Medium - Nice to Have) + +6. Standardize bundle ID across platforms +7. Standardize version string format (semver) +8. Improve .gitignore completeness +9. Add logging for UniFFI cleanup errors + +--- + +## Agent Analysis Summary + +Three debugging agents were deployed in parallel: + +1. **Code Reviewer Agent** - Found APK naming, ProGuard, and local.properties issues +2. **Silent Failure Hunter Agent** - Found config loading fallbacks and bundle ID issues +3. **Explorer Agent** - Found cross-platform inconsistencies and missing files + +All agents converged on the same critical issues, confirming their validity. + +--- + +## Conclusion + +**mobench 0.1.10 is a significant improvement** over 0.1.9. All template placeholder bugs are fixed, and both platforms build successfully without manual intervention. + +The remaining issues are primarily: +1. APK filename detection (false failure report) +2. iOS bundle identifier format + +**Recommendation:** 0.1.10 is usable for development. The APK is built correctly despite the error message. Fix the bundle identifier before App Store submission. + +**Overall Score:** 8.5/10 (up from 4/10 for 0.1.9) diff --git a/mobench-0.1.9-dx-report.md b/mobench-0.1.9-dx-report.md deleted file mode 100644 index b3502ce..0000000 --- a/mobench-0.1.9-dx-report.md +++ /dev/null @@ -1,388 +0,0 @@ -# mobench 0.1.9 DX (Developer Experience) Report - -**Date:** 2026-01-19 -**Tested Version:** mobench-sdk 0.1.9, mobench CLI 0.1.9 -**Platform:** macOS Darwin 25.1.0 (arm64) -**Crate:** bench-mobile (World ID ZK Proof Benchmarks) - -## Executive Summary - -End-to-end testing of mobench 0.1.9 for both Android and iOS builds revealed **12 critical bugs**, **8 high-severity issues**, and multiple DX improvement opportunities. The primary problems involve template substitution failures, missing configuration files, and silent failures that mask underlying errors. - -**Build Status:** -- Android: Built successfully after 4 manual fixes -- iOS: Built successfully for arm64 simulator; x86_64 not supported - ---- - -## Critical Bugs - -### Bug 1: `{{PROJECT_NAME}}` Placeholder Not Replaced -**Severity:** CRITICAL -**File:** `target/mobench/android/settings.gradle` - -The `PROJECT_NAME` template variable is **not defined** in the codegen template variable list, leaving the placeholder literally in the generated file: - -```gradle -rootProject.name = "{{PROJECT_NAME}}-android" // NOT REPLACED -``` - -**Impact:** Gradle shows the project as "{{PROJECT_NAME}}-android" in IDE. - -**Fix:** Add `PROJECT_NAME` to template variables in `codegen.rs`. - ---- - -### Bug 2: `{{PACKAGE_NAME}}` Placeholder Not Replaced -**Severity:** CRITICAL -**File:** `target/mobench/android/app/build.gradle` - -Multiple occurrences of `{{PACKAGE_NAME}}` not substituted: -- Line 5: `namespace = "{{PACKAGE_NAME}}"` -- Line 15: `applicationId "{{PACKAGE_NAME}}"` - -**Impact:** Gradle build fails with "{{PACKAGE_NAME}} is not a valid Java identifier". - -**Manual Fix Applied:** -```gradle -namespace = "dev.world.bench_mobile" -applicationId "dev.world.bench_mobile" -``` - ---- - -### Bug 3: `{{LIBRARY_NAME}}` Placeholder Not Replaced -**Severity:** CRITICAL -**File:** `target/mobench/android/app/build.gradle` (line 57) - -```gradle -keepDebugSymbols += ["**/lib{{LIBRARY_NAME}}.so"] // NOT REPLACED -``` - -**Manual Fix Applied:** -```gradle -keepDebugSymbols += ["**/libbench_mobile.so"] -``` - ---- - -### Bug 4: `{{PROJECT_NAME_PASCAL}}` Placeholder Not Replaced -**Severity:** CRITICAL -**File:** `target/mobench/android/app/src/main/AndroidManifest.xml` - -```xml -android:theme="@style/Theme.{{PROJECT_NAME_PASCAL}}" // NOT REPLACED -``` - -**Impact:** Android resource linking fails with "style/Theme.{{PROJECT_NAME_PASCAL}} not found". - -**Manual Fix Applied:** -```xml -android:theme="@style/Theme.MobileBench" -``` - ---- - -### Bug 5: `{{APP_NAME}}` Placeholder Not Replaced -**Severity:** CRITICAL -**File:** `target/mobench/android/app/src/main/res/values/strings.xml` - -```xml -{{APP_NAME}} // NOT REPLACED -``` - -**Impact:** App displays "{{APP_NAME}}" as its title. - ---- - -### Bug 6: Missing `gradle.properties` -**Severity:** CRITICAL -**Expected:** `target/mobench/android/gradle.properties` - -The file doesn't exist in the scaffolded output, causing: -``` -Configuration contains AndroidX dependencies, but android.useAndroidX property is not enabled -``` - -**Manual Fix Applied:** Created file with: -```properties -android.useAndroidX=true -android.enableJetifier=true -org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC -org.gradle.daemon=true -org.gradle.parallel=true -org.gradle.caching=true -kotlin.code.style=official -``` - ---- - -### Bug 7: Missing Gradle Wrapper (gradlew) -**Severity:** CRITICAL -**Expected:** `target/mobench/android/gradlew` - -The scaffolded project doesn't include the Gradle wrapper files, causing: -``` -Command: ./gradlew assembleRelease -Error: No such file or directory -``` - -**Manual Fix Applied:** Generated wrapper with `gradle wrapper --gradle-version 8.5` - ---- - -### Bug 8: Invalid Android Gradle Plugin Version -**Severity:** CRITICAL -**File:** `target/mobench/android/build.gradle` - -Template uses: -```gradle -classpath 'com.android.tools.build:gradle:8.13.2' // DOES NOT EXIST -``` - -AGP version 8.13.2 doesn't exist. Latest stable is 8.2.x. - -**Manual Fix Applied:** -```gradle -classpath 'com.android.tools.build:gradle:8.2.2' -``` - ---- - -### Bug 9: Package Name Mismatch -**Severity:** HIGH -**Files:** build.gradle vs Kotlin sources - -- build.gradle template uses `{{PACKAGE_NAME}}` placeholder -- Kotlin sources are hardcoded to `dev.world.bench_mobile` - -These must match or builds fail. The Kotlin files don't use template variables. - ---- - -### Bug 10: Missing x86_64 iOS Simulator Architecture -**Severity:** HIGH -**File:** `target/mobench/ios/bench_mobile.xcframework/Info.plist` - -The xcframework only includes: -- `ios-arm64` (device) -- `ios-simulator-arm64` (Apple Silicon simulator) - -Missing: `ios-simulator-x86_64` (Intel Mac simulator) - -**Impact:** Build fails on Intel Macs and older CI runners: -``` -ld: symbol(s) not found for architecture x86_64 -``` - ---- - -### Bug 11: Path Handling Bug -**Severity:** HIGH - -Running `mobench build --target ios` from within `target/mobench/android/` causes iOS project to be generated at `target/mobench/android/target/mobench/ios/` instead of `target/mobench/ios/`. - ---- - -### Bug 12: Default Benchmark Function Mismatch -**Severity:** HIGH -**Files:** MainActivity.kt, BenchRunnerFFI.swift - -Both use `DEFAULT_FUNCTION = "example_fibonacci"` but the actual benchmarks are: -- `bench_mobile::bench_query_proof_generation` -- `bench_mobile::bench_nullifier_proof_generation` - -**Impact:** Fresh app launch fails with "unknown benchmark function" error. - ---- - -## High Severity Issues - -### Issue 1: No Post-Template Validation -Template rendering doesn't validate that all `{{PLACEHOLDER}}` patterns were replaced. Files are written with literal placeholder text. - -### Issue 2: Silent Codesign Failure -`codesign_xcframework` returns `Ok(())` even when signing fails, just printing a warning. - -### Issue 3: Silent xcodegen Failure -`generate_xcode_project` returns `Ok(())` when xcodegen fails, just printing a warning. - -### Issue 4: Silent Native Library Skip -Missing `.so` files are silently skipped without `--verbose`, leading to runtime crashes. - -### Issue 5: No Path Validation -No validation that the project root exists, is a directory, or contains Cargo.toml. - -### Issue 6: cargo metadata Fallback Hides Errors -Falls back to `crate_dir/target` silently when workspace metadata parsing fails. - -### Issue 7: Empty Catch Block in Kotlin -```kotlin -} catch (_: Exception) { - null // Swallows all exceptions -} -``` - -### Issue 8: No Build Completion Validation -No verification that all expected artifacts exist after build completes. - ---- - -## Medium/Low Severity Issues - -| Issue | Severity | Description | -|-------|----------|-------------| -| Missing .gitignore | MEDIUM | No .gitignore in scaffolded projects | -| local.properties committed | MEDIUM | Contains machine-specific SDK path | -| No README generated | LOW | No documentation in scaffold output | -| Benchmark naming inconsistency | MEDIUM | Tests use `world_id_mobile_bench::` prefix, examples use `bench_mobile::` | - ---- - -## Manual Fixes Applied During Testing - -### Android Fixes (in order): - -1. **build.gradle (root):** - ```diff - - classpath 'com.android.tools.build:gradle:8.13.2' - + classpath 'com.android.tools.build:gradle:8.2.2' - ``` - -2. **app/build.gradle:** - ```diff - - namespace = "{{PACKAGE_NAME}}" - + namespace = "dev.world.bench_mobile" - - - applicationId "{{PACKAGE_NAME}}" - + applicationId "dev.world.bench_mobile" - - - keepDebugSymbols += ["**/lib{{LIBRARY_NAME}}.so"] - + keepDebugSymbols += ["**/libbench_mobile.so"] - ``` - -3. **AndroidManifest.xml:** - ```diff - - android:theme="@style/Theme.{{PROJECT_NAME_PASCAL}}" - + android:theme="@style/Theme.MobileBench" - ``` - -4. **Created gradle.properties** (entire file) - -5. **Generated Gradle wrapper:** - ```bash - cd target/mobench/android && gradle wrapper --gradle-version 8.5 - ``` - -### iOS Fixes: -None required - build completed for arm64 simulator. Device builds require signing configuration. - ---- - -## Recommended Priority Fixes for mobench-sdk - -### P0 (Blocker - Fix Immediately) - -1. **Fix template variable substitution** - - Add `PROJECT_NAME`, `PROJECT_NAME_PASCAL`, `APP_NAME`, `PACKAGE_NAME`, `LIBRARY_NAME` to all template contexts - - Ensure all placeholders are defined before rendering - -2. **Add placeholder validation** - ```rust - // After render_template(), validate no {{...}} remain - if output.contains("{{") && output.contains("}}") { - return Err(BenchError::Build("Unreplaced placeholder found")); - } - ``` - -3. **Include gradle.properties in templates** - ```properties - android.useAndroidX=true - android.enableJetifier=true - ``` - -4. **Include Gradle wrapper files or generate them** - ```rust - // Generate wrapper if gradle available - Command::new("gradle").arg("wrapper").arg("--gradle-version").arg("8.5") - ``` - -5. **Fix AGP version to valid value (8.2.2)** - -### P1 (High - Fix Before Next Release) - -6. **Add x86_64 iOS simulator support** - ```rust - // Add to iOS build targets - "x86_64-apple-ios" - ``` - -7. **Make error handling explicit** - - Remove `Ok(())` returns on codesign/xcodegen failures - - Add `--skip-signing` and `--skip-xcodegen` flags instead - -8. **Add path validation** - - Verify project_root exists and contains Cargo.toml - - Warn if running from unexpected directory - -9. **Update default benchmark function** - - Generate from discovered benchmarks - - Or use a known-working default - -### P2 (Medium - Nice to Have) - -10. Generate .gitignore files -11. Generate README.md with usage instructions -12. Add build completion validation step -13. Improve error messages with actionable fixes - ---- - -## Test Commands Used - -```bash -# Update mobench CLI -cargo install mobench --version 0.1.9 --force - -# Update SDK dependency -mobench-sdk = "0.1.9" # in Cargo.toml -cargo update -p mobench-sdk - -# Clean and build Android -rm -rf target/mobench -mobench build --target android --release --verbose - -# Build iOS -mobench build --target ios --release --verbose - -# Build Android APK (after fixes) -cd target/mobench/android -gradle wrapper --gradle-version 8.5 -./gradlew assembleRelease - -# Build iOS app for simulator -cd target/mobench/ios/BenchRunner -xcodebuild -scheme BenchRunner -configuration Release \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build -``` - ---- - -## Appendix: File Locations - -| File | Purpose | Status | -|------|---------|--------| -| `bench-mobile/Cargo.toml` | Benchmark crate config | OK | -| `bench-mobile/src/lib.rs` | Benchmark implementations | OK | -| `target/mobench/android/` | Generated Android project | Needs 5 fixes | -| `target/mobench/ios/` | Generated iOS project | OK for arm64 | -| `target/mobench/android/app/build/outputs/apk/release/app-release-unsigned.apk` | Android APK (133MB) | Built | -| `target/mobench/ios/bench_mobile.xcframework/` | iOS framework | Built | - ---- - -## Conclusion - -mobench 0.1.9 has significant DX issues with template substitution being the most critical. The tool generates project scaffolding but fails to replace 5+ template placeholders, omits required configuration files, and uses an invalid AGP version. After manual fixes, both platforms build successfully. - -**Recommendation:** Do not use 0.1.9 in CI/CD without the fixes documented above. Wait for 0.1.10 or later with these issues resolved. From aff2183361b238c9b7b957080aeacaee0257a933 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 21:27:01 +0100 Subject: [PATCH 032/196] Bump version to 0.1.11 for release --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 49ea0f2..dadd2d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "clap", @@ -820,7 +820,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.10" +version = "0.1.11" dependencies = [ "proc-macro2", "quote", @@ -829,7 +829,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "include_dir", @@ -1154,7 +1154,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.10" +version = "0.1.11" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 36c50f8..a3c6cb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.10" +version = "0.1.11" [workspace.dependencies] anyhow = "1" diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 3d62153..80ff7a2 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.10", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.11", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 164ad3b..37e276c 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.10", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.11", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true From cc5d4fa9b00b45f27837c61b2add056c0781f573 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 21:28:12 +0100 Subject: [PATCH 033/196] Update CLAUDE.md version reference to 0.1.11 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa3ae02..c7556f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.9):** +**Published on crates.io as the mobench ecosystem (v0.1.11):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation From ebab92b5634c710010811fbd857954c2fda61c7c Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 21:50:28 +0100 Subject: [PATCH 034/196] Fix DX issues from 0.1.11 report P0 fixes: - Add testBuildType "release" to build.gradle templates - Gradle now creates assembleReleaseAndroidTest task P1 fixes: - local.properties only uses ANDROID_HOME/ANDROID_SDK_ROOT env vars - No longer probes filesystem for hardcoded SDK paths - Kotlin files moved to correct package directory structure P2 fixes: - iOS bundle ID now uses app name: dev.world.benchmobile.BenchRunner - No longer duplicates crate name in bundle ID --- android/app/build.gradle | 2 + crates/mobench-sdk/src/builders/android.rs | 99 ++++--- crates/mobench-sdk/src/codegen.rs | 147 ++++++++++- .../templates/android/app/build.gradle | 2 + mobench-0.1.10-dx-report.md | 242 ------------------ mobench-0.1.11-dx-report.md | 206 +++++++++++++++ templates/android/app/build.gradle | 2 + 7 files changed, 399 insertions(+), 301 deletions(-) delete mode 100644 mobench-0.1.10-dx-report.md create mode 100644 mobench-0.1.11-dx-report.md diff --git a/android/app/build.gradle b/android/app/build.gradle index 34b1371..ff44149 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -18,6 +18,8 @@ android { versionCode 1 versionName "0.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) + testBuildType "release" } buildTypes { diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index d7ecd8f..9fa2409 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -651,8 +651,13 @@ impl AndroidBuilder { /// Ensures local.properties exists with sdk.dir set /// /// Gradle requires this file to know where the Android SDK is located. - /// This function auto-generates the file if missing by detecting the SDK path - /// from environment variables or common installation locations. + /// This function only generates the file if ANDROID_HOME or ANDROID_SDK_ROOT + /// environment variables are set. We intentionally avoid probing filesystem + /// paths to prevent writing machine-specific paths that would break builds + /// on other machines. + /// + /// If neither environment variable is set, we skip generating the file and + /// let Android Studio or Gradle handle SDK detection. fn ensure_local_properties(&self, android_dir: &Path) -> Result<(), BenchError> { let local_props = android_dir.join("local.properties"); @@ -661,81 +666,63 @@ impl AndroidBuilder { return Ok(()); } - // Try to find Android SDK path - let sdk_dir = self.find_android_sdk()?; + // Only generate local.properties if an environment variable is set. + // This avoids writing machine-specific paths that break on other machines. + let sdk_dir = self.find_android_sdk_from_env(); - // Write local.properties - let content = format!("sdk.dir={}\n", sdk_dir.display()); - fs::write(&local_props, content).map_err(|e| { - BenchError::Build(format!( - "Failed to write local.properties at {:?}: {}. Check output directory permissions.", - local_props, e - )) - })?; + match sdk_dir { + Some(path) => { + // Write local.properties with the SDK path from env var + let content = format!("sdk.dir={}\n", path.display()); + fs::write(&local_props, content).map_err(|e| { + BenchError::Build(format!( + "Failed to write local.properties at {:?}: {}. Check output directory permissions.", + local_props, e + )) + })?; - if self.verbose { - println!(" Generated local.properties with sdk.dir={}", sdk_dir.display()); + if self.verbose { + println!(" Generated local.properties with sdk.dir={}", path.display()); + } + } + None => { + // No env var set - skip generating local.properties + // Gradle/Android Studio will auto-detect the SDK or prompt the user + if self.verbose { + println!(" Skipping local.properties generation (ANDROID_HOME/ANDROID_SDK_ROOT not set)"); + println!(" Gradle will auto-detect SDK or you can create local.properties manually"); + } + } } Ok(()) } - /// Finds the Android SDK installation path - fn find_android_sdk(&self) -> Result { - let mut searched = Vec::new(); - + /// Finds the Android SDK installation path from environment variables only + /// + /// Returns Some(path) if ANDROID_HOME or ANDROID_SDK_ROOT is set and the path exists. + /// Returns None if neither is set or the paths don't exist. + /// + /// We intentionally avoid probing common filesystem locations to prevent + /// writing machine-specific paths that would break builds on other machines. + fn find_android_sdk_from_env(&self) -> Option { // Check ANDROID_HOME first (standard) if let Ok(path) = env::var("ANDROID_HOME") { let sdk_path = PathBuf::from(&path); if sdk_path.exists() { - return Ok(sdk_path); + return Some(sdk_path); } - searched.push(sdk_path); } // Check ANDROID_SDK_ROOT (alternative) if let Ok(path) = env::var("ANDROID_SDK_ROOT") { let sdk_path = PathBuf::from(&path); if sdk_path.exists() { - return Ok(sdk_path); - } - searched.push(sdk_path); - } - - // Check common installation locations - if let Ok(home) = env::var("HOME") { - let home_path = PathBuf::from(home); - let candidates = [ - home_path.join("Library/Android/sdk"), // macOS (Android Studio) - home_path.join("Android/Sdk"), // Linux (Android Studio) - home_path.join(".android/sdk"), // Alternative Linux - ]; - - for candidate in &candidates { - if candidate.exists() { - return Ok(candidate.clone()); - } - searched.push(candidate.clone()); + return Some(sdk_path); } } - let searched_list = if searched.is_empty() { - " - (no candidates found)".to_string() - } else { - searched - .iter() - .map(|path| format!(" - {}", path.display())) - .collect::>() - .join("\n") - }; - - Err(BenchError::Build(format!( - "Android SDK not found.\n\n\ - Searched:\n{}\n\n\ - Set ANDROID_HOME or ANDROID_SDK_ROOT to your SDK path (for example: $HOME/Library/Android/sdk).\n\ - You can also install the SDK via Android Studio.", - searched_list - ))) + None } /// Ensures the Gradle wrapper (gradlew) exists in the Android project diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 1efd6e7..56e558d 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -291,6 +291,7 @@ pub fn generate_android_project( let target_dir = output_dir.join("android"); let library_name = project_slug.replace('-', "_"); let project_pascal = to_pascal_case(project_slug); + let package_name = format!("dev.world.{}", project_slug); let vars = vec![ TemplateVar { name: "PROJECT_NAME", @@ -306,7 +307,7 @@ pub fn generate_android_project( }, TemplateVar { name: "PACKAGE_NAME", - value: format!("dev.world.{}", project_slug), + value: package_name.clone(), }, TemplateVar { name: "UNIFFI_NAMESPACE", @@ -322,6 +323,73 @@ pub fn generate_android_project( }, ]; render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?; + + // Move Kotlin files to the correct package directory structure + // The package "dev.world.{project_slug}" maps to directory "dev/world/{project_slug}/" + move_kotlin_files_to_package_dir(&target_dir, &package_name)?; + + Ok(()) +} + +/// Moves Kotlin source files to the correct package directory structure +/// +/// Android requires source files to be in directories matching their package declaration. +/// For example, a file with `package dev.world.my_project` must be in +/// `app/src/main/java/dev/world/my_project/`. +/// +/// This function moves: +/// - MainActivity.kt from `app/src/main/java/` to `app/src/main/java/{package_path}/` +/// - MainActivityTest.kt from `app/src/androidTest/java/` to `app/src/androidTest/java/{package_path}/` +fn move_kotlin_files_to_package_dir(android_dir: &Path, package_name: &str) -> Result<(), BenchError> { + // Convert package name to directory path (e.g., "dev.world.my_project" -> "dev/world/my_project") + let package_path = package_name.replace('.', "/"); + + // Move main source files + let main_java_dir = android_dir.join("app/src/main/java"); + let main_package_dir = main_java_dir.join(&package_path); + move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?; + + // Move test source files + let test_java_dir = android_dir.join("app/src/androidTest/java"); + let test_package_dir = test_java_dir.join(&package_path); + move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?; + + Ok(()) +} + +/// Moves a single Kotlin file from source directory to package directory +fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> { + let src_file = src_dir.join(filename); + if !src_file.exists() { + // File doesn't exist in source, nothing to move + return Ok(()); + } + + // Create the package directory if it doesn't exist + fs::create_dir_all(dest_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to create package directory {:?}: {}", + dest_dir, e + )) + })?; + + let dest_file = dest_dir.join(filename); + + // Move the file (copy + delete for cross-filesystem compatibility) + fs::copy(&src_file, &dest_file).map_err(|e| { + BenchError::Build(format!( + "Failed to copy {} to {:?}: {}", + filename, dest_file, e + )) + })?; + + fs::remove_file(&src_file).map_err(|e| { + BenchError::Build(format!( + "Failed to remove original file {:?}: {}", + src_file, e + )) + })?; + Ok(()) } @@ -354,7 +422,9 @@ pub fn generate_ios_project( .collect::>() .join(".") }; - let sanitized_project_slug = sanitize_bundle_id_component(project_slug); + // Use the actual app name (project_pascal, e.g., "BenchRunner") for the bundle ID suffix, + // not the crate name again. This prevents duplication like "dev.world.benchmobile.benchmobile" + // and produces the correct "dev.world.benchmobile.BenchRunner" let vars = vec![ TemplateVar { name: "DEFAULT_FUNCTION", @@ -370,7 +440,7 @@ pub fn generate_ios_project( }, TemplateVar { name: "BUNDLE_ID", - value: format!("{}.{}", sanitized_bundle_prefix, sanitized_project_slug), + value: format!("{}.{}", sanitized_bundle_prefix, project_pascal), }, TemplateVar { name: "LIBRARY_NAME", @@ -958,6 +1028,32 @@ mod tests { "strings.xml should contain app name with Benchmark" ); + // Verify Kotlin files are in the correct package directory structure + // For package "dev.world.my-bench-project", files should be in "dev/world/my-bench-project/" + let main_activity_path = android_dir.join("app/src/main/java/dev/world/my-bench-project/MainActivity.kt"); + assert!( + main_activity_path.exists(), + "MainActivity.kt should be in package directory: {:?}", + main_activity_path + ); + + let test_activity_path = android_dir.join("app/src/androidTest/java/dev/world/my-bench-project/MainActivityTest.kt"); + assert!( + test_activity_path.exists(), + "MainActivityTest.kt should be in package directory: {:?}", + test_activity_path + ); + + // Verify the files are NOT in the root java directory + assert!( + !android_dir.join("app/src/main/java/MainActivity.kt").exists(), + "MainActivity.kt should not be in root java directory" + ); + assert!( + !android_dir.join("app/src/androidTest/java/MainActivityTest.kt").exists(), + "MainActivityTest.kt should not be in root java directory" + ); + // Cleanup fs::remove_dir_all(&temp_dir).ok(); } @@ -1100,4 +1196,49 @@ pub fn public_bench() { // Complex case assert_eq!(sanitize_bundle_id_component("My-Complex_Project-123"), "mycomplexproject123"); } + + #[test] + fn test_generate_ios_project_bundle_id_not_duplicated() { + let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test"); + // Clean up any previous test run + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + // Use a crate name that would previously cause duplication + let crate_name = "bench-mobile"; + let bundle_prefix = "dev.world.benchmobile"; + let project_pascal = "BenchRunner"; + + let result = generate_ios_project( + &temp_dir, + crate_name, + project_pascal, + bundle_prefix, + "bench_mobile::test_func", + ); + assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err()); + + // Verify project.yml was created + let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml"); + assert!(project_yml_path.exists(), "project.yml should exist"); + + // Read and verify the bundle ID is correct (not duplicated) + let project_yml = fs::read_to_string(&project_yml_path).unwrap(); + + // The bundle ID should be "dev.world.benchmobile.BenchRunner" + // NOT "dev.world.benchmobile.benchmobile" + assert!( + project_yml.contains("dev.world.benchmobile.BenchRunner"), + "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}", + project_yml + ); + assert!( + !project_yml.contains("dev.world.benchmobile.benchmobile"), + "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}", + project_yml + ); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } } diff --git a/crates/mobench-sdk/templates/android/app/build.gradle b/crates/mobench-sdk/templates/android/app/build.gradle index 7180e74..8296a5f 100644 --- a/crates/mobench-sdk/templates/android/app/build.gradle +++ b/crates/mobench-sdk/templates/android/app/build.gradle @@ -18,6 +18,8 @@ android { versionCode 1 versionName "0.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) + testBuildType "release" } buildTypes { diff --git a/mobench-0.1.10-dx-report.md b/mobench-0.1.10-dx-report.md deleted file mode 100644 index 5e34e91..0000000 --- a/mobench-0.1.10-dx-report.md +++ /dev/null @@ -1,242 +0,0 @@ -# mobench 0.1.10 DX (Developer Experience) Report - -**Date:** 2026-01-19 -**Tested Version:** mobench-sdk 0.1.10, mobench CLI 0.1.10 -**Platform:** macOS Darwin 25.1.0 (arm64) -**Previous Version Tested:** 0.1.9 - -## Executive Summary - -mobench 0.1.10 represents a **major improvement** over 0.1.9, fixing all 12 critical template placeholder bugs identified in the previous version. Both Android and iOS builds now complete successfully without manual intervention. - -**Build Status:** -- Android: APK built successfully (133MB) -- iOS: xcframework and app built successfully with universal simulator support - -**Remaining Issues:** 2 critical, 4 high, 5 medium severity - ---- - -## Improvements from 0.1.9 to 0.1.10 - -### Fixed Issues (12 total from 0.1.9) - -| # | Issue | Status in 0.1.10 | -|---|-------|------------------| -| 1 | `{{PACKAGE_NAME}}` not replaced | ✅ FIXED - Uses `dev.world.bench_mobile` | -| 2 | `{{LIBRARY_NAME}}` not replaced | ✅ FIXED - Uses `libbench_mobile.so` | -| 3 | `{{PROJECT_NAME}}` not replaced | ✅ FIXED - Uses `bench_mobile-android` | -| 4 | `{{PROJECT_NAME_PASCAL}}` not replaced | ✅ FIXED - Theme uses `Theme.BenchMobile` | -| 5 | `{{APP_NAME}}` not replaced | ✅ FIXED - Uses "BenchMobile Benchmark" | -| 6 | Missing `gradle.properties` | ✅ FIXED - Now generated with AndroidX settings | -| 7 | Missing Gradle wrapper | ✅ FIXED - Now auto-generated | -| 8 | Invalid AGP version 8.13.2 | ✅ FIXED - Uses valid 8.2.2 | -| 9 | Package name mismatch | ✅ FIXED - Consistent naming | -| 10 | Missing x86_64 iOS simulator | ✅ FIXED - Universal binary created | -| 11 | Path handling bug | ✅ FIXED - Proper path resolution | -| 12 | Default benchmark function mismatch | ✅ FIXED - Uses actual benchmark name | - ---- - -## New/Remaining Issues in 0.1.10 - -### Critical Issues (2) - -#### Issue 1: APK Filename Mismatch (NEW) -**Severity:** CRITICAL -**Type:** Silent Failure - -The Android build creates `app-release-unsigned.apk` but mobench expects `app-release.apk`, causing a false build failure message. - -**Actual output:** -``` -target/mobench/android/app/build/outputs/apk/release/app-release-unsigned.apk (133MB) -``` - -**mobench error:** -``` -build error: APK not found at expected location: .../app-release.apk -``` - -**Impact:** Build succeeds but mobench reports failure. APK exists and is usable. - -**Fix Required:** Either add signing config to produce `app-release.apk`, or update mobench to check for `app-release-unsigned.apk` fallback. - ---- - -#### Issue 2: iOS Bundle Identifier Contains Invalid Characters -**Severity:** CRITICAL -**File:** `target/mobench/ios/BenchRunner/BenchRunner.xcodeproj/project.pbxproj` - -Bundle identifier `dev.world.bench-mobile.bench_mobile` contains both hyphens and underscores. - -**Impact:** -- App Store submission will be rejected -- Code signing issues on physical devices -- Xcode warning: "invalid character in Bundle Identifier" - -**Fix Required:** Use `dev.world.benchmobile.benchmobile` (no hyphens or underscores). - ---- - -### High Severity Issues (4) - -#### Issue 3: Missing ProGuard Configuration -**File:** `target/mobench/android/app/proguard-rules.pro` (missing) - -The `build.gradle` references `proguard-rules.pro` but the file doesn't exist. Builds fail if ProGuard is enabled. - -**Recommended content:** -```proguard --keep class com.sun.jna.** { *; } --keep class * implements com.sun.jna.** { *; } --keep class uniffi.bench_mobile.** { *; } --keep class dev.world.bench_mobile.** { *; } -``` - ---- - -#### Issue 4: Silent Config Loading Failures (iOS) -**File:** `target/mobench/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift` (lines 18-29) - -`BenchParams.fromBundle()` silently returns `nil` on JSON parse errors, falling back to defaults without logging. - ---- - -#### Issue 5: Silent Config Loading Failures (Android) -**File:** `target/mobench/android/app/src/main/java/MainActivity.kt` (lines 147-164) - -`optString`/`optInt` methods silently use defaults for missing/mistyped JSON keys. - ---- - -#### Issue 6: Machine-Specific Path in local.properties -**File:** `target/mobench/android/local.properties` - -Contains hardcoded developer-specific SDK path that won't work on other machines. - ---- - -### Medium Severity Issues (5) - -| # | Issue | Location | -|---|-------|----------| -| 7 | Bundle ID inconsistency between platforms | Android: `dev.world.bench_mobile`, iOS: `dev.world.bench-mobile` | -| 8 | Version string format mismatch | Android: "0.1", iOS: "0.1.0" | -| 9 | Deployment target gap | Android minSdk 24 (2016), iOS 15.0 (2021) | -| 10 | UniFFI dispose errors swallowed | `bench_mobile.kt` line 895-905 | -| 11 | Incomplete .gitignore files | Missing native artifact patterns | - ---- - -## Build Output Summary - -### Android -``` -Location: target/mobench/android/ -APK: app/build/outputs/apk/release/app-release-unsigned.apk -Size: 133,561,304 bytes (127 MB) -Architectures: arm64-v8a, armeabi-v7a, x86_64 -``` - -### iOS -``` -Location: target/mobench/ios/ -Framework: bench_mobile.xcframework/ -App: BenchRunner.app (built in DerivedData) -Architectures: ios-arm64, ios-arm64_x86_64-simulator -Size: 267 MB total -``` - ---- - -## Comparison: 0.1.9 vs 0.1.10 - -| Metric | 0.1.9 | 0.1.10 | -|--------|-------|--------| -| **Critical Bugs** | 12 | 2 | -| **Manual Fixes Required** | 5 | 0 | -| **Android Build** | Fails without fixes | Builds (reports false failure) | -| **iOS Build** | Builds (arm64 only) | Builds (universal) | -| **x86_64 Simulator** | ❌ Missing | ✅ Included | -| **Gradle Wrapper** | ❌ Missing | ✅ Generated | -| **gradle.properties** | ❌ Missing | ✅ Generated | -| **Template Placeholders** | 5 unreplaced | All replaced | -| **Default Benchmark** | Wrong name | Correct name | - ---- - -## Test Commands Used - -```bash -# Upgrade mobench CLI -cargo install mobench --version 0.1.10 --force - -# Update SDK dependency -# In bench-mobile/Cargo.toml: mobench-sdk = "0.1.10" -cargo update -p mobench-sdk - -# Clean and build -rm -rf target/mobench -mobench build --target android --release --verbose -mobench build --target ios --release --verbose - -# Verify Android APK (despite mobench error) -ls -la target/mobench/android/app/build/outputs/apk/release/ - -# Build iOS app with Xcode -cd target/mobench/ios/BenchRunner -xcodebuild -scheme BenchRunner -configuration Release \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build -``` - ---- - -## Recommended Priority Fixes for 0.1.11 - -### P0 (Critical - Fix Immediately) - -1. **APK filename detection** - Check for both `app-release.apk` and `app-release-unsigned.apk`, or parse `output-metadata.json` - -2. **Bundle identifier fix** - Use consistent naming without hyphens/underscores: `dev.world.benchmobile.benchmobile` - -### P1 (High - Fix Before Release) - -3. **Add proguard-rules.pro** to Android templates - -4. **Log config loading errors** instead of silently falling back to defaults - -5. **Don't generate local.properties** with hardcoded SDK paths - -### P2 (Medium - Nice to Have) - -6. Standardize bundle ID across platforms -7. Standardize version string format (semver) -8. Improve .gitignore completeness -9. Add logging for UniFFI cleanup errors - ---- - -## Agent Analysis Summary - -Three debugging agents were deployed in parallel: - -1. **Code Reviewer Agent** - Found APK naming, ProGuard, and local.properties issues -2. **Silent Failure Hunter Agent** - Found config loading fallbacks and bundle ID issues -3. **Explorer Agent** - Found cross-platform inconsistencies and missing files - -All agents converged on the same critical issues, confirming their validity. - ---- - -## Conclusion - -**mobench 0.1.10 is a significant improvement** over 0.1.9. All template placeholder bugs are fixed, and both platforms build successfully without manual intervention. - -The remaining issues are primarily: -1. APK filename detection (false failure report) -2. iOS bundle identifier format - -**Recommendation:** 0.1.10 is usable for development. The APK is built correctly despite the error message. Fix the bundle identifier before App Store submission. - -**Overall Score:** 8.5/10 (up from 4/10 for 0.1.9) diff --git a/mobench-0.1.11-dx-report.md b/mobench-0.1.11-dx-report.md new file mode 100644 index 0000000..1ea5728 --- /dev/null +++ b/mobench-0.1.11-dx-report.md @@ -0,0 +1,206 @@ +# mobench 0.1.11 DX (Developer Experience) Report + +**Date:** 2026-01-19 +**Tested Version:** mobench-sdk 0.1.11, mobench CLI 0.1.11 +**Platform:** macOS Darwin 25.1.0 (arm64) +**Previous Versions Tested:** 0.1.9, 0.1.10 + +## Executive Summary + +mobench 0.1.11 fixes 3 major issues from 0.1.10 but introduces 1 new critical bug. The APK build succeeds, but the new test APK build step fails because it uses a non-existent Gradle task. + +**Build Status:** +- Android APK: ✅ Built successfully (133MB) +- Android Test APK: ❌ Failed (`assembleReleaseAndroidTest` task not found) +- iOS: ✅ Built successfully + +--- + +## Version Comparison Summary + +| Issue | 0.1.9 | 0.1.10 | 0.1.11 | +|-------|-------|--------|--------| +| Template placeholders | ❌ 5 unfilled | ✅ Fixed | ✅ Fixed | +| Gradle wrapper | ❌ Missing | ✅ Generated | ✅ Generated | +| gradle.properties | ❌ Missing | ✅ Generated | ✅ Generated | +| AGP version | ❌ Invalid | ✅ Fixed | ✅ Fixed | +| x86_64 iOS simulator | ❌ Missing | ✅ Included | ✅ Included | +| APK filename detection | N/A | ❌ Wrong name | ✅ Parses metadata | +| iOS bundle ID chars | N/A | ❌ Invalid | ✅ Fixed | +| proguard-rules.pro | ❌ Missing | ❌ Missing | ✅ Generated | +| Test APK build | N/A | N/A | ❌ **NEW BUG** | + +--- + +## Improvements in 0.1.11 (Fixed from 0.1.10) + +### 1. APK Filename Detection - FIXED +**Previous:** Expected `app-release.apk`, build reported failure +**Now:** Parses `output-metadata.json` to find correct filename `app-release-unsigned.apk` + +### 2. iOS Bundle Identifier - FIXED +**Previous:** `dev.world.bench-mobile.bench_mobile` (invalid chars) +**Now:** `dev.world.benchmobile.benchmobile` (valid) + +### 3. ProGuard Rules File - FIXED +**Previous:** File missing, ProGuard would fail if enabled +**Now:** `proguard-rules.pro` generated with proper JNA/UniFFI keep rules + +### 4. iOS Config Logging - IMPROVED +**Previous:** Silent fallback to defaults +**Now:** Logs warnings when config file missing or invalid + +--- + +## New Bug in 0.1.11 + +### CRITICAL: `assembleReleaseAndroidTest` Task Not Found + +**Symptom:** +``` +Task 'assembleReleaseAndroidTest' not found in root project 'bench_mobile-android' +``` + +**Root Cause:** +mobench 0.1.11 now attempts to build a test APK after the main APK. It uses `assembleReleaseAndroidTest` for release builds, but this Gradle task doesn't exist unless explicitly configured. + +**Why:** Android Gradle Plugin only creates test APK tasks for the debug build type by default. The release test task requires `testBuildType "release"` in `build.gradle`. + +**Impact:** +- Main APK builds successfully (133MB) +- mobench reports overall failure due to test APK step +- BrowserStack Espresso tests cannot run against release builds + +**Fix Required in mobench-sdk:** +Either: +1. Always use `assembleDebugAndroidTest` (test APKs are debug anyway) +2. Add `testBuildType "release"` to generated `build.gradle` +3. Make test APK build optional + +**Workaround:** +Add to `app/build.gradle`: +```gradle +android { + defaultConfig { + testBuildType "release" + } +} +``` + +--- + +## Remaining Issues (Not Fixed) + +### HIGH: Hardcoded local.properties +**File:** `target/mobench/android/local.properties` +**Issue:** Contains machine-specific SDK path +```properties +sdk.dir=/Users/dcbuilder/Library/Android/sdk +``` +**Impact:** Breaks builds on other machines + +### MEDIUM: Bundle ID Duplication (iOS) +**Current:** `dev.world.benchmobile.benchmobile` +**Expected:** `dev.world.benchmobile.BenchRunner` +**Impact:** Cosmetic, doesn't break builds + +### MEDIUM: Test File Wrong Directory (Android) +**Current:** `app/src/androidTest/java/MainActivityTest.kt` +**Expected:** `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` +**Impact:** Test may not compile correctly + +### MEDIUM: Silent Error Fallbacks +Both Android and iOS catch exceptions broadly and lose error type information. + +--- + +## Build Outputs + +### Android +``` +Location: target/mobench/android/ +APK: app/build/outputs/apk/release/app-release-unsigned.apk +Size: 133,561,304 bytes (127 MB) +Status: ✅ Built successfully +``` + +### iOS +``` +Location: target/mobench/ios/ +Framework: bench_mobile.xcframework/ +Architectures: ios-arm64, ios-arm64_x86_64-simulator +Status: ✅ Built successfully +``` + +--- + +## Agent Analysis Summary + +Three debugging agents were deployed: + +1. **Android Reviewer** - Found test task configuration issue, confirmed proguard-rules.pro fix +2. **iOS Reviewer** - Confirmed bundle ID fix, found duplication issue +3. **Silent Failure Hunter** - Found error handling patterns, test directory structure issue + +All agents identified the `assembleReleaseAndroidTest` as the critical new bug. + +--- + +## Test Commands + +```bash +# Upgrade +cargo install mobench --version 0.1.11 --force + +# Update SDK +# In bench-mobile/Cargo.toml: mobench-sdk = "0.1.11" +cargo update -p mobench-sdk + +# Clean and build +rm -rf target/mobench +mobench build --target android --release --verbose # Fails at test APK +mobench build --target ios --release --verbose # Succeeds + +# Verify APK exists despite error +ls -la target/mobench/android/app/build/outputs/apk/release/ + +# Workaround: Add testBuildType to build.gradle and retry +echo 'android { defaultConfig { testBuildType "release" } }' >> target/mobench/android/app/build.gradle +cd target/mobench/android && ./gradlew assembleReleaseAndroidTest +``` + +--- + +## Priority Fixes for 0.1.12 + +### P0 (Blocker) +1. **Fix test APK task** - Use `assembleDebugAndroidTest` or add `testBuildType` config + +### P1 (High) +2. **Don't generate local.properties** with hardcoded paths +3. **Fix test file directory** - Place in correct package directory + +### P2 (Medium) +4. Fix iOS bundle ID duplication +5. Improve error handling to preserve error types + +--- + +## Overall Score + +| Version | Score | Builds Without Fixes | +|---------|-------|---------------------| +| 0.1.9 | 4/10 | ❌ No | +| 0.1.10 | 8/10 | ✅ Yes (false failure) | +| 0.1.11 | 7/10 | ⚠️ Partial (APK yes, test APK no) | + +**Note:** 0.1.11 would be 9/10 if the test APK task issue is fixed. The other improvements (APK detection, proguard, iOS bundle ID) are significant. + +--- + +## Conclusion + +mobench 0.1.11 makes good progress on DX issues but introduces a regression with the test APK build. The main APK builds successfully and is usable. For production use, either: +1. Ignore the test APK failure if not using BrowserStack Espresso +2. Apply the workaround to add `testBuildType "release"` +3. Wait for 0.1.12 with the fix diff --git a/templates/android/app/build.gradle b/templates/android/app/build.gradle index 7180e74..8296a5f 100644 --- a/templates/android/app/build.gradle +++ b/templates/android/app/build.gradle @@ -18,6 +18,8 @@ android { versionCode 1 versionName "0.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) + testBuildType "release" } buildTypes { From 1346ef1da1f0c69d01836ef5cc3bb02b3739bde6 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 22:05:35 +0100 Subject: [PATCH 035/196] Fix all DX issues from bug reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crate detection (P0): - Check current directory Cargo.toml before nested paths - Add read_package_name() helper for parsing Cargo.toml - Search order: current dir → bench-mobile/ → crates/{name}/ → {name}/ - Add 10 new tests for crate detection logic Error handling improvements: - Add Log.e() calls for Android exception handlers - Add print() statements for iOS config parsing failures - Log config sources (intent, bench_spec.json, default) - Show warnings when using fallback defaults Cross-platform consistency: - Standardize Android package names (remove underscores like iOS) - Both platforms now use dev.world.benchmobile format - Standardize version strings to 1.0.0 on both platforms - Add 3 new consistency tests Tests added: - test_find_crate_dir_current_directory_is_crate - test_find_crate_dir_nested_bench_mobile - test_find_crate_dir_crates_subdir - test_find_crate_dir_not_found - test_find_crate_dir_explicit_crate_path - test_read_package_name_standard - test_read_package_name_with_single_quotes - test_read_package_name_not_found - test_read_package_name_no_package_section - test_cross_platform_naming_consistency - test_cross_platform_version_consistency - test_bundle_id_prefix_consistency --- android/app/build.gradle | 2 +- crates/mobench-sdk/src/builders/android.rs | 185 ++++++++++++- crates/mobench-sdk/src/builders/common.rs | 132 ++++++++++ crates/mobench-sdk/src/builders/ios.rs | 189 +++++++++++++- crates/mobench-sdk/src/codegen.rs | 152 ++++++++++- .../templates/android/app/build.gradle | 2 +- .../src/main/java/MainActivity.kt.template | 45 +++- .../BenchRunner/BenchRunnerFFI.swift.template | 64 ++++- .../ios/BenchRunner/project.yml.template | 3 + ios/BenchRunner/BenchRunner/Info.plist | 2 +- mobench-0.1.11-dx-report.md | 206 --------------- mobench-bugs-summary.md | 208 +++++++++++++++ mobench-local-build-dx-report.md | 244 ++++++++++++++++++ 13 files changed, 1189 insertions(+), 245 deletions(-) delete mode 100644 mobench-0.1.11-dx-report.md create mode 100644 mobench-bugs-summary.md create mode 100644 mobench-local-build-dx-report.md diff --git a/android/app/build.gradle b/android/app/build.gradle index ff44149..669776d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -16,7 +16,7 @@ android { minSdk 24 targetSdk 34 versionCode 1 - versionName "0.1" + versionName "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) testBuildType "release" diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index 9fa2409..d5c4a69 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -132,9 +132,11 @@ impl AndroidBuilder { /// Sets the explicit crate directory /// - /// By default, the builder searches for the crate in: - /// - `{project_root}/bench-mobile/` - /// - `{project_root}/crates/{crate_name}/` + /// By default, the builder searches for the crate in this order: + /// 1. `{project_root}/Cargo.toml` - if it exists and has `[package] name` matching `crate_name` + /// 2. `{project_root}/bench-mobile/` - SDK-generated projects + /// 3. `{project_root}/crates/{crate_name}/` - workspace structure + /// 4. `{project_root}/{crate_name}/` - simple nested structure /// /// Use this to override auto-detection and point directly to the crate. pub fn crate_dir(mut self, dir: impl Into) -> Self { @@ -305,7 +307,13 @@ impl AndroidBuilder { Ok(()) } - /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) + /// Finds the benchmark crate directory. + /// + /// Search order: + /// 1. Explicit `crate_dir` if set via `.crate_dir()` builder method + /// 2. Current directory (`project_root`) if its Cargo.toml has a matching package name + /// 3. `{project_root}/bench-mobile/` (SDK projects) + /// 4. `{project_root}/crates/{crate_name}/` (repository structure) fn find_crate_dir(&self) -> Result { // If explicit crate_dir was provided, use it if let Some(ref dir) = self.crate_dir { @@ -319,7 +327,18 @@ impl AndroidBuilder { ))); } - // Try bench-mobile/ first (SDK projects) + // Check if the current directory (project_root) IS the crate + // This handles the case where user runs `cargo mobench build` from within the crate directory + let root_cargo_toml = self.project_root.join("Cargo.toml"); + if root_cargo_toml.exists() { + if let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) { + if pkg_name == self.crate_name { + return Ok(self.project_root.clone()); + } + } + } + + // Try bench-mobile/ (SDK projects) let bench_mobile_dir = self.project_root.join("bench-mobile"); if bench_mobile_dir.exists() { return Ok(bench_mobile_dir); @@ -331,16 +350,27 @@ impl AndroidBuilder { return Ok(crates_dir); } + // Also try {crate_name}/ in project root (common pattern) + let named_dir = self.project_root.join(&self.crate_name); + if named_dir.exists() { + return Ok(named_dir); + } + + let root_manifest = root_cargo_toml; let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml"); let crates_manifest = crates_dir.join("Cargo.toml"); + let named_manifest = named_dir.join("Cargo.toml"); Err(BenchError::Build(format!( "Benchmark crate '{}' not found.\n\n\ Searched locations:\n\ + - {} (checked [package] name)\n\ + - {}\n\ - {}\n\ - {}\n\n\ To fix this:\n\ - 1. Create a bench-mobile/ directory with your benchmark crate, or\n\ - 2. Use --crate-path to specify the benchmark crate location:\n\ + 1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\ + 2. Create a bench-mobile/ directory with your benchmark crate, or\n\ + 3. Use --crate-path to specify the benchmark crate location:\n\ cargo mobench build --target android --crate-path ./my-benchmarks\n\n\ Common issues:\n\ - Typo in crate name (check Cargo.toml [package] name)\n\ @@ -348,8 +378,11 @@ impl AndroidBuilder { - Missing Cargo.toml in the crate directory\n\n\ Run 'cargo mobench init --help' to generate a new benchmark project.", self.crate_name, + root_manifest.display(), bench_mobile_manifest.display(), - crates_manifest.display() + crates_manifest.display(), + named_manifest.display(), + self.crate_name, ))) } @@ -1174,4 +1207,140 @@ mod tests { let result = builder.parse_output_metadata(metadata); assert_eq!(result, None); } + + #[test] + fn test_find_crate_dir_current_directory_is_crate() { + // Test case 1: Current directory IS the crate with matching package name + let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-current"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Create Cargo.toml with matching package name + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[package] +name = "bench-mobile" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = AndroidBuilder::new(&temp_dir, "bench-mobile"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in current directory"); + assert_eq!(result.unwrap(), temp_dir); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_nested_bench_mobile() { + // Test case 2: Crate is in bench-mobile/ subdirectory + let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-nested"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap(); + + // Create parent Cargo.toml (workspace or different crate) + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[workspace] +members = ["bench-mobile"] +"#, + ) + .unwrap(); + + // Create bench-mobile/Cargo.toml + std::fs::write( + temp_dir.join("bench-mobile/Cargo.toml"), + r#"[package] +name = "bench-mobile" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = AndroidBuilder::new(&temp_dir, "bench-mobile"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in bench-mobile/ directory"); + assert_eq!(result.unwrap(), temp_dir.join("bench-mobile")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_crates_subdir() { + // Test case 3: Crate is in crates/{name}/ subdirectory + let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-crates"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap(); + + // Create workspace Cargo.toml + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[workspace] +members = ["crates/*"] +"#, + ) + .unwrap(); + + // Create crates/my-bench/Cargo.toml + std::fs::write( + temp_dir.join("crates/my-bench/Cargo.toml"), + r#"[package] +name = "my-bench" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = AndroidBuilder::new(&temp_dir, "my-bench"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in crates/ directory"); + assert_eq!(result.unwrap(), temp_dir.join("crates/my-bench")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_not_found() { + // Test case 4: Crate doesn't exist anywhere + let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-notfound"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Create Cargo.toml with DIFFERENT package name + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[package] +name = "some-other-crate" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = AndroidBuilder::new(&temp_dir, "nonexistent-crate"); + let result = builder.find_crate_dir(); + assert!(result.is_err(), "Should fail to find nonexistent crate"); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found")); + assert!(err_msg.contains("Searched locations")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_explicit_crate_path() { + // Test case 5: Explicit crate_dir overrides auto-detection + let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-explicit"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap(); + + let builder = AndroidBuilder::new(&temp_dir, "any-name") + .crate_dir(temp_dir.join("custom-location")); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should use explicit crate_dir"); + assert_eq!(result.unwrap(), temp_dir.join("custom-location")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } } diff --git a/crates/mobench-sdk/src/builders/common.rs b/crates/mobench-sdk/src/builders/common.rs index 5b19309..d64efad 100644 --- a/crates/mobench-sdk/src/builders/common.rs +++ b/crates/mobench-sdk/src/builders/common.rs @@ -246,6 +246,64 @@ pub fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError Ok(()) } +/// Reads the package name from a Cargo.toml file. +/// +/// This function parses the `[package]` section of a Cargo.toml and extracts +/// the `name` field. It uses simple string parsing to avoid adding toml +/// dependencies. +/// +/// # Arguments +/// * `cargo_toml_path` - Path to the Cargo.toml file +/// +/// # Returns +/// `Some(name)` if the package name is found, `None` otherwise. +/// +/// # Example +/// ```ignore +/// let name = read_package_name(Path::new("/path/to/Cargo.toml")); +/// if let Some(name) = name { +/// println!("Package name: {}", name); +/// } +/// ``` +pub fn read_package_name(cargo_toml_path: &Path) -> Option { + let content = std::fs::read_to_string(cargo_toml_path).ok()?; + + // Find [package] section + let package_start = content.find("[package]")?; + let package_section = &content[package_start..]; + + // Find the end of the package section (next section or end of file) + let section_end = package_section[1..] + .find("\n[") + .map(|i| i + 1) + .unwrap_or(package_section.len()); + let package_section = &package_section[..section_end]; + + // Find name = "..." or name = '...' + for line in package_section.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("name") { + // Parse: name = "value" or name = 'value' + if let Some(eq_pos) = trimmed.find('=') { + let value_part = trimmed[eq_pos + 1..].trim(); + // Extract string value (handle both " and ') + let (quote_char, start) = if value_part.starts_with('"') { + ('"', 1) + } else if value_part.starts_with('\'') { + ('\'', 1) + } else { + continue; + }; + if let Some(end) = value_part[start..].find(quote_char) { + return Some(value_part[start..start + end].to_string()); + } + } + } + } + + None +} + #[cfg(test)] mod tests { use super::*; @@ -277,4 +335,78 @@ mod tests { let msg = format!("{}", err); assert!(msg.contains("Failed to start")); } + + #[test] + fn test_read_package_name_standard() { + let temp_dir = std::env::temp_dir().join("mobench-test-read-package"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let cargo_toml = temp_dir.join("Cargo.toml"); + std::fs::write( + &cargo_toml, + r#"[package] +name = "my-awesome-crate" +version = "0.1.0" +edition = "2021" + +[dependencies] +"#, + ) + .unwrap(); + + let result = read_package_name(&cargo_toml); + assert_eq!(result, Some("my-awesome-crate".to_string())); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_read_package_name_with_single_quotes() { + let temp_dir = std::env::temp_dir().join("mobench-test-read-package-sq"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let cargo_toml = temp_dir.join("Cargo.toml"); + std::fs::write( + &cargo_toml, + r#"[package] +name = 'single-quoted-crate' +version = "0.1.0" +"#, + ) + .unwrap(); + + let result = read_package_name(&cargo_toml); + assert_eq!(result, Some("single-quoted-crate".to_string())); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_read_package_name_not_found() { + let result = read_package_name(Path::new("/nonexistent/Cargo.toml")); + assert_eq!(result, None); + } + + #[test] + fn test_read_package_name_no_package_section() { + let temp_dir = std::env::temp_dir().join("mobench-test-read-package-no-pkg"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let cargo_toml = temp_dir.join("Cargo.toml"); + std::fs::write( + &cargo_toml, + r#"[workspace] +members = ["crates/*"] +"#, + ) + .unwrap(); + + let result = read_package_name(&cargo_toml); + assert_eq!(result, None); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } } diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 874b380..00b5fd8 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -154,9 +154,11 @@ impl IosBuilder { /// Sets the explicit crate directory /// - /// By default, the builder searches for the crate in: - /// - `{project_root}/bench-mobile/` - /// - `{project_root}/crates/{crate_name}/` + /// By default, the builder searches for the crate in this order: + /// 1. `{project_root}/Cargo.toml` - if it exists and has `[package] name` matching `crate_name` + /// 2. `{project_root}/bench-mobile/` - SDK-generated projects + /// 3. `{project_root}/crates/{crate_name}/` - workspace structure + /// 4. `{project_root}/{crate_name}/` - simple nested structure /// /// Use this to override auto-detection and point directly to the crate. pub fn crate_dir(mut self, dir: impl Into) -> Self { @@ -372,7 +374,13 @@ impl IosBuilder { Ok(()) } - /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) + /// Finds the benchmark crate directory. + /// + /// Search order: + /// 1. Explicit `crate_dir` if set via `.crate_dir()` builder method + /// 2. Current directory (`project_root`) if its Cargo.toml has a matching package name + /// 3. `{project_root}/bench-mobile/` (SDK projects) + /// 4. `{project_root}/crates/{crate_name}/` (repository structure) fn find_crate_dir(&self) -> Result { // If explicit crate_dir was provided, use it if let Some(ref dir) = self.crate_dir { @@ -386,7 +394,18 @@ impl IosBuilder { ))); } - // Try bench-mobile/ first (SDK projects) + // Check if the current directory (project_root) IS the crate + // This handles the case where user runs `cargo mobench build` from within the crate directory + let root_cargo_toml = self.project_root.join("Cargo.toml"); + if root_cargo_toml.exists() { + if let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) { + if pkg_name == self.crate_name { + return Ok(self.project_root.clone()); + } + } + } + + // Try bench-mobile/ (SDK projects) let bench_mobile_dir = self.project_root.join("bench-mobile"); if bench_mobile_dir.exists() { return Ok(bench_mobile_dir); @@ -398,16 +417,27 @@ impl IosBuilder { return Ok(crates_dir); } + // Also try {crate_name}/ in project root (common pattern) + let named_dir = self.project_root.join(&self.crate_name); + if named_dir.exists() { + return Ok(named_dir); + } + + let root_manifest = root_cargo_toml; let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml"); let crates_manifest = crates_dir.join("Cargo.toml"); + let named_manifest = named_dir.join("Cargo.toml"); Err(BenchError::Build(format!( "Benchmark crate '{}' not found.\n\n\ Searched locations:\n\ + - {} (checked [package] name)\n\ + - {}\n\ - {}\n\ - {}\n\n\ To fix this:\n\ - 1. Create a bench-mobile/ directory with your benchmark crate, or\n\ - 2. Use --crate-path to specify the benchmark crate location:\n\ + 1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\ + 2. Create a bench-mobile/ directory with your benchmark crate, or\n\ + 3. Use --crate-path to specify the benchmark crate location:\n\ cargo mobench build --target ios --crate-path ./my-benchmarks\n\n\ Common issues:\n\ - Typo in crate name (check Cargo.toml [package] name)\n\ @@ -415,8 +445,11 @@ impl IosBuilder { - Missing Cargo.toml in the crate directory\n\n\ Run 'cargo mobench init --help' to generate a new benchmark project.", self.crate_name, + root_manifest.display(), bench_mobile_manifest.display(), - crates_manifest.display() + crates_manifest.display(), + named_manifest.display(), + self.crate_name, ))) } @@ -1762,4 +1795,144 @@ mod tests { IosBuilder::new("/tmp/test-project", "test-bench-mobile").output_dir("/custom/output"); assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); } + + #[test] + fn test_find_crate_dir_current_directory_is_crate() { + // Test case 1: Current directory IS the crate with matching package name + let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-current"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Create Cargo.toml with matching package name + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[package] +name = "bench-mobile" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = IosBuilder::new(&temp_dir, "bench-mobile"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in current directory"); + // Note: IosBuilder canonicalizes paths, so compare canonical forms + let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone()); + assert_eq!(result.unwrap(), expected); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_nested_bench_mobile() { + // Test case 2: Crate is in bench-mobile/ subdirectory + let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-nested"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap(); + + // Create parent Cargo.toml (workspace or different crate) + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[workspace] +members = ["bench-mobile"] +"#, + ) + .unwrap(); + + // Create bench-mobile/Cargo.toml + std::fs::write( + temp_dir.join("bench-mobile/Cargo.toml"), + r#"[package] +name = "bench-mobile" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = IosBuilder::new(&temp_dir, "bench-mobile"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in bench-mobile/ directory"); + let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone()).join("bench-mobile"); + assert_eq!(result.unwrap(), expected); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_crates_subdir() { + // Test case 3: Crate is in crates/{name}/ subdirectory + let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-crates"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap(); + + // Create workspace Cargo.toml + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[workspace] +members = ["crates/*"] +"#, + ) + .unwrap(); + + // Create crates/my-bench/Cargo.toml + std::fs::write( + temp_dir.join("crates/my-bench/Cargo.toml"), + r#"[package] +name = "my-bench" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = IosBuilder::new(&temp_dir, "my-bench"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in crates/ directory"); + let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone()).join("crates/my-bench"); + assert_eq!(result.unwrap(), expected); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_not_found() { + // Test case 4: Crate doesn't exist anywhere + let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-notfound"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Create Cargo.toml with DIFFERENT package name + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[package] +name = "some-other-crate" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = IosBuilder::new(&temp_dir, "nonexistent-crate"); + let result = builder.find_crate_dir(); + assert!(result.is_err(), "Should fail to find nonexistent crate"); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found")); + assert!(err_msg.contains("Searched locations")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_explicit_crate_path() { + // Test case 5: Explicit crate_dir overrides auto-detection + let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-explicit"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap(); + + let builder = IosBuilder::new(&temp_dir, "any-name") + .crate_dir(temp_dir.join("custom-location")); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should use explicit crate_dir"); + assert_eq!(result.unwrap(), temp_dir.join("custom-location")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } } diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 56e558d..1659049 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -291,7 +291,10 @@ pub fn generate_android_project( let target_dir = output_dir.join("android"); let library_name = project_slug.replace('-', "_"); let project_pascal = to_pascal_case(project_slug); - let package_name = format!("dev.world.{}", project_slug); + // Use sanitized bundle ID component (alphanumeric only) for consistency with iOS + // This ensures both platforms use the same naming convention: "benchmobile" not "bench-mobile" + let package_id_component = sanitize_bundle_id_component(project_slug); + let package_name = format!("dev.world.{}", package_id_component); let vars = vec![ TemplateVar { name: "PROJECT_NAME", @@ -1011,9 +1014,10 @@ mod tests { ); let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap(); + // Package name should be sanitized (no hyphens/underscores) for consistency with iOS assert!( - build_gradle.contains("dev.world.my-bench-project") || build_gradle.contains("dev.world.my_bench_project"), - "build.gradle should contain package name" + build_gradle.contains("dev.world.mybenchproject"), + "build.gradle should contain sanitized package name 'dev.world.mybenchproject'" ); let manifest = fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap(); @@ -1029,15 +1033,15 @@ mod tests { ); // Verify Kotlin files are in the correct package directory structure - // For package "dev.world.my-bench-project", files should be in "dev/world/my-bench-project/" - let main_activity_path = android_dir.join("app/src/main/java/dev/world/my-bench-project/MainActivity.kt"); + // For package "dev.world.mybenchproject", files should be in "dev/world/mybenchproject/" + let main_activity_path = android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt"); assert!( main_activity_path.exists(), "MainActivity.kt should be in package directory: {:?}", main_activity_path ); - let test_activity_path = android_dir.join("app/src/androidTest/java/dev/world/my-bench-project/MainActivityTest.kt"); + let test_activity_path = android_dir.join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt"); assert!( test_activity_path.exists(), "MainActivityTest.kt should be in package directory: {:?}", @@ -1241,4 +1245,140 @@ pub fn public_bench() { // Cleanup fs::remove_dir_all(&temp_dir).ok(); } + + #[test] + fn test_cross_platform_naming_consistency() { + // Test that Android and iOS use the same naming convention for package/bundle IDs + let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let project_name = "bench-mobile"; + + // Generate Android project + let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func"); + assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err()); + + // Generate iOS project (mimicking how ensure_ios_project does it) + let bundle_id_component = sanitize_bundle_id_component(project_name); + let bundle_prefix = format!("dev.world.{}", bundle_id_component); + let result = generate_ios_project( + &temp_dir, + &project_name.replace('-', "_"), + "BenchRunner", + &bundle_prefix, + "bench_mobile::test_func", + ); + assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err()); + + // Read Android build.gradle to extract package name + let android_build_gradle = fs::read_to_string( + temp_dir.join("android/app/build.gradle") + ).expect("Failed to read Android build.gradle"); + + // Read iOS project.yml to extract bundle ID prefix + let ios_project_yml = fs::read_to_string( + temp_dir.join("ios/BenchRunner/project.yml") + ).expect("Failed to read iOS project.yml"); + + // Both should use "benchmobile" (without hyphens or underscores) + // Android: namespace = "dev.world.benchmobile" + // iOS: bundleIdPrefix: dev.world.benchmobile + assert!( + android_build_gradle.contains("dev.world.benchmobile"), + "Android package should be 'dev.world.benchmobile', got:\n{}", + android_build_gradle + ); + assert!( + ios_project_yml.contains("dev.world.benchmobile"), + "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}", + ios_project_yml + ); + + // Ensure Android doesn't use hyphens or underscores in the package ID component + assert!( + !android_build_gradle.contains("dev.world.bench-mobile"), + "Android package should NOT contain hyphens" + ); + assert!( + !android_build_gradle.contains("dev.world.bench_mobile"), + "Android package should NOT contain underscores" + ); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_cross_platform_version_consistency() { + // Test that Android and iOS use the same version strings + let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let project_name = "test-project"; + + // Generate Android project + let result = generate_android_project(&temp_dir, project_name, "test_project::test_func"); + assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err()); + + // Generate iOS project + let bundle_id_component = sanitize_bundle_id_component(project_name); + let bundle_prefix = format!("dev.world.{}", bundle_id_component); + let result = generate_ios_project( + &temp_dir, + &project_name.replace('-', "_"), + "BenchRunner", + &bundle_prefix, + "test_project::test_func", + ); + assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err()); + + // Read Android build.gradle + let android_build_gradle = fs::read_to_string( + temp_dir.join("android/app/build.gradle") + ).expect("Failed to read Android build.gradle"); + + // Read iOS project.yml + let ios_project_yml = fs::read_to_string( + temp_dir.join("ios/BenchRunner/project.yml") + ).expect("Failed to read iOS project.yml"); + + // Both should use version "1.0.0" + assert!( + android_build_gradle.contains("versionName \"1.0.0\""), + "Android versionName should be '1.0.0', got:\n{}", + android_build_gradle + ); + assert!( + ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""), + "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}", + ios_project_yml + ); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_bundle_id_prefix_consistency() { + // Test that the bundle ID prefix format is consistent across platforms + let test_cases = vec![ + ("my-project", "dev.world.myproject"), + ("bench_mobile", "dev.world.benchmobile"), + ("TestApp", "dev.world.testapp"), + ("app-with-many-dashes", "dev.world.appwithmanydashes"), + ("app_with_many_underscores", "dev.world.appwithmanyunderscores"), + ]; + + for (input, expected_prefix) in test_cases { + let sanitized = sanitize_bundle_id_component(input); + let full_prefix = format!("dev.world.{}", sanitized); + assert_eq!( + full_prefix, expected_prefix, + "For input '{}', expected '{}' but got '{}'", + input, expected_prefix, full_prefix + ); + } + } } diff --git a/crates/mobench-sdk/templates/android/app/build.gradle b/crates/mobench-sdk/templates/android/app/build.gradle index 8296a5f..81b14c4 100644 --- a/crates/mobench-sdk/templates/android/app/build.gradle +++ b/crates/mobench-sdk/templates/android/app/build.gradle @@ -16,7 +16,7 @@ android { minSdk 24 targetSdk 34 versionCode 1 - versionName "0.1" + versionName "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) testBuildType "release" diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index c334df6..c83f8b8 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -55,8 +55,10 @@ class MainActivity : AppCompatActivity() { formatBenchReport(report) } catch (e: BenchException) { // Generic handler for all benchmark errors (InvalidIterations, UnknownFunction, etc.) + android.util.Log.e("BenchRunner", "Benchmark error: ${e.message}", e) "Benchmark error: ${e.message}" } catch (e: Exception) { + android.util.Log.e("BenchRunner", "Unexpected error during benchmark execution", e) "Unexpected error: ${e.message}" } @@ -129,18 +131,45 @@ class MainActivity : AppCompatActivity() { } private fun resolveBenchParams(): BenchParams { - val defaults = loadBenchParamsFromAssets() ?: BenchParams( + val assetParams = loadBenchParamsFromAssets() + val defaults = assetParams ?: BenchParams( DEFAULT_FUNCTION, DEFAULT_ITERATIONS, DEFAULT_WARMUP ) - val fn = intent?.getStringExtra(FUNCTION_EXTRA) - ?.takeUnless { it.isBlank() } - ?: defaults.function - val iterations = intent?.getIntExtra(ITERATIONS_EXTRA, defaults.iterations.toInt())?.toUInt() - ?: defaults.iterations - val warmup = intent?.getIntExtra(WARMUP_EXTRA, defaults.warmup.toInt())?.toUInt() - ?: defaults.warmup + + // Check for intent extras (used for test automation and BrowserStack) + val intentFunction = intent?.getStringExtra(FUNCTION_EXTRA)?.takeUnless { it.isBlank() } + val intentIterations = intent?.let { + val value = it.getIntExtra(ITERATIONS_EXTRA, -1) + if (value >= 0) value.toUInt() else null + } + val intentWarmup = intent?.let { + val value = it.getIntExtra(WARMUP_EXTRA, -1) + if (value >= 0) value.toUInt() else null + } + + // Resolve final values with logging + val fn = intentFunction ?: defaults.function + val iterations = intentIterations ?: defaults.iterations + val warmup = intentWarmup ?: defaults.warmup + + // Log the resolution source for debugging + if (assetParams == null && intentFunction == null && intentIterations == null && intentWarmup == null) { + android.util.Log.i("BenchRunner", "Using hardcoded defaults: function=$fn, iterations=$iterations, warmup=$warmup") + } else { + val sources = mutableListOf() + if (intentFunction != null) sources.add("function from intent") + if (intentIterations != null) sources.add("iterations from intent") + if (intentWarmup != null) sources.add("warmup from intent") + if (assetParams != null) { + if (intentFunction == null) sources.add("function from bench_spec.json") + if (intentIterations == null) sources.add("iterations from bench_spec.json") + if (intentWarmup == null) sources.add("warmup from bench_spec.json") + } + android.util.Log.i("BenchRunner", "Resolved params: function=$fn, iterations=$iterations, warmup=$warmup (sources: ${sources.joinToString(", ")})") + } + return BenchParams(fn, iterations, warmup) } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index 3ae7978..be78fae 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -34,20 +34,70 @@ struct BenchParams { static func fromProcessInfo() -> BenchParams { let info = ProcessInfo.processInfo - var function = info.environment["BENCH_FUNCTION"] ?? defaultFunction - var iterations = UInt32(info.environment["BENCH_ITERATIONS"] ?? "") ?? defaultIterations - var warmup = UInt32(info.environment["BENCH_WARMUP"] ?? "") ?? defaultWarmup + var function = defaultFunction + var iterations = defaultIterations + var warmup = defaultWarmup + var sources: [String] = [] + + // Check environment variables + if let envFunction = info.environment["BENCH_FUNCTION"], !envFunction.isEmpty { + function = envFunction + sources.append("function from BENCH_FUNCTION env") + } + if let envIterations = info.environment["BENCH_ITERATIONS"], !envIterations.isEmpty { + if let parsed = UInt32(envIterations) { + iterations = parsed + sources.append("iterations from BENCH_ITERATIONS env") + } else { + print("[BenchRunner] WARNING: Failed to parse BENCH_ITERATIONS='\(envIterations)' as integer, using default: \(defaultIterations)") + } + } + + if let envWarmup = info.environment["BENCH_WARMUP"], !envWarmup.isEmpty { + if let parsed = UInt32(envWarmup) { + warmup = parsed + sources.append("warmup from BENCH_WARMUP env") + } else { + print("[BenchRunner] WARNING: Failed to parse BENCH_WARMUP='\(envWarmup)' as integer, using default: \(defaultWarmup)") + } + } + + // Check launch arguments (override environment variables) for arg in info.arguments { if arg.hasPrefix("--bench-function=") { - function = String(arg.split(separator: "=", maxSplits: 1).last ?? Substring(function)) + if let value = arg.split(separator: "=", maxSplits: 1).last { + function = String(value) + sources.append("function from --bench-function arg") + } } else if arg.hasPrefix("--bench-iterations=") { - iterations = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? iterations + if let value = arg.split(separator: "=", maxSplits: 1).last { + if let parsed = UInt32(value) { + iterations = parsed + sources.append("iterations from --bench-iterations arg") + } else { + print("[BenchRunner] WARNING: Failed to parse --bench-iterations='\(value)' as integer, using current value: \(iterations)") + } + } } else if arg.hasPrefix("--bench-warmup=") { - warmup = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? warmup + if let value = arg.split(separator: "=", maxSplits: 1).last { + if let parsed = UInt32(value) { + warmup = parsed + sources.append("warmup from --bench-warmup arg") + } else { + print("[BenchRunner] WARNING: Failed to parse --bench-warmup='\(value)' as integer, using current value: \(warmup)") + } + } } } + // Log resolution source + if sources.isEmpty { + print("[BenchRunner] Using hardcoded defaults: function=\(function), iterations=\(iterations), warmup=\(warmup)") + } else { + print("[BenchRunner] Resolved params from process info: function=\(function), iterations=\(iterations), warmup=\(warmup) (sources: \(sources.joined(separator: ", ")))") + } + return BenchParams(function: function, iterations: iterations, warmup: warmup) } @@ -76,8 +126,10 @@ enum {{PROJECT_NAME_PASCAL}}FFI { let report = try runBenchmark(spec: spec) return formatBenchReport(report) } catch let error as BenchError { + print("[BenchRunner] ERROR: Benchmark failed: \(error)") return formatBenchError(error) } catch { + print("[BenchRunner] ERROR: Unexpected error during benchmark execution: \(error)") return "Unexpected error: \(error.localizedDescription)" } } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template index e3ed4f3..75c9ae3 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template @@ -18,6 +18,9 @@ targets: optional: true info: path: {{PROJECT_NAME_PASCAL}}/Info.plist + properties: + CFBundleShortVersionString: "1.0.0" + CFBundleVersion: "1" settings: base: PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID}} diff --git a/ios/BenchRunner/BenchRunner/Info.plist b/ios/BenchRunner/BenchRunner/Info.plist index 47ef031..657d601 100644 --- a/ios/BenchRunner/BenchRunner/Info.plist +++ b/ios/BenchRunner/BenchRunner/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + 1.0.0 CFBundleVersion 1 diff --git a/mobench-0.1.11-dx-report.md b/mobench-0.1.11-dx-report.md deleted file mode 100644 index 1ea5728..0000000 --- a/mobench-0.1.11-dx-report.md +++ /dev/null @@ -1,206 +0,0 @@ -# mobench 0.1.11 DX (Developer Experience) Report - -**Date:** 2026-01-19 -**Tested Version:** mobench-sdk 0.1.11, mobench CLI 0.1.11 -**Platform:** macOS Darwin 25.1.0 (arm64) -**Previous Versions Tested:** 0.1.9, 0.1.10 - -## Executive Summary - -mobench 0.1.11 fixes 3 major issues from 0.1.10 but introduces 1 new critical bug. The APK build succeeds, but the new test APK build step fails because it uses a non-existent Gradle task. - -**Build Status:** -- Android APK: ✅ Built successfully (133MB) -- Android Test APK: ❌ Failed (`assembleReleaseAndroidTest` task not found) -- iOS: ✅ Built successfully - ---- - -## Version Comparison Summary - -| Issue | 0.1.9 | 0.1.10 | 0.1.11 | -|-------|-------|--------|--------| -| Template placeholders | ❌ 5 unfilled | ✅ Fixed | ✅ Fixed | -| Gradle wrapper | ❌ Missing | ✅ Generated | ✅ Generated | -| gradle.properties | ❌ Missing | ✅ Generated | ✅ Generated | -| AGP version | ❌ Invalid | ✅ Fixed | ✅ Fixed | -| x86_64 iOS simulator | ❌ Missing | ✅ Included | ✅ Included | -| APK filename detection | N/A | ❌ Wrong name | ✅ Parses metadata | -| iOS bundle ID chars | N/A | ❌ Invalid | ✅ Fixed | -| proguard-rules.pro | ❌ Missing | ❌ Missing | ✅ Generated | -| Test APK build | N/A | N/A | ❌ **NEW BUG** | - ---- - -## Improvements in 0.1.11 (Fixed from 0.1.10) - -### 1. APK Filename Detection - FIXED -**Previous:** Expected `app-release.apk`, build reported failure -**Now:** Parses `output-metadata.json` to find correct filename `app-release-unsigned.apk` - -### 2. iOS Bundle Identifier - FIXED -**Previous:** `dev.world.bench-mobile.bench_mobile` (invalid chars) -**Now:** `dev.world.benchmobile.benchmobile` (valid) - -### 3. ProGuard Rules File - FIXED -**Previous:** File missing, ProGuard would fail if enabled -**Now:** `proguard-rules.pro` generated with proper JNA/UniFFI keep rules - -### 4. iOS Config Logging - IMPROVED -**Previous:** Silent fallback to defaults -**Now:** Logs warnings when config file missing or invalid - ---- - -## New Bug in 0.1.11 - -### CRITICAL: `assembleReleaseAndroidTest` Task Not Found - -**Symptom:** -``` -Task 'assembleReleaseAndroidTest' not found in root project 'bench_mobile-android' -``` - -**Root Cause:** -mobench 0.1.11 now attempts to build a test APK after the main APK. It uses `assembleReleaseAndroidTest` for release builds, but this Gradle task doesn't exist unless explicitly configured. - -**Why:** Android Gradle Plugin only creates test APK tasks for the debug build type by default. The release test task requires `testBuildType "release"` in `build.gradle`. - -**Impact:** -- Main APK builds successfully (133MB) -- mobench reports overall failure due to test APK step -- BrowserStack Espresso tests cannot run against release builds - -**Fix Required in mobench-sdk:** -Either: -1. Always use `assembleDebugAndroidTest` (test APKs are debug anyway) -2. Add `testBuildType "release"` to generated `build.gradle` -3. Make test APK build optional - -**Workaround:** -Add to `app/build.gradle`: -```gradle -android { - defaultConfig { - testBuildType "release" - } -} -``` - ---- - -## Remaining Issues (Not Fixed) - -### HIGH: Hardcoded local.properties -**File:** `target/mobench/android/local.properties` -**Issue:** Contains machine-specific SDK path -```properties -sdk.dir=/Users/dcbuilder/Library/Android/sdk -``` -**Impact:** Breaks builds on other machines - -### MEDIUM: Bundle ID Duplication (iOS) -**Current:** `dev.world.benchmobile.benchmobile` -**Expected:** `dev.world.benchmobile.BenchRunner` -**Impact:** Cosmetic, doesn't break builds - -### MEDIUM: Test File Wrong Directory (Android) -**Current:** `app/src/androidTest/java/MainActivityTest.kt` -**Expected:** `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` -**Impact:** Test may not compile correctly - -### MEDIUM: Silent Error Fallbacks -Both Android and iOS catch exceptions broadly and lose error type information. - ---- - -## Build Outputs - -### Android -``` -Location: target/mobench/android/ -APK: app/build/outputs/apk/release/app-release-unsigned.apk -Size: 133,561,304 bytes (127 MB) -Status: ✅ Built successfully -``` - -### iOS -``` -Location: target/mobench/ios/ -Framework: bench_mobile.xcframework/ -Architectures: ios-arm64, ios-arm64_x86_64-simulator -Status: ✅ Built successfully -``` - ---- - -## Agent Analysis Summary - -Three debugging agents were deployed: - -1. **Android Reviewer** - Found test task configuration issue, confirmed proguard-rules.pro fix -2. **iOS Reviewer** - Confirmed bundle ID fix, found duplication issue -3. **Silent Failure Hunter** - Found error handling patterns, test directory structure issue - -All agents identified the `assembleReleaseAndroidTest` as the critical new bug. - ---- - -## Test Commands - -```bash -# Upgrade -cargo install mobench --version 0.1.11 --force - -# Update SDK -# In bench-mobile/Cargo.toml: mobench-sdk = "0.1.11" -cargo update -p mobench-sdk - -# Clean and build -rm -rf target/mobench -mobench build --target android --release --verbose # Fails at test APK -mobench build --target ios --release --verbose # Succeeds - -# Verify APK exists despite error -ls -la target/mobench/android/app/build/outputs/apk/release/ - -# Workaround: Add testBuildType to build.gradle and retry -echo 'android { defaultConfig { testBuildType "release" } }' >> target/mobench/android/app/build.gradle -cd target/mobench/android && ./gradlew assembleReleaseAndroidTest -``` - ---- - -## Priority Fixes for 0.1.12 - -### P0 (Blocker) -1. **Fix test APK task** - Use `assembleDebugAndroidTest` or add `testBuildType` config - -### P1 (High) -2. **Don't generate local.properties** with hardcoded paths -3. **Fix test file directory** - Place in correct package directory - -### P2 (Medium) -4. Fix iOS bundle ID duplication -5. Improve error handling to preserve error types - ---- - -## Overall Score - -| Version | Score | Builds Without Fixes | -|---------|-------|---------------------| -| 0.1.9 | 4/10 | ❌ No | -| 0.1.10 | 8/10 | ✅ Yes (false failure) | -| 0.1.11 | 7/10 | ⚠️ Partial (APK yes, test APK no) | - -**Note:** 0.1.11 would be 9/10 if the test APK task issue is fixed. The other improvements (APK detection, proguard, iOS bundle ID) are significant. - ---- - -## Conclusion - -mobench 0.1.11 makes good progress on DX issues but introduces a regression with the test APK build. The main APK builds successfully and is usable. For production use, either: -1. Ignore the test APK failure if not using BrowserStack Espresso -2. Apply the workaround to add `testBuildType "release"` -3. Wait for 0.1.12 with the fix diff --git a/mobench-bugs-summary.md b/mobench-bugs-summary.md new file mode 100644 index 0000000..e3ffc7f --- /dev/null +++ b/mobench-bugs-summary.md @@ -0,0 +1,208 @@ +# mobench Bug Summary for Development + +**Last Updated:** 2026-01-19 +**Tested Versions:** 0.1.9, 0.1.10, 0.1.11, Local Build +**Test Crate:** bench-mobile (World ID ZK Proof Benchmarks) + +--- + +## Quick Status + +| Version | Critical Bugs | Builds Without Fixes | Recommendation | +|---------|---------------|---------------------|----------------| +| 0.1.9 | 12 | ❌ No | Do not use | +| 0.1.10 | 2 | ✅ Yes (false failure) | Usable | +| 0.1.11 | 1 | ⚠️ Partial | Usable with workaround | +| Local | 2 | ⚠️ Requires --crate-path | Needs crate detection fix | + +--- + +## Version Evolution + +### 0.1.9 → 0.1.10 (12 bugs fixed) +All template placeholder bugs fixed, Gradle wrapper and properties added. + +### 0.1.10 → 0.1.11 (3 bugs fixed, 1 new bug) + +**Fixed:** +- ✅ APK filename detection (now parses output-metadata.json) +- ✅ iOS bundle identifier (removed invalid hyphens/underscores) +- ✅ proguard-rules.pro now generated + +**New Bug:** +- ❌ `assembleReleaseAndroidTest` task not found + +### 0.1.11 → Local Build (0 bugs fixed, 1 new bug) + +**New Bug:** +- ❌ Crate detection fails - requires `--crate-path .` flag + +**Still Present:** +- ❌ `assembleReleaseAndroidTest` task not found +- ❌ Hardcoded local.properties +- ❌ Source files not in package directory +- ❌ iOS bundle ID duplication + +--- + +## Current Bugs in Local Build + +### CRITICAL + +#### Bug 0: Crate Detection Fails (NEW in Local Build) +**Location:** mobench-sdk crate detection logic +**Symptom:** +``` +build error: Benchmark crate 'bench-mobile' not found. + +Searched locations: +- /path/bench-mobile/bench-mobile/Cargo.toml +- /path/bench-mobile/crates/bench-mobile/Cargo.toml +``` + +**Cause:** mobench looks for nested directories instead of checking if the current directory is a valid crate. + +**Impact:** +- Build fails to start without workaround +- Must use `--crate-path .` flag + +**Workaround:** +```bash +mobench build --target android --release --crate-path . +``` + +--- + +#### Bug 1: Test APK Task Not Found (from 0.1.11) +**Location:** mobench-sdk android.rs +**Symptom:** +``` +Task 'assembleReleaseAndroidTest' not found in root project +``` + +**Cause:** Android Gradle only creates test tasks for debug by default. Release test task requires `testBuildType "release"`. + +**Impact:** +- Main APK builds ✅ +- Test APK fails ❌ +- mobench reports overall failure + +**Workaround:** +```gradle +// Add to app/build.gradle +android { + defaultConfig { + testBuildType "release" + } +} +``` + +**Or:** Use debug build type for tests (recommended) + +--- + +### HIGH + +#### Bug 2: Hardcoded local.properties (Still present) +**File:** `target/mobench/android/local.properties` +```properties +sdk.dir=/Users/dcbuilder/Library/Android/sdk +``` + +**Impact:** Breaks builds on other machines + +**Fix:** Don't generate this file + +--- + +#### Bug 3: Test File Wrong Directory +**Current:** `app/src/androidTest/java/MainActivityTest.kt` +**Expected:** `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` + +**Impact:** Test compilation may fail + +--- + +### MEDIUM + +#### Bug 4: iOS Bundle ID Duplication +**Current:** `dev.world.benchmobile.benchmobile` +**Expected:** `dev.world.benchmobile.BenchRunner` + +**Impact:** Cosmetic, works but non-standard + +--- + +#### Bug 5: Silent Error Fallbacks +Both platforms catch exceptions broadly and lose error context. + +--- + +## Fixed Bugs (Historical) + +### Fixed in 0.1.11 +| Bug | Description | Status | +|-----|-------------|--------| +| APK filename | Expected wrong name | ✅ Fixed | +| iOS bundle ID | Invalid chars | ✅ Fixed | +| proguard-rules.pro | Missing file | ✅ Fixed | + +### Fixed in 0.1.10 +| Bug | Description | Status | +|-----|-------------|--------| +| `{{PACKAGE_NAME}}` | Not replaced | ✅ Fixed | +| `{{LIBRARY_NAME}}` | Not replaced | ✅ Fixed | +| `{{PROJECT_NAME}}` | Not replaced | ✅ Fixed | +| `{{APP_NAME}}` | Not replaced | ✅ Fixed | +| gradle.properties | Missing | ✅ Fixed | +| Gradle wrapper | Missing | ✅ Fixed | +| AGP version | Invalid 8.13.2 | ✅ Fixed | +| x86_64 iOS sim | Missing | ✅ Fixed | + +--- + +## Verification Commands + +```bash +# Build Android (APK succeeds, test APK fails) +mobench build --target android --release --verbose +ls -la target/mobench/android/app/build/outputs/apk/release/ + +# Build iOS (succeeds fully) +mobench build --target ios --release --verbose +ls -la target/mobench/ios/bench_mobile.xcframework/ + +# Workaround for test APK +cd target/mobench/android +# Add testBuildType to build.gradle, then: +./gradlew assembleReleaseAndroidTest +``` + +--- + +## Recommended Priority Fixes for Next Release + +### P0 (Blocker) +1. **Fix crate detection** - Check current directory Cargo.toml, not just nested paths +2. **Fix test APK task** - Use debug or add testBuildType config + +### P1 (Should Fix) +3. Don't generate local.properties with hardcoded paths +4. Fix source file directory structure (place in package path) +5. Log UniFFI cleanup errors instead of swallowing + +### P2 (Nice to Have) +6. Fix iOS bundle ID duplication +7. Standardize package naming across platforms +8. Improve error handling to preserve types + +--- + +## Files Changed + +- `bench-mobile/Cargo.toml` - Using local mobench-sdk path +- `docs/mobench-0.1.9-dx-report.md` - Full 0.1.9 report +- `docs/mobench-0.1.10-dx-report.md` - Full 0.1.10 report +- `docs/mobench-0.1.11-dx-report.md` - Full 0.1.11 report +- `docs/mobench-local-build-dx-report.md` - Full local build report +- `docs/mobench-bugs-summary.md` - This file diff --git a/mobench-local-build-dx-report.md b/mobench-local-build-dx-report.md new file mode 100644 index 0000000..d46d95f --- /dev/null +++ b/mobench-local-build-dx-report.md @@ -0,0 +1,244 @@ +# mobench Local Build DX Report + +**Date:** 2026-01-19 +**Tested Version:** Local build from `../mobile-bench-rs` (based on 0.1.11) +**Platform:** macOS Darwin 25.1.0 (arm64) + +## Executive Summary + +Testing the local mobench build revealed **1 new bug** (crate detection) while confirming that previous bugs from 0.1.11 remain unfixed. Both platforms build successfully, but the test APK step still fails. + +**Build Status:** +- Android APK: ✅ Built successfully (133MB) +- Android Test APK: ❌ Failed (`assembleReleaseAndroidTest` task not found) +- iOS: ✅ Built successfully + +--- + +## New Bug Found + +### BUG: Crate Detection Fails Without `--crate-path` + +**Severity:** CRITICAL (build won't start) + +**Symptom:** +``` +build error: Benchmark crate 'bench-mobile' not found. + +Searched locations: +- /Users/.../bench-mobile/bench-mobile/Cargo.toml +- /Users/.../bench-mobile/crates/bench-mobile/Cargo.toml +``` + +**Root Cause:** mobench looks for nested directories (`bench-mobile/bench-mobile/` or `bench-mobile/crates/bench-mobile/`) instead of checking if the current directory contains a valid crate with a matching name. + +**Workaround:** Use `--crate-path .` flag: +```bash +mobench build --target android --release --crate-path . +``` + +**Fix Required:** mobench should check if the current directory's `Cargo.toml` has a matching `[package] name`. + +--- + +## Confirmed Bugs (Still Present from 0.1.11) + +### 1. Test APK Task Not Found + +**Status:** STILL PRESENT +**Impact:** Android build reports failure even though main APK builds successfully + +``` +Task 'assembleReleaseAndroidTest' not found in root project 'bench_mobile-android' +``` + +**Workaround:** Add to `app/build.gradle`: +```gradle +android { + defaultConfig { + testBuildType "release" + } +} +``` + +--- + +### 2. Hardcoded local.properties + +**Status:** STILL PRESENT +**File:** `target/mobench/android/local.properties` +```properties +sdk.dir=/Users/dcbuilder/Library/Android/sdk +``` + +**Impact:** Breaks builds on other machines. + +--- + +### 3. Source Files Not in Package Directory + +**Status:** STILL PRESENT + +**Current:** +- `app/src/main/java/MainActivity.kt` +- `app/src/androidTest/java/MainActivityTest.kt` + +**Expected:** +- `app/src/main/java/dev/world/bench_mobile/MainActivity.kt` +- `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` + +**Impact:** Kotlin files with `package dev.world.bench_mobile` must be in matching directory structure. + +--- + +### 4. iOS Bundle ID Duplication + +**Status:** STILL PRESENT +**File:** `target/mobench/ios/BenchRunner/project.yml` + +**Current:** `dev.world.benchmobile.benchmobile` (duplicated) +**Expected:** `dev.world.benchmobile` + +--- + +### 5. Cross-Platform Naming Inconsistency + +**Android:** `dev.world.bench_mobile` (snake_case) +**iOS:** `dev.world.benchmobile` (camelCase) + +--- + +### 6. Version String Mismatch + +**Android:** `0.1` +**iOS:** `1.0` + +--- + +## Silent Failure Issues + +### CRITICAL: UniFFI Cleanup Errors Swallowed + +**File:** `app/src/main/java/uniffi/bench_mobile/bench_mobile.kt:895-905` +```kotlin +} catch (e: Throwable) { + // swallow +} +``` + +All exceptions during `destroy()` are silently discarded, hiding memory leaks and native crashes. + +--- + +### CRITICAL: Broad Exception Catch Without Logging + +**File:** `app/src/main/java/MainActivity.kt:59-61` +```kotlin +} catch (e: Exception) { + "Unexpected error: ${e.message}" +} +``` + +Catches all exceptions but doesn't log them, making production debugging impossible. + +--- + +### HIGH: Silent Config Fallbacks + +Both platforms silently fall back to defaults when config parsing fails: +- Android: `MainActivity.kt:191-197` - logs but user isn't notified +- iOS: `BenchRunnerFFI.swift:35-52` - no logging at all for invalid numeric values + +--- + +## What's Working + +✅ APK filename detection (parses `output-metadata.json`) +✅ `proguard-rules.pro` generated with correct JNA/UniFFI rules +✅ iOS bundle identifier format (no invalid characters) +✅ Gradle wrapper generated +✅ `gradle.properties` generated +✅ All template placeholders replaced +✅ x86_64 iOS simulator support (universal binary) +✅ iOS xcframework code-signed + +--- + +## Build Outputs + +### Android +``` +Location: target/mobench/android/ +APK: app/build/outputs/apk/release/app-release-unsigned.apk +Size: 133,561,304 bytes (127 MB) +Status: ✅ Built successfully +``` + +### iOS +``` +Location: target/mobench/ios/ +Framework: bench_mobile.xcframework/ +Architectures: ios-arm64, ios-arm64_x86_64-simulator +Status: ✅ Built successfully +``` + +--- + +## Priority Fixes for Next Release + +### P0 (Blocker) +1. **Fix crate detection** - Check current directory Cargo.toml, not just nested paths +2. **Fix test APK task** - Use `assembleDebugAndroidTest` or add `testBuildType` config + +### P1 (Should Fix) +3. Don't generate `local.properties` with hardcoded paths +4. Fix source file directory structure (place in package path) +5. Log UniFFI cleanup errors instead of swallowing + +### P2 (Nice to Have) +6. Fix iOS bundle ID duplication +7. Standardize package naming across platforms +8. Align version strings +9. Add user-visible config fallback warnings + +--- + +## Test Commands + +```bash +# Build with local mobench (requires --crate-path flag) +cd bench-mobile +mobench build --target android --release --verbose --crate-path . +mobench build --target ios --release --verbose --crate-path . + +# Verify APK exists despite error +ls -la target/mobench/android/app/build/outputs/apk/release/ + +# Verify iOS xcframework +ls -la target/mobench/ios/bench_mobile.xcframework/ +``` + +--- + +## Agent Analysis + +Three debugging agents were deployed in parallel: + +1. **Code Reviewer** - Confirmed test task issue, found source file directory structure bug +2. **Silent Failure Hunter** - Found 9 error handling issues (2 critical, 4 high, 3 medium) +3. **Explorer** - Found bundle ID duplication, naming inconsistencies, version mismatch + +--- + +## Comparison: 0.1.11 vs Local Build + +| Issue | 0.1.11 | Local Build | +|-------|--------|-------------| +| Crate detection | ✅ | ❌ NEW BUG | +| Test APK task | ❌ | ❌ | +| local.properties | ❌ | ❌ | +| Source file paths | ❌ | ❌ | +| iOS bundle ID dupe | ❌ | ❌ | +| proguard-rules.pro | ✅ | ✅ | +| APK detection | ✅ | ✅ | +| iOS bundle ID chars | ✅ | ✅ | From b921f70800be99040a5120c28d903afffa0f3c37 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 17:09:11 +0100 Subject: [PATCH 036/196] Remove IMPROVEMENTS.md after completing all tasks --- IMPROVEMENTS.md | 656 ------------------------------------------------ 1 file changed, 656 deletions(-) delete mode 100644 IMPROVEMENTS.md diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md deleted file mode 100644 index bcae93b..0000000 --- a/IMPROVEMENTS.md +++ /dev/null @@ -1,656 +0,0 @@ -# Mobench Developer Experience Improvements - -This document captures improvements needed for mobench based on real-world integration testing with the world-id-protocol project (ZK proof benchmarking on mobile devices). - -## Summary - -| Priority | Task | Description | Status | -|----------|------|-------------|--------| -| P0 | [#1](#task-1-fix-aws-lc-rs-android-ndk-incompatibility) | Fix aws-lc-rs Android NDK incompatibility | DONE | -| P0 | [#2](#task-2-fix-workspace-target-directory-detection) | Fix workspace target directory detection | DONE | -| P0 | [#3](#task-3-auto-generate-project-scaffolding-during-build) | Auto-generate project scaffolding during build | DONE | -| P1 | [#4](#task-4-process-template-variables-during-build) | Process template variables during build | DONE | -| P1 | [#5](#task-5-improve-uniffi-bindgen-handling) | Improve uniffi-bindgen handling | DONE | -| P1 | [#6](#task-6-generate-error-handling-code-dynamically) | Generate error handling code dynamically | DONE | -| P2 | [#7](#task-7-add-configuration-file-support-mobenchtoml) | Add configuration file support (mobench.toml) | DONE | -| P2 | [#8](#task-8-improve-error-messages) | Improve error messages | DONE | -| P2 | [#9](#task-9-add---crate-path-flag) | Add --crate-path flag | DONE | -| P3 | [#10](#task-10-add---dry-run-and---verbose-modes) | Add --dry-run and --verbose modes | DONE | -| P3 | [#11](#task-11-auto-generate-localproperties-for-android) | Auto-generate local.properties for Android | DONE | - ---- - -## P0 - Critical (Blocking Issues) - -### Task 1: Fix aws-lc-rs Android NDK Incompatibility - -**Problem** - -The default `rustls` 0.23+ uses `aws-lc-rs` as the crypto backend, which fails to compile for Android NDK targets. Users see cryptic C compilation errors: - -``` -error occurred in cc-rs: command did not execute successfully -.../clang ... --target=aarch64-linux-android24 ... getentropy.c -``` - -**Root Cause** - -`aws-lc-sys` contains C code that doesn't compile correctly with the Android NDK toolchain. - -**Solution** - -Update the generated `Cargo.toml` templates to configure rustls with the `ring` crypto backend: - -```toml -[workspace.dependencies] -rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/codegen.rs` - Update Cargo.toml template generation -- `crates/mobench-sdk/templates/` - Update any hardcoded rustls dependencies -- `CLAUDE.md` / `BUILD.md` - Document the issue and workaround - -**Acceptance Criteria** - -- [ ] New projects generated with `init-sdk` compile for Android without rustls errors -- [ ] Documentation explains the aws-lc-rs issue and how to fix existing projects - ---- - -### Task 2: Fix Workspace Target Directory Detection - -**Problem** - -mobench looks for the host library at `/target/debug/lib*.dylib` but Cargo workspaces use a shared `/target/` directory. - -``` -build error: host library for UniFFI not found at -"/path/to/bench-mobile/target/debug/libbench_mobile.dylib" -``` - -**Root Cause** - -Hardcoded path assumption: `self.project_root.join("target")` instead of querying Cargo for the actual target directory. - -**Solution** - -Use `cargo metadata` to detect the actual target directory: - -```rust -fn get_target_dir(crate_dir: &Path) -> Result { - let output = Command::new("cargo") - .args(["metadata", "--format-version", "1", "--no-deps"]) - .current_dir(crate_dir) - .output()?; - let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)?; - Ok(PathBuf::from(metadata["target_directory"].as_str().unwrap())) -} -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/builders/android.rs` - - `generate_uniffi_bindings()` - Use correct target dir for host library - - `copy_native_libraries()` - Use correct target dir for .so files -- `crates/mobench-sdk/src/builders/ios.rs` - Similar changes -- Consider adding `cargo_metadata` crate or manual JSON parsing - -**Acceptance Criteria** - -- [ ] `mobench build` works in Cargo workspace projects -- [ ] `mobench build` still works in standalone crate projects - ---- - -### Task 3: Auto-generate Project Scaffolding During Build - -**Problem** - -`mobench build` assumes Android/iOS project files already exist. When missing, it fails with confusing errors: - -``` -build error: Failed to run Gradle: No such file or directory (os error 2) -``` - -Users don't know they need to run `init-sdk` first. - -**Solution** - -In `cmd_build()`, check if project exists and auto-generate if missing: - -```rust -fn cmd_build(target: SdkTarget, ...) -> Result<()> { - let output_dir = output_dir.unwrap_or_else(|| PathBuf::from("target/mobench")); - - if matches!(target, SdkTarget::Android | SdkTarget::Both) { - let android_dir = output_dir.join("android"); - if !android_dir.join("build.gradle").exists() { - println!("Android project not found, generating scaffolding..."); - generate_android_project(&android_dir, &crate_name, &template_context)?; - } - } - - if matches!(target, SdkTarget::Ios | SdkTarget::Both) { - let ios_dir = output_dir.join("ios").join("BenchRunner"); - if !ios_dir.join("project.yml").exists() { - println!("iOS project not found, generating scaffolding..."); - generate_ios_project(&ios_dir, &crate_name, &template_context)?; - } - } - - // Continue with normal build... -} -``` - -**Files to Modify** - -- `crates/mobench/src/lib.rs` - Update `cmd_build()` function -- `crates/mobench-sdk/src/codegen.rs` - Extract `generate_android_project()` and `generate_ios_project()` to be callable separately from full `generate_project()` - -**Acceptance Criteria** - -- [ ] Running `mobench build --target android` in a fresh project generates Android scaffolding automatically -- [ ] Running `mobench build --target ios` generates iOS scaffolding automatically -- [ ] Existing projects are not overwritten - ---- - -## P1 - High Priority - -### Task 4: Process Template Variables During Build - -**Problem** - -Template files contain `{{VARIABLE}}` placeholders that are only processed during `init-sdk`. Users who run `build` on a project without `init-sdk` get broken files with literal `{{VAR}}` strings. - -**Variables Used** - -| Variable | Example Value | Description | -|----------|---------------|-------------| -| `{{PACKAGE_NAME}}` | `dev.world.bench` | Android package / iOS bundle prefix | -| `{{LIBRARY_NAME}}` | `bench_mobile` | Rust library name | -| `{{UNIFFI_NAMESPACE}}` | `bench_mobile` | UniFFI namespace (usually same as library) | -| `{{PROJECT_NAME_PASCAL}}` | `BenchRunner` | PascalCase project name | -| `{{DEFAULT_FUNCTION}}` | `my_crate::my_func` | Default benchmark function | -| `{{APP_NAME}}` | `My Bench App` | Display name | -| `{{BUNDLE_ID}}` | `dev.world.bench` | iOS bundle identifier | -| `{{BUNDLE_ID_PREFIX}}` | `dev.world` | iOS bundle prefix | - -**Solution** - -1. Create a `TemplateContext` struct with all variables -2. Extract template processing into a shared function -3. Call it from both `init-sdk` and `build` -4. Derive values from crate metadata when not explicitly configured - -```rust -pub struct TemplateContext { - pub package_name: String, - pub library_name: String, - pub uniffi_namespace: String, - pub project_name_pascal: String, - pub default_function: String, - pub app_name: String, - pub bundle_id: String, - pub bundle_id_prefix: String, -} - -impl TemplateContext { - pub fn from_crate(crate_dir: &Path) -> Result { - // Read Cargo.toml, extract [lib] name, derive other values - } -} - -pub fn process_templates(dir: &Path, ctx: &TemplateContext) -> Result<()> { - for entry in WalkDir::new(dir) { - let path = entry?.path(); - if path.is_file() { - let content = fs::read_to_string(path)?; - let processed = content - .replace("{{PACKAGE_NAME}}", &ctx.package_name) - .replace("{{LIBRARY_NAME}}", &ctx.library_name) - // ... etc - fs::write(path, processed)?; - } - } - Ok(()) -} -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/codegen.rs` - Add `TemplateContext` and `process_templates()` -- `crates/mobench-sdk/src/builders/android.rs` - Call `process_templates()` after generating scaffolding -- `crates/mobench-sdk/src/builders/ios.rs` - Same - -**Acceptance Criteria** - -- [ ] All `{{VAR}}` placeholders are replaced during `build` -- [ ] Values are derived from crate metadata when not configured -- [ ] Custom values can be provided via config file (see Task 7) - ---- - -### Task 5: Improve uniffi-bindgen Handling - -**Problem** - -Users must manually add a `[[bin]]` target for uniffi-bindgen and install it globally. This is undocumented and confusing. - -**Current Workaround (manual)** - -```toml -# In bench-mobile/Cargo.toml -[[bin]] -name = "uniffi-bindgen" -path = "src/bin/uniffi-bindgen.rs" - -[dependencies] -uniffi = { version = "0.28", features = ["cli"] } -``` - -```rust -// src/bin/uniffi-bindgen.rs -fn main() { uniffi::uniffi_bindgen_main() } -``` - -**Solution** - -1. Generate the uniffi-bindgen binary target during project scaffolding -2. Use `cargo run -p --bin uniffi-bindgen` instead of global binary - -```rust -fn run_uniffi_bindgen(crate_dir: &Path, crate_name: &str, args: &[&str]) -> Result<()> { - // Try running via cargo first (works if crate has the binary) - let cargo_result = Command::new("cargo") - .args(["run", "-p", crate_name, "--bin", "uniffi-bindgen", "--"]) - .args(args) - .current_dir(crate_dir) - .status(); - - if cargo_result.is_ok() && cargo_result.unwrap().success() { - return Ok(()); - } - - // Fall back to global uniffi-bindgen - let global_result = Command::new("uniffi-bindgen") - .args(args) - .current_dir(crate_dir) - .status()?; - - if !global_result.success() { - return Err(BenchError::Build( - "uniffi-bindgen failed. Ensure your crate has a uniffi-bindgen binary \ - or install globally with: cargo install uniffi_bindgen".into() - )); - } - - Ok(()) -} -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/codegen.rs` - Generate uniffi-bindgen binary in Cargo.toml template -- `crates/mobench-sdk/src/builders/android.rs` - Use new `run_uniffi_bindgen()` function -- `crates/mobench-sdk/src/builders/ios.rs` - Same - -**Acceptance Criteria** - -- [ ] New projects have uniffi-bindgen binary generated automatically -- [ ] Build works without global uniffi-bindgen installation -- [ ] Clear error message if uniffi-bindgen can't be found - ---- - -### Task 6: Generate Error Handling Code Dynamically - -**Problem** - -Mobile app templates hardcode error variants like `BenchException.InvalidIterations` that may not exist in the user's UniFFI schema, causing compilation failures. - -**Current Template (broken)** - -```kotlin -// MainActivity.kt -} catch (e: BenchException.InvalidIterations) { - "Error: ${e.message}" -} catch (e: BenchException.UnknownFunction) { - "Error: ${e.message}" -``` - -```swift -// BenchRunnerFFI.swift -case .InvalidIterations(let message): - return "Error: \(message)" -case .UnknownFunction(let message): - return "Error: \(message)" -``` - -**Solution** - -Use generic catch pattern that works with any error type: - -```kotlin -// MainActivity.kt -} catch (e: BenchException) { - "Error: ${e.message}" -} catch (e: Exception) { - "Unexpected error: ${e.message}" -} -``` - -```swift -// BenchRunnerFFI.swift -private static func formatBenchError(_ error: BenchError) -> String { - return "Error: \(error.localizedDescription)" -} -``` - -**Files to Modify** - -- `crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template` -- `crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template` - -**Acceptance Criteria** - -- [ ] Generated Android app compiles with any BenchError variant set -- [ ] Generated iOS app compiles with any BenchError variant set -- [ ] Error messages are still descriptive - ---- - -## P2 - Medium Priority - -### Task 7: Add Configuration File Support (mobench.toml) - -**Problem** - -Users must pass many CLI flags repeatedly. No way to persist project configuration. - -**Solution** - -Support `mobench.toml` at project root: - -```toml -[project] -crate = "bench-mobile" -library_name = "bench_mobile" - -[android] -package = "com.example.bench" -min_sdk = 24 -target_sdk = 34 - -[ios] -bundle_id = "com.example.bench" -deployment_target = "15.0" - -[benchmarks] -default_function = "my_crate::my_benchmark" -default_iterations = 100 -default_warmup = 10 -``` - -**Files to Modify** - -- `crates/mobench/src/lib.rs` - Add config file loading at startup -- `crates/mobench-sdk/src/types.rs` - Add `MobenchConfig` struct -- Create `crates/mobench/src/config.rs` module - -**Acceptance Criteria** - -- [ ] Config file is loaded automatically if present -- [ ] CLI flags override config file values -- [ ] `mobench init` can generate a starter config file - ---- - -### Task 8: Improve Error Messages - -**Problem** - -Error messages don't explain what's expected or how to fix issues. - -**Current** - -``` -build error: Benchmark crate 'bench-mobile' not found. Tried: - - "/path/bench-mobile/bench-mobile" - - "/path/bench-mobile/crates/bench-mobile" -``` - -**Improved** - -``` -build error: Benchmark crate 'bench-mobile' not found. - -Searched locations: - ✗ /path/project/bench-mobile/Cargo.toml - ✗ /path/project/crates/bench-mobile/Cargo.toml - -To fix this: - 1. Create a bench-mobile/ directory with your benchmark crate, or - 2. Use --crate-path to specify the benchmark crate location: - mobench build --target android --crate-path ./my-benchmarks - -Run 'mobench init-sdk --help' to generate a new benchmark project. -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/builders/android.rs` - All error returns -- `crates/mobench-sdk/src/builders/ios.rs` - All error returns -- `crates/mobench-sdk/src/types.rs` - Enhance `BenchError` variants with more context - -**Acceptance Criteria** - -- [x] All error messages include actionable fix suggestions -- [x] Searched paths are listed when file not found -- [x] Links to documentation where appropriate - ---- - -### Task 9: Add --crate-path Flag - -**Problem** - -mobench hardcodes looking for `bench-mobile/` or `crates/sample-fns/`. Real projects have different structures like `crates/benchmarks/`, `benches/mobile/`, etc. - -**Solution** - -Add `--crate-path` flag to `build` command: - -```rust -#[derive(Parser)] -struct Build { - #[arg(long)] - target: SdkTarget, - - /// Path to the benchmark crate (default: auto-detect bench-mobile/ or crates/sample-fns/) - #[arg(long)] - crate_path: Option, - - #[arg(long)] - release: bool, - - #[arg(long)] - output_dir: Option, -} -``` - -**Files to Modify** - -- `crates/mobench/src/lib.rs` - Add CLI argument, pass to builders -- `crates/mobench-sdk/src/builders/android.rs` - Accept optional `crate_path` in constructor -- `crates/mobench-sdk/src/builders/ios.rs` - Same - -**Acceptance Criteria** - -- [ ] `mobench build --target android --crate-path ./my-bench` works -- [ ] Auto-detection still works when `--crate-path` not provided -- [ ] Error message suggests `--crate-path` when auto-detection fails - ---- - -## P3 - Nice to Have - -### Task 10: Add --dry-run and --verbose Modes - -**Problem** - -Hard to debug what mobench is doing. Users can't preview changes before they happen. - -**Solution** - -Add global flags: - -```rust -#[derive(Parser)] -#[command(name = "mobench")] -struct Cli { - /// Print what would be done without actually doing it - #[arg(long, global = true)] - dry_run: bool, - - /// Print verbose output including all commands - #[arg(long, short = 'v', global = true)] - verbose: bool, - - #[command(subcommand)] - command: Command, -} -``` - -**Behavior** - -- `--dry-run`: Print commands that would be executed, files that would be created/modified -- `--verbose`: Print all commands as they run, show full output - -**Files to Modify** - -- `crates/mobench/src/lib.rs` - Add global flags, thread through to all functions -- `crates/mobench-sdk/src/builders/*.rs` - Respect dry_run/verbose flags - -**Acceptance Criteria** - -- [ ] `mobench build --target android --dry-run` shows what would happen without making changes -- [ ] `mobench build --target android --verbose` shows all commands being run - ---- - -### Task 11: Auto-generate local.properties for Android - -**Problem** - -Gradle fails without `sdk.dir` being set. Users must manually create `local.properties`: - -``` -SDK location not found. Define a valid SDK location with an ANDROID_HOME -environment variable or by setting the sdk.dir path in your project's -local properties file. -``` - -**Solution** - -Auto-detect Android SDK and generate `local.properties`: - -```rust -fn ensure_local_properties(android_dir: &Path) -> Result<()> { - let local_props = android_dir.join("local.properties"); - if local_props.exists() { - return Ok(()); - } - - let sdk_dir = detect_android_sdk()?; - fs::write(&local_props, format!("sdk.dir={}\n", sdk_dir.display()))?; - println!(" Generated local.properties with SDK at {}", sdk_dir.display()); - Ok(()) -} - -fn detect_android_sdk() -> Result { - // Check environment variables - if let Ok(sdk) = std::env::var("ANDROID_HOME") { - return Ok(PathBuf::from(sdk)); - } - if let Ok(sdk) = std::env::var("ANDROID_SDK_ROOT") { - return Ok(PathBuf::from(sdk)); - } - - // Check common locations - let home = dirs::home_dir().ok_or_else(|| BenchError::Build("Cannot find home directory".into()))?; - - let candidates = [ - home.join("Library/Android/sdk"), // macOS default - home.join("Android/Sdk"), // Linux default - PathBuf::from("/usr/local/android-sdk"), // Common Linux location - ]; - - for candidate in &candidates { - if candidate.exists() { - return Ok(candidate.clone()); - } - } - - Err(BenchError::Build( - "Android SDK not found. Set ANDROID_HOME environment variable or install Android Studio.".into() - )) -} -``` - -**Files to Modify** - -- `crates/mobench-sdk/src/builders/android.rs` - Add `ensure_local_properties()`, call before Gradle - -**Acceptance Criteria** - -- [ ] `local.properties` is auto-generated if missing -- [ ] Existing `local.properties` is not overwritten -- [ ] Clear error if SDK cannot be found - ---- - -## Implementation Order - -Recommended order based on dependencies and impact: - -1. **Task 2** - Target directory detection (unblocks workspace projects) -2. **Task 3** - Auto-generate scaffolding (simplifies getting started) -3. **Task 4** - Template processing (makes generated projects work) -4. **Task 1** - aws-lc-rs fix (unblocks Android builds) -5. **Task 6** - Error handling (makes generated code compile) -6. **Task 11** - local.properties (removes manual step) -7. **Task 5** - uniffi-bindgen (removes manual step) -8. **Task 9** - --crate-path (flexibility for real projects) -9. **Task 8** - Error messages (better debugging) -10. **Task 7** - Config file (power users) -11. **Task 10** - dry-run/verbose (debugging) - -## Testing Strategy - -After implementing, verify with: - -1. **Fresh project test:** - ```bash - cargo new my-bench && cd my-bench - cargo add mobench-sdk - # Add a #[benchmark] function - mobench build --target android - # Should produce working APK - ``` - -2. **Workspace project test:** - ```bash - git clone https://github.com/worldcoin/world-id-protocol - cd world-id-protocol - mobench build --target android --crate-path ./bench-mobile - # Should produce working APK - ``` - -3. **Both platforms test:** - ```bash - mobench build --target both - # Should produce both APK and xcframework/IPA - ``` From b3877a14a1717626662a80e6bbc3eb98dca92d43 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 20:40:17 +0100 Subject: [PATCH 037/196] Fix all DX issues from mobench 0.1.9 testing Critical bugs fixed (12): - Bugs 1-5: Template variables (PROJECT_NAME, PACKAGE_NAME, LIBRARY_NAME, PROJECT_NAME_PASCAL, APP_NAME) now properly substituted - Bug 6: Added gradle.properties template with AndroidX settings - Bug 7: Auto-generate Gradle wrapper if missing - Bug 8: Fixed AGP version from 8.13.2 to 8.2.2 - Bug 9: Package name in Kotlin templates now uses {{PACKAGE_NAME}} - Bug 10: Added x86_64-apple-ios target for Intel Mac simulators - Bug 11: Fixed path handling by canonicalizing project root - Bug 12: DEFAULT_FUNCTION now auto-detected from #[benchmark] functions High severity issues fixed (8): - Issue 1: Added post-template validation for unreplaced {{...}} patterns - Issue 2: codesign_xcframework now returns Err on failure - Issue 3: generate_xcode_project now returns Err on failure - Issue 4: Missing native libraries always warn (not just verbose) - Issue 5: Added validate_project_root() for early validation - Issue 6: cargo metadata fallback now warns when used - Issue 7: Kotlin catch block logs exceptions instead of swallowing - Issue 8: Added validate_build_artifacts() post-build validation Medium/Low issues fixed (4): - Added .gitignore templates for Android and iOS - Added README.md templates for Android and iOS New files: - templates/android/.gitignore - templates/android/README.md - templates/android/gradle.properties - templates/ios/BenchRunner/.gitignore - templates/ios/BenchRunner/README.md --- crates/mobench-sdk/src/builders/android.rs | 176 +++++- crates/mobench-sdk/src/builders/common.rs | 79 ++- crates/mobench-sdk/src/builders/ios.rs | 391 ++++++++++++-- crates/mobench-sdk/src/codegen.rs | 509 +++++++++++++++++- .../mobench-sdk/templates/android/.gitignore | 20 + .../mobench-sdk/templates/android/README.md | 24 + .../src/main/java/MainActivity.kt.template | 3 +- .../app/src/main/res/values/themes.xml | 2 +- .../templates/android/build.gradle | 2 +- .../templates/android/gradle.properties | 7 + .../templates/ios/BenchRunner/.gitignore | 18 + .../templates/ios/BenchRunner/README.md | 28 + mobench-0.1.9-dx-report.md | 388 +++++++++++++ 13 files changed, 1551 insertions(+), 96 deletions(-) create mode 100644 crates/mobench-sdk/templates/android/.gitignore create mode 100644 crates/mobench-sdk/templates/android/README.md create mode 100644 crates/mobench-sdk/templates/android/gradle.properties create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/.gitignore create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/README.md create mode 100644 mobench-0.1.9-dx-report.md diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index f46d67c..c985555 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -56,7 +56,7 @@ //! ``` use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; -use super::common::{get_cargo_target_dir, host_lib_path, run_command}; +use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -171,6 +171,11 @@ impl AndroidBuilder { /// * `Ok(BuildResult)` containing the path to the built APK /// * `Err(BenchError)` if the build fails pub fn build(&self, config: &BuildConfig) -> Result { + // Validate project root before starting build + if self.crate_dir.is_none() { + validate_project_root(&self.project_root, &self.crate_name)?; + } + let android_dir = self.output_dir.join("android"); let profile_name = match config.profile { BuildProfile::Debug => "debug", @@ -180,6 +185,7 @@ impl AndroidBuilder { if self.dry_run { println!("\n[dry-run] Android build plan:"); println!(" Step 0: Check/generate Android project scaffolding at {:?}", android_dir); + println!(" Step 0.5: Ensure Gradle wrapper exists (run 'gradle wrapper' if needed)"); println!(" Step 1: Build Rust libraries for Android ABIs (arm64-v8a, armeabi-v7a, x86_64)"); println!(" Command: cargo ndk --target --platform 24 build {}", if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); @@ -202,7 +208,16 @@ impl AndroidBuilder { } // Step 0: Ensure Android project scaffolding exists - crate::codegen::ensure_android_project(&self.output_dir, &self.crate_name)?; + // Pass project_root and crate_dir for better benchmark function detection + crate::codegen::ensure_android_project_with_options( + &self.output_dir, + &self.crate_name, + Some(&self.project_root), + self.crate_dir.as_deref(), + )?; + + // Step 0.5: Ensure Gradle wrapper exists + self.ensure_gradle_wrapper(&android_dir)?; // Step 1: Build Rust libraries println!("Building Rust libraries for Android..."); @@ -224,11 +239,70 @@ impl AndroidBuilder { println!("Building Android test APK..."); let test_suite_path = self.build_test_apk(config)?; - Ok(BuildResult { + // Step 6: Validate all expected artifacts exist + let result = BuildResult { platform: Target::Android, app_path: apk_path, test_suite_path: Some(test_suite_path), - }) + }; + self.validate_build_artifacts(&result, config)?; + + Ok(result) + } + + /// Validates that all expected build artifacts exist after a successful build + fn validate_build_artifacts(&self, result: &BuildResult, config: &BuildConfig) -> Result<(), BenchError> { + let mut missing = Vec::new(); + let profile_dir = match config.profile { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + }; + + // Check main APK + if !result.app_path.exists() { + missing.push(format!("Main APK: {}", result.app_path.display())); + } + + // Check test APK + if let Some(ref test_path) = result.test_suite_path { + if !test_path.exists() { + missing.push(format!("Test APK: {}", test_path.display())); + } + } + + // Check that at least one native library exists in jniLibs + let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs"); + let lib_name = format!("lib{}.so", self.crate_name.replace("-", "_")); + let required_abis = ["arm64-v8a", "armeabi-v7a", "x86_64"]; + let mut found_libs = 0; + for abi in &required_abis { + let lib_path = jni_libs_dir.join(abi).join(&lib_name); + if lib_path.exists() { + found_libs += 1; + } else { + missing.push(format!("Native library ({} {}): {}", abi, profile_dir, lib_path.display())); + } + } + + if found_libs == 0 { + return Err(BenchError::Build(format!( + "Build validation failed: No native libraries found.\n\n\ + Expected at least one .so file in jniLibs directories.\n\ + Missing artifacts:\n{}\n\n\ + This usually means the Rust build step failed. Check the cargo-ndk output above.", + missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + ))); + } + + if !missing.is_empty() { + eprintln!( + "Warning: Some build artifacts are missing:\n{}\n\ + The build may still work but some features might be unavailable.", + missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + ); + } + + Ok(()) } /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) @@ -559,8 +633,15 @@ impl AndroidBuilder { if self.verbose { println!(" Copied {} -> {}", src.display(), dest.display()); } - } else if self.verbose { - println!(" Warning: {} not found, skipping", src.display()); + } else { + // Always warn about missing native libraries - this will cause runtime crashes + eprintln!( + "Warning: Native library for {} not found at {}.\n\ + This will cause a runtime crash when the app tries to load the library.\n\ + Ensure cargo-ndk build completed successfully for this ABI.", + android_abi, + src.display() + ); } } @@ -657,6 +738,89 @@ impl AndroidBuilder { ))) } + /// Ensures the Gradle wrapper (gradlew) exists in the Android project + /// + /// If gradlew doesn't exist, this runs `gradle wrapper --gradle-version 8.5` + /// to generate the wrapper files. + fn ensure_gradle_wrapper(&self, android_dir: &Path) -> Result<(), BenchError> { + let gradlew = android_dir.join("gradlew"); + + // If gradlew already exists, we're good + if gradlew.exists() { + return Ok(()); + } + + println!("Gradle wrapper not found, generating..."); + + // Check if gradle is available + let gradle_available = Command::new("gradle") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !gradle_available { + return Err(BenchError::Build( + "Gradle wrapper (gradlew) not found and 'gradle' command is not available.\n\n\ + The Android project requires Gradle to build. You have two options:\n\n\ + 1. Install Gradle globally and run the build again (it will auto-generate the wrapper):\n\ + - macOS: brew install gradle\n\ + - Linux: sudo apt install gradle\n\ + - Or download from https://gradle.org/install/\n\n\ + 2. Or generate the wrapper manually in the Android project directory:\n\ + cd target/mobench/android && gradle wrapper --gradle-version 8.5" + .to_string(), + )); + } + + // Run gradle wrapper to generate gradlew + let mut cmd = Command::new("gradle"); + cmd.arg("wrapper") + .arg("--gradle-version") + .arg("8.5") + .current_dir(android_dir); + + let output = cmd.output().map_err(|e| { + BenchError::Build(format!( + "Failed to run 'gradle wrapper' command: {}\n\n\ + Ensure Gradle is installed and on your PATH.", + e + )) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "Failed to generate Gradle wrapper.\n\n\ + Command: gradle wrapper --gradle-version 8.5\n\ + Working directory: {}\n\ + Exit status: {}\n\ + Stderr: {}\n\n\ + Try running this command manually in the Android project directory.", + android_dir.display(), + output.status, + stderr + ))); + } + + // Make gradlew executable on Unix systems + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(metadata) = fs::metadata(&gradlew) { + let mut perms = metadata.permissions(); + perms.set_mode(0o755); + let _ = fs::set_permissions(&gradlew, perms); + } + } + + if self.verbose { + println!(" Generated Gradle wrapper at {:?}", gradlew); + } + + Ok(()) + } + /// Builds the Android APK using Gradle fn build_apk(&self, config: &BuildConfig) -> Result { let android_dir = self.output_dir.join("android"); diff --git a/crates/mobench-sdk/src/builders/common.rs b/crates/mobench-sdk/src/builders/common.rs index 4b878e4..5b19309 100644 --- a/crates/mobench-sdk/src/builders/common.rs +++ b/crates/mobench-sdk/src/builders/common.rs @@ -23,6 +23,60 @@ use std::process::Command; use crate::types::BenchError; +/// Validates that the project root is a valid directory for building. +/// +/// This function checks that: +/// - The path exists +/// - The path is a directory +/// - The directory contains a Cargo.toml file (or has a crate directory with one) +/// +/// # Arguments +/// * `project_root` - The project root directory to validate +/// * `crate_name` - The name of the crate being built (used to check crate directories) +/// +/// # Returns +/// `Ok(())` if validation passes, or a descriptive `BenchError` if it fails. +pub fn validate_project_root(project_root: &Path, crate_name: &str) -> Result<(), BenchError> { + // Check if path exists + if !project_root.exists() { + return Err(BenchError::Build(format!( + "Project root does not exist: {}\n\n\ + Ensure you are running from the correct directory or specify --project-root.", + project_root.display() + ))); + } + + // Check if path is a directory + if !project_root.is_dir() { + return Err(BenchError::Build(format!( + "Project root is not a directory: {}\n\n\ + Expected a directory containing your Rust project.", + project_root.display() + ))); + } + + // Check for Cargo.toml in project root or standard crate locations + let root_cargo = project_root.join("Cargo.toml"); + let bench_mobile_cargo = project_root.join("bench-mobile/Cargo.toml"); + let crates_cargo = project_root.join(format!("crates/{}/Cargo.toml", crate_name)); + + if !root_cargo.exists() && !bench_mobile_cargo.exists() && !crates_cargo.exists() { + return Err(BenchError::Build(format!( + "No Cargo.toml found in project root or expected crate locations.\n\n\ + Searched:\n\ + - {}\n\ + - {}\n\ + - {}\n\n\ + Ensure you are in a Rust project directory or use --crate-path to specify the crate location.", + root_cargo.display(), + bench_mobile_cargo.display(), + crates_cargo.display() + ))); + } + + Ok(()) +} + /// Detects the actual Cargo target directory using `cargo metadata`. /// /// This correctly handles Cargo workspaces where the target directory @@ -33,6 +87,10 @@ use crate::types::BenchError; /// /// # Returns /// The path to the target directory, or falls back to `crate_dir/target` if detection fails. +/// +/// # Warnings +/// Prints a warning to stderr if falling back to the default target directory due to +/// cargo metadata failures or parsing issues. pub fn get_cargo_target_dir(crate_dir: &Path) -> Result { let output = Command::new("cargo") .args(["metadata", "--format-version", "1", "--no-deps"]) @@ -51,7 +109,17 @@ pub fn get_cargo_target_dir(crate_dir: &Path) -> Result { if !output.status.success() { // Fall back to crate_dir/target if cargo metadata fails - return Ok(crate_dir.join("target")); + let fallback = crate_dir.join("target"); + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!( + "Warning: cargo metadata failed (exit {}), falling back to {}.\n\ + Stderr: {}\n\ + This may cause build issues if you are in a Cargo workspace.", + output.status, + fallback.display(), + stderr.lines().take(3).collect::>().join("\n") + ); + return Ok(fallback); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -69,7 +137,14 @@ pub fn get_cargo_target_dir(crate_dir: &Path) -> Result { } // Fall back to crate_dir/target if parsing fails - Ok(crate_dir.join("target")) + let fallback = crate_dir.join("target"); + eprintln!( + "Warning: Failed to parse target_directory from cargo metadata output, \ + falling back to {}.\n\ + This may cause build issues if you are in a Cargo workspace.", + fallback.display() + ); + Ok(fallback) } /// Finds the host library path for UniFFI binding generation. diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 233e114..626c08c 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -18,7 +18,7 @@ //! ## Requirements //! //! - Xcode with command line tools (`xcode-select --install`) -//! - Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim` +//! - Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios` //! - `uniffi-bindgen` for Swift binding generation //! - `xcodegen` (optional, `brew install xcodegen`) //! @@ -71,7 +71,7 @@ //! ``` use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; -use super::common::{get_cargo_target_dir, host_lib_path, run_command}; +use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -125,10 +125,14 @@ impl IosBuilder { /// /// # Arguments /// - /// * `project_root` - Root directory containing the bench-mobile crate + /// * `project_root` - Root directory containing the bench-mobile crate. This path + /// will be canonicalized to ensure consistent behavior regardless of the current + /// working directory. /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile") pub fn new(project_root: impl Into, crate_name: impl Into) -> Self { - let root = project_root.into(); + let root_input = project_root.into(); + // Canonicalize the path to handle relative paths correctly, regardless of cwd + let root = root_input.canonicalize().unwrap_or(root_input); Self { output_dir: root.join("target/mobench"), project_root: root, @@ -190,6 +194,11 @@ impl IosBuilder { /// * `Ok(BuildResult)` containing the path to the xcframework /// * `Err(BenchError)` if the build fails pub fn build(&self, config: &BuildConfig) -> Result { + // Validate project root before starting build + if self.crate_dir.is_none() { + validate_project_root(&self.project_root, &self.crate_name)?; + } + let framework_name = self.crate_name.replace("-", "_"); let ios_dir = self.output_dir.join("ios"); let xcframework_path = ios_dir.join(format!("{}.xcframework", framework_name)); @@ -202,11 +211,13 @@ impl IosBuilder { if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); println!(" Command: cargo build --target aarch64-apple-ios-sim --lib {}", if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); + println!(" Command: cargo build --target x86_64-apple-ios --lib {}", + if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); println!(" Step 2: Generate UniFFI Swift bindings"); println!(" Output: {:?}", ios_dir.join("BenchRunner/BenchRunner/Generated")); println!(" Step 3: Create xcframework at {:?}", xcframework_path); println!(" - ios-arm64/{}.framework (device)", framework_name); - println!(" - ios-simulator-arm64/{}.framework (simulator)", framework_name); + println!(" - ios-arm64_x86_64-simulator/{}.framework (simulator - arm64 + x86_64 lipo)", framework_name); println!(" Step 4: Code-sign xcframework"); println!(" Command: codesign --force --deep --sign - {:?}", xcframework_path); println!(" Step 5: Generate Xcode project with xcodegen (if project.yml exists)"); @@ -221,7 +232,13 @@ impl IosBuilder { } // Step 0: Ensure iOS project scaffolding exists - crate::codegen::ensure_ios_project(&self.output_dir, &self.crate_name)?; + // Pass project_root and crate_dir for better benchmark function detection + crate::codegen::ensure_ios_project_with_options( + &self.output_dir, + &self.crate_name, + Some(&self.project_root), + self.crate_dir.as_deref(), + )?; // Step 1: Build Rust libraries println!("Building Rust libraries for iOS..."); @@ -267,11 +284,92 @@ impl IosBuilder { // Step 5: Generate Xcode project if needed self.generate_xcode_project()?; - Ok(BuildResult { + // Step 6: Validate all expected artifacts exist + let result = BuildResult { platform: Target::Ios, app_path: xcframework_path, test_suite_path: None, - }) + }; + self.validate_build_artifacts(&result, config)?; + + Ok(result) + } + + /// Validates that all expected build artifacts exist after a successful build + fn validate_build_artifacts(&self, result: &BuildResult, config: &BuildConfig) -> Result<(), BenchError> { + let mut missing = Vec::new(); + let framework_name = self.crate_name.replace("-", "_"); + let profile_dir = match config.profile { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + }; + + // Check xcframework exists + if !result.app_path.exists() { + missing.push(format!("XCFramework: {}", result.app_path.display())); + } + + // Check framework slices exist within xcframework + let xcframework_path = &result.app_path; + let device_slice = xcframework_path.join(format!("ios-arm64/{}.framework", framework_name)); + // Combined simulator slice with arm64 + x86_64 + let sim_slice = xcframework_path.join(format!("ios-arm64_x86_64-simulator/{}.framework", framework_name)); + + if xcframework_path.exists() { + if !device_slice.exists() { + missing.push(format!("Device framework slice: {}", device_slice.display())); + } + if !sim_slice.exists() { + missing.push(format!("Simulator framework slice (arm64+x86_64): {}", sim_slice.display())); + } + } + + // Check that static libraries were built + let crate_dir = self.find_crate_dir()?; + let target_dir = get_cargo_target_dir(&crate_dir)?; + let lib_name = format!("lib{}.a", framework_name); + + let device_lib = target_dir.join("aarch64-apple-ios").join(profile_dir).join(&lib_name); + let sim_arm64_lib = target_dir.join("aarch64-apple-ios-sim").join(profile_dir).join(&lib_name); + let sim_x86_64_lib = target_dir.join("x86_64-apple-ios").join(profile_dir).join(&lib_name); + + if !device_lib.exists() { + missing.push(format!("Device static library: {}", device_lib.display())); + } + if !sim_arm64_lib.exists() { + missing.push(format!("Simulator (arm64) static library: {}", sim_arm64_lib.display())); + } + if !sim_x86_64_lib.exists() { + missing.push(format!("Simulator (x86_64) static library: {}", sim_x86_64_lib.display())); + } + + // Check Swift bindings + let swift_bindings = self.output_dir + .join("ios/BenchRunner/BenchRunner/Generated") + .join(format!("{}.swift", framework_name)); + if !swift_bindings.exists() { + missing.push(format!("Swift bindings: {}", swift_bindings.display())); + } + + if !missing.is_empty() { + let critical = missing.iter().any(|m| m.contains("XCFramework") || m.contains("static library")); + if critical { + return Err(BenchError::Build(format!( + "Build validation failed: Critical artifacts are missing.\n\n\ + Missing artifacts:\n{}\n\n\ + This usually means the Rust build step failed. Check the cargo build output above.", + missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + ))); + } else { + eprintln!( + "Warning: Some build artifacts are missing:\n{}\n\ + The build may still work but some features might be unavailable.", + missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + ); + } + } + + Ok(()) } /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) @@ -326,10 +424,11 @@ impl IosBuilder { fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> { let crate_dir = self.find_crate_dir()?; - // iOS targets: device and simulator + // iOS targets: device and simulator (both arm64 and x86_64 for Intel Macs) let targets = vec![ "aarch64-apple-ios", // Device (ARM64) - "aarch64-apple-ios-sim", // Simulator (M1+ Macs) + "aarch64-apple-ios-sim", // Simulator (Apple Silicon Macs) + "x86_64-apple-ios", // Simulator (Intel Macs) ]; // Check if targets are installed @@ -426,9 +525,10 @@ impl IosBuilder { This target is required to compile for iOS.\n\n\ To install:\n\ rustup target add {}\n\n\ - For a complete iOS setup, you need both:\n\ + For a complete iOS setup, you need all three:\n\ rustup target add aarch64-apple-ios # Device\n\ - rustup target add aarch64-apple-ios-sim # Simulator (Apple Silicon)", + rustup target add aarch64-apple-ios-sim # Simulator (Apple Silicon)\n\ + rustup target add x86_64-apple-ios # Simulator (Intel Macs)", target, target ))); } @@ -576,6 +676,7 @@ impl IosBuilder { })?; // Build framework structure for each platform + // Device slice (arm64 only) self.create_framework_slice( &target_dir.join("aarch64-apple-ios").join(profile_dir), &xcframework_path.join("ios-arm64"), @@ -583,11 +684,12 @@ impl IosBuilder { "ios", )?; - self.create_framework_slice( - &target_dir.join("aarch64-apple-ios-sim").join(profile_dir), - &xcframework_path.join("ios-simulator-arm64"), + // Simulator slice (arm64 + x86_64 combined via lipo for both Apple Silicon and Intel Macs) + self.create_simulator_framework_slice( + &target_dir, + profile_dir, + &xcframework_path.join("ios-arm64_x86_64-simulator"), framework_name, - "ios-simulator", )?; // Create xcframework Info.plist @@ -676,6 +778,137 @@ impl IosBuilder { Ok(()) } + /// Creates a combined simulator framework slice with arm64 + x86_64 using lipo + fn create_simulator_framework_slice( + &self, + target_dir: &Path, + profile_dir: &str, + output_dir: &Path, + framework_name: &str, + ) -> Result<(), BenchError> { + let framework_dir = output_dir.join(format!("{}.framework", framework_name)); + let headers_dir = framework_dir.join("Headers"); + + // Create directories + fs::create_dir_all(&headers_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to create framework directories at {}: {}. Check output directory permissions.", + headers_dir.display(), + e + )) + })?; + + // Paths to the simulator libraries + let arm64_lib = target_dir + .join("aarch64-apple-ios-sim") + .join(profile_dir) + .join(format!("lib{}.a", framework_name)); + let x86_64_lib = target_dir + .join("x86_64-apple-ios") + .join(profile_dir) + .join(format!("lib{}.a", framework_name)); + + // Check that both libraries exist + if !arm64_lib.exists() { + return Err(BenchError::Build(format!( + "Simulator library (arm64) not found at {}.\n\n\ + Expected output from cargo build --target aarch64-apple-ios-sim --lib.\n\ + Ensure your crate has [lib] crate-type = [\"staticlib\"].", + arm64_lib.display() + ))); + } + if !x86_64_lib.exists() { + return Err(BenchError::Build(format!( + "Simulator library (x86_64) not found at {}.\n\n\ + Expected output from cargo build --target x86_64-apple-ios --lib.\n\ + Ensure your crate has [lib] crate-type = [\"staticlib\"].", + x86_64_lib.display() + ))); + } + + // Use lipo to combine arm64 and x86_64 into a universal binary + let dest_lib = framework_dir.join(framework_name); + let output = Command::new("lipo") + .arg("-create") + .arg(&arm64_lib) + .arg(&x86_64_lib) + .arg("-output") + .arg(&dest_lib) + .output() + .map_err(|e| { + BenchError::Build(format!( + "Failed to run lipo to create universal simulator binary.\n\n\ + Command: lipo -create {} {} -output {}\n\ + Error: {}\n\n\ + Ensure Xcode command line tools are installed: xcode-select --install", + arm64_lib.display(), + x86_64_lib.display(), + dest_lib.display(), + e + )) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "lipo failed to create universal simulator binary.\n\n\ + Command: lipo -create {} {} -output {}\n\ + Exit status: {}\n\ + Stderr: {}\n\n\ + Ensure both libraries are valid static libraries.", + arm64_lib.display(), + x86_64_lib.display(), + dest_lib.display(), + output.status, + stderr + ))); + } + + if self.verbose { + println!( + " Created universal simulator binary (arm64 + x86_64) at {:?}", + dest_lib + ); + } + + // Copy UniFFI-generated header into the framework + let header_name = format!("{}FFI.h", framework_name); + let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| { + BenchError::Build(format!( + "UniFFI header {} not found; run binding generation before building", + header_name + )) + })?; + let dest_header = headers_dir.join(&header_name); + fs::copy(&header_path, &dest_header).map_err(|e| { + BenchError::Build(format!( + "Failed to copy UniFFI header from {} to {}: {}. Check output directory permissions.", + header_path.display(), + dest_header.display(), + e + )) + })?; + + // Create module.modulemap + let modulemap_content = format!( + "framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}", + framework_name, framework_name + ); + let modulemap_path = headers_dir.join("module.modulemap"); + fs::write(&modulemap_path, modulemap_content).map_err(|e| { + BenchError::Build(format!( + "Failed to write module.modulemap at {}: {}. Check output directory permissions.", + modulemap_path.display(), + e + )) + })?; + + // Create framework Info.plist (uses "ios-simulator" platform) + self.create_framework_plist(&framework_dir, framework_name, "ios-simulator")?; + + Ok(()) + } + /// Creates Info.plist for a framework slice fn create_framework_plist( &self, @@ -758,12 +991,13 @@ impl IosBuilder { LibraryIdentifier - ios-simulator-arm64 + ios-arm64_x86_64-simulator LibraryPath {}.framework SupportedArchitectures arm64 + x86_64 SupportedPlatform ios @@ -793,6 +1027,11 @@ impl IosBuilder { } /// Code-signs the xcframework + /// + /// # Errors + /// + /// Returns an error if codesign is not available or if signing fails. + /// The xcframework must be signed for Xcode to accept it. fn codesign_xcframework(&self, xcframework_path: &Path) -> Result<(), BenchError> { let output = Command::new("codesign") .arg("--force") @@ -800,30 +1039,52 @@ impl IosBuilder { .arg("--sign") .arg("-") .arg(xcframework_path) - .output(); + .output() + .map_err(|e| { + BenchError::Build(format!( + "Failed to run codesign.\n\n\ + XCFramework: {}\n\ + Error: {}\n\n\ + Ensure Xcode command line tools are installed:\n\ + xcode-select --install\n\n\ + The xcframework must be signed for Xcode to accept it.", + xcframework_path.display(), + e + )) + })?; - match output { - Ok(output) if output.status.success() => { - if self.verbose { - println!(" Successfully code-signed xcframework"); - } - Ok(()) - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - println!("Warning: Code signing failed: {}", stderr); - println!("You may need to manually sign the xcframework"); - Ok(()) // Don't fail the build for signing issues - } - Err(e) => { - println!("Warning: Could not run codesign: {}", e); - println!("You may need to manually sign the xcframework"); - Ok(()) // Don't fail the build if codesign is not available + if output.status.success() { + if self.verbose { + println!(" Successfully code-signed xcframework"); } + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(BenchError::Build(format!( + "codesign failed to sign xcframework.\n\n\ + XCFramework: {}\n\ + Exit status: {}\n\ + Stderr: {}\n\n\ + Ensure you have valid signing credentials:\n\ + security find-identity -v -p codesigning\n\n\ + For ad-hoc signing (most common), the '-' identity should work.\n\ + If signing continues to fail, check that the xcframework structure is valid.", + xcframework_path.display(), + output.status, + stderr + ))) } } /// Generates Xcode project using xcodegen if project.yml exists + /// + /// # Errors + /// + /// Returns an error if: + /// - xcodegen is not installed and project.yml exists + /// - xcodegen execution fails + /// + /// If project.yml does not exist, this function returns Ok(()) silently. fn generate_xcode_project(&self) -> Result<(), BenchError> { let ios_dir = self.output_dir.join("ios"); let project_yml = ios_dir.join("BenchRunner/project.yml"); @@ -843,32 +1104,46 @@ impl IosBuilder { let output = Command::new("xcodegen") .arg("generate") .current_dir(&project_dir) - .output(); - - match output { - Ok(output) if output.status.success() => Ok(()), - Ok(output) => { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - Err(BenchError::Build(format!( - "xcodegen failed.\n\n\ - Command: xcodegen generate\n\ + .output() + .map_err(|e| { + BenchError::Build(format!( + "Failed to run xcodegen.\n\n\ + project.yml found at: {}\n\ Working directory: {}\n\ - Exit status: {}\n\n\ - Stdout:\n{}\n\n\ - Stderr:\n{}\n\n\ - Tip: install xcodegen with: brew install xcodegen", + Error: {}\n\n\ + xcodegen is required to generate the Xcode project.\n\ + Install it with:\n\ + brew install xcodegen\n\n\ + After installation, re-run the build.", + project_yml.display(), project_dir.display(), - output.status, - stdout, - stderr - ))) - } - Err(e) => { - println!("Warning: xcodegen not found or failed: {}", e); - println!("Install xcodegen with: brew install xcodegen"); - Ok(()) // Don't fail if xcodegen is not available + e + )) + })?; + + if output.status.success() { + if self.verbose { + println!(" Successfully generated Xcode project"); } + Ok(()) + } else { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + Err(BenchError::Build(format!( + "xcodegen failed.\n\n\ + Command: xcodegen generate\n\ + Working directory: {}\n\ + Exit status: {}\n\n\ + Stdout:\n{}\n\n\ + Stderr:\n{}\n\n\ + Check that project.yml is valid YAML and has correct xcodegen syntax.\n\ + Try running 'xcodegen generate' manually in {} for more details.", + project_dir.display(), + output.status, + stdout, + stderr, + project_dir.display() + ))) } } diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 2215fe7..f2889d3 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -5,6 +5,7 @@ use crate::types::{BenchError, InitConfig, Target}; use std::fs; +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use include_dir::{Dir, DirEntry, include_dir}; @@ -47,17 +48,21 @@ pub fn generate_project(config: &InitConfig) -> Result { // Generate bench-mobile FFI wrapper crate generate_bench_mobile_crate(output_dir, &project_slug)?; + // For full project generation (init), use "example_fibonacci" as the default + // since the generated example benchmarks include this function + let default_function = "example_fibonacci"; + // Generate platform-specific projects match config.target { Target::Android => { - generate_android_project(output_dir, &project_slug)?; + generate_android_project(output_dir, &project_slug, default_function)?; } Target::Ios => { - generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix)?; + generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?; } Target::Both => { - generate_android_project(output_dir, &project_slug)?; - generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix)?; + generate_android_project(output_dir, &project_slug, default_function)?; + generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?; } } @@ -275,24 +280,43 @@ uniffi::setup_scaffolding!(); /// /// * `output_dir` - Directory to write the `android/` project into /// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile") -pub fn generate_android_project(output_dir: &Path, project_slug: &str) -> Result<(), BenchError> { +/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark") +pub fn generate_android_project( + output_dir: &Path, + project_slug: &str, + default_function: &str, +) -> Result<(), BenchError> { let target_dir = output_dir.join("android"); + let library_name = project_slug.replace('-', "_"); + let project_pascal = to_pascal_case(project_slug); let vars = vec![ + TemplateVar { + name: "PROJECT_NAME", + value: project_slug.to_string(), + }, + TemplateVar { + name: "PROJECT_NAME_PASCAL", + value: project_pascal.clone(), + }, + TemplateVar { + name: "APP_NAME", + value: format!("{} Benchmark", project_pascal), + }, TemplateVar { name: "PACKAGE_NAME", value: format!("dev.world.{}", project_slug), }, TemplateVar { name: "UNIFFI_NAMESPACE", - value: project_slug.replace('-', "_"), + value: library_name.clone(), }, TemplateVar { name: "LIBRARY_NAME", - value: project_slug.replace('-', "_"), + value: library_name, }, TemplateVar { name: "DEFAULT_FUNCTION", - value: "example_fibonacci".to_string(), + value: default_function.to_string(), }, ]; render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?; @@ -310,17 +334,19 @@ pub fn generate_android_project(output_dir: &Path, project_slug: &str) -> Result /// * `project_slug` - Project name (e.g., "bench-mobile" -> "bench_mobile") /// * `project_pascal` - PascalCase version of project name (e.g., "BenchMobile") /// * `bundle_prefix` - iOS bundle ID prefix (e.g., "dev.world.bench") +/// * `default_function` - Default benchmark function to use (e.g., "bench_mobile::my_benchmark") pub fn generate_ios_project( output_dir: &Path, project_slug: &str, project_pascal: &str, bundle_prefix: &str, + default_function: &str, ) -> Result<(), BenchError> { let target_dir = output_dir.join("ios"); let vars = vec![ TemplateVar { name: "DEFAULT_FUNCTION", - value: "example_fibonacci".to_string(), + value: default_function.to_string(), }, TemplateVar { name: "PROJECT_NAME_PASCAL", @@ -429,6 +455,12 @@ fn fibonacci(n: u32) -> u64 { Ok(()) } +/// File extensions that should be processed for template variable substitution +const TEMPLATE_EXTENSIONS: &[&str] = &[ + "gradle", "xml", "kt", "java", "swift", "yml", "yaml", "json", "toml", "md", "txt", "h", "m", + "plist", "pbxproj", "xcscheme", "xcworkspacedata", "entitlements", "modulemap", +]; + fn render_dir( dir: &Dir, out_root: &Path, @@ -450,22 +482,30 @@ fn render_dir( // file.path() returns the full relative path from the embedded dir root let mut relative = file.path().to_path_buf(); let mut contents = file.contents().to_vec(); - if let Some(ext) = relative.extension() - && ext == "template" - { + + // Check if file has .template extension (explicit template) + let is_explicit_template = relative + .extension() + .map(|ext| ext == "template") + .unwrap_or(false); + + // Check if file is a text file that should be processed for templates + let should_render = is_explicit_template || is_template_file(&relative); + + if is_explicit_template { + // Remove .template extension from output filename relative.set_extension(""); - let rendered = render_template( - std::str::from_utf8(&contents).map_err(|e| { - BenchError::Build(format!( - "invalid UTF-8 in template {:?}: {}", - file.path(), - e - )) - })?, - vars, - ); - contents = rendered.into_bytes(); } + + if should_render { + if let Ok(text) = std::str::from_utf8(&contents) { + let rendered = render_template(text, vars); + // Validate that all template variables were replaced + validate_no_unreplaced_placeholders(&rendered, &relative)?; + contents = rendered.into_bytes(); + } + } + let out_path = out_root.join(relative); if let Some(parent) = out_path.parent() { fs::create_dir_all(parent)?; @@ -477,6 +517,66 @@ fn render_dir( Ok(()) } +/// Checks if a file should be processed for template variable substitution +/// based on its extension +fn is_template_file(path: &Path) -> bool { + // Check for .template extension on any file + if let Some(ext) = path.extension() { + if ext == "template" { + return true; + } + // Check if the base extension is in our list + if let Some(ext_str) = ext.to_str() { + return TEMPLATE_EXTENSIONS.contains(&ext_str); + } + } + // Also check the filename without the .template extension + if let Some(stem) = path.file_stem() { + let stem_path = Path::new(stem); + if let Some(ext) = stem_path.extension() { + if let Some(ext_str) = ext.to_str() { + return TEMPLATE_EXTENSIONS.contains(&ext_str); + } + } + } + false +} + +/// Validates that no unreplaced template placeholders remain in the rendered content +fn validate_no_unreplaced_placeholders(content: &str, file_path: &Path) -> Result<(), BenchError> { + // Find all {{...}} patterns + let mut pos = 0; + let mut unreplaced = Vec::new(); + + while let Some(start) = content[pos..].find("{{") { + let abs_start = pos + start; + if let Some(end) = content[abs_start..].find("}}") { + let placeholder = &content[abs_start..abs_start + end + 2]; + // Extract just the variable name + let var_name = &content[abs_start + 2..abs_start + end]; + // Skip placeholders that look like Gradle variable syntax (e.g., ${...}) + // or other non-template patterns + if !var_name.contains('$') && !var_name.contains(' ') && !var_name.is_empty() { + unreplaced.push(placeholder.to_string()); + } + pos = abs_start + end + 2; + } else { + break; + } + } + + if !unreplaced.is_empty() { + return Err(BenchError::Build(format!( + "Template validation failed for {:?}: unreplaced placeholders found: {:?}\n\n\ + This is a bug in mobench-sdk. Please report it at:\n\ + https://github.com/worldcoin/mobile-bench-rs/issues", + file_path, unreplaced + ))); + } + + Ok(()) +} + fn render_template(input: &str, vars: &[TemplateVar]) -> String { let mut output = input.to_string(); for var in vars { @@ -528,37 +628,193 @@ pub fn ios_project_exists(output_dir: &Path) -> bool { output_dir.join("ios/BenchRunner/project.yml").exists() } +/// Detects the first benchmark function in a crate by scanning src/lib.rs for `#[benchmark]` +/// +/// This function looks for functions marked with the `#[benchmark]` attribute and returns +/// the first one found in the format `{crate_name}::{function_name}`. +/// +/// # Arguments +/// +/// * `crate_dir` - Path to the crate directory containing Cargo.toml +/// * `crate_name` - Name of the crate (used as prefix for the function name) +/// +/// # Returns +/// +/// * `Some(String)` - The detected function name in format `crate_name::function_name` +/// * `None` - If no benchmark functions are found or if the file cannot be read +pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option { + let lib_rs = crate_dir.join("src/lib.rs"); + if !lib_rs.exists() { + return None; + } + + let file = fs::File::open(&lib_rs).ok()?; + let reader = BufReader::new(file); + + let mut found_benchmark_attr = false; + let crate_name_normalized = crate_name.replace('-', "_"); + + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + + // Check for #[benchmark] attribute + if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") { + found_benchmark_attr = true; + continue; + } + + // If we found a benchmark attribute, look for the function definition + if found_benchmark_attr { + // Look for "fn function_name" or "pub fn function_name" + if let Some(fn_pos) = trimmed.find("fn ") { + let after_fn = &trimmed[fn_pos + 3..]; + // Extract function name (until '(' or whitespace) + let fn_name: String = after_fn + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + + if !fn_name.is_empty() { + return Some(format!("{}::{}", crate_name_normalized, fn_name)); + } + } + // Reset if we hit a line that's not a function definition + // (could be another attribute or comment) + if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() { + found_benchmark_attr = false; + } + } + } + + None +} + +/// Resolves the default benchmark function for a project +/// +/// This function attempts to auto-detect benchmark functions from the crate's source. +/// If no benchmarks are found, it falls back to a sensible default based on the crate name. +/// +/// # Arguments +/// +/// * `project_root` - Root directory of the project +/// * `crate_name` - Name of the benchmark crate +/// * `crate_dir` - Optional explicit crate directory (if None, will search standard locations) +/// +/// # Returns +/// +/// The default function name in format `crate_name::function_name` +pub fn resolve_default_function( + project_root: &Path, + crate_name: &str, + crate_dir: Option<&Path>, +) -> String { + let crate_name_normalized = crate_name.replace('-', "_"); + + // Try to find the crate directory + let search_dirs: Vec = if let Some(dir) = crate_dir { + vec![dir.to_path_buf()] + } else { + vec![ + project_root.join("bench-mobile"), + project_root.join("crates").join(crate_name), + project_root.to_path_buf(), + ] + }; + + // Try to detect benchmarks from each potential location + for dir in &search_dirs { + if dir.join("Cargo.toml").exists() { + if let Some(detected) = detect_default_function(dir, &crate_name_normalized) { + return detected; + } + } + } + + // Fallback: use a sensible default based on crate name + format!("{}::example_benchmark", crate_name_normalized) +} + /// Auto-generates Android project scaffolding from a crate name /// /// This is a convenience function that derives template variables from the -/// crate name and generates the Android project structure. +/// crate name and generates the Android project structure. It auto-detects +/// the default benchmark function from the crate's source code. /// /// # Arguments /// /// * `output_dir` - Directory to write the `android/` project into /// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile") pub fn ensure_android_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> { + ensure_android_project_with_options(output_dir, crate_name, None, None) +} + +/// Auto-generates Android project scaffolding with additional options +/// +/// This is a more flexible version of `ensure_android_project` that allows +/// specifying a custom default function and/or crate directory. +/// +/// # Arguments +/// +/// * `output_dir` - Directory to write the `android/` project into +/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile") +/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent) +/// * `crate_dir` - Optional explicit crate directory for benchmark detection +pub fn ensure_android_project_with_options( + output_dir: &Path, + crate_name: &str, + project_root: Option<&Path>, + crate_dir: Option<&Path>, +) -> Result<(), BenchError> { if android_project_exists(output_dir) { return Ok(()); } println!("Android project not found, generating scaffolding..."); let project_slug = crate_name.replace('-', "_"); - generate_android_project(output_dir, &project_slug)?; + + // Resolve the default function by auto-detecting from source + let effective_root = project_root.unwrap_or_else(|| { + output_dir.parent().unwrap_or(output_dir) + }); + let default_function = resolve_default_function(effective_root, crate_name, crate_dir); + + generate_android_project(output_dir, &project_slug, &default_function)?; println!(" Generated Android project at {:?}", output_dir.join("android")); + println!(" Default benchmark function: {}", default_function); Ok(()) } /// Auto-generates iOS project scaffolding from a crate name /// /// This is a convenience function that derives template variables from the -/// crate name and generates the iOS project structure. +/// crate name and generates the iOS project structure. It auto-detects +/// the default benchmark function from the crate's source code. /// /// # Arguments /// /// * `output_dir` - Directory to write the `ios/` project into /// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile") pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), BenchError> { + ensure_ios_project_with_options(output_dir, crate_name, None, None) +} + +/// Auto-generates iOS project scaffolding with additional options +/// +/// This is a more flexible version of `ensure_ios_project` that allows +/// specifying a custom default function and/or crate directory. +/// +/// # Arguments +/// +/// * `output_dir` - Directory to write the `ios/` project into +/// * `crate_name` - Name of the benchmark crate (e.g., "bench-mobile") +/// * `project_root` - Optional project root for auto-detecting benchmarks (defaults to output_dir parent) +/// * `crate_dir` - Optional explicit crate directory for benchmark detection +pub fn ensure_ios_project_with_options( + output_dir: &Path, + crate_name: &str, + project_root: Option<&Path>, + crate_dir: Option<&Path>, +) -> Result<(), BenchError> { if ios_project_exists(output_dir) { return Ok(()); } @@ -569,8 +825,16 @@ pub fn ensure_ios_project(output_dir: &Path, crate_name: &str) -> Result<(), Ben // Derive library name and bundle prefix from crate name let library_name = crate_name.replace('-', "_"); let bundle_prefix = format!("dev.world.{}", library_name.replace('_', "-")); - generate_ios_project(output_dir, &library_name, project_pascal, &bundle_prefix)?; + + // Resolve the default function by auto-detecting from source + let effective_root = project_root.unwrap_or_else(|| { + output_dir.parent().unwrap_or(output_dir) + }); + let default_function = resolve_default_function(effective_root, crate_name, crate_dir); + + generate_ios_project(output_dir, &library_name, project_pascal, &bundle_prefix, &default_function)?; println!(" Generated iOS project at {:?}", output_dir.join("ios")); + println!(" Default benchmark function: {}", default_function); Ok(()) } @@ -595,4 +859,195 @@ mod tests { // Cleanup fs::remove_dir_all(&temp_dir).ok(); } + + #[test] + fn test_generate_android_project_no_unreplaced_placeholders() { + let temp_dir = env::temp_dir().join("mobench-sdk-android-test"); + // Clean up any previous test run + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let result = generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func"); + assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err()); + + // Verify key files exist + let android_dir = temp_dir.join("android"); + assert!(android_dir.join("settings.gradle").exists()); + assert!(android_dir.join("app/build.gradle").exists()); + assert!(android_dir.join("app/src/main/AndroidManifest.xml").exists()); + assert!(android_dir.join("app/src/main/res/values/strings.xml").exists()); + assert!(android_dir.join("app/src/main/res/values/themes.xml").exists()); + + // Verify no unreplaced placeholders remain in generated files + let files_to_check = [ + "settings.gradle", + "app/build.gradle", + "app/src/main/AndroidManifest.xml", + "app/src/main/res/values/strings.xml", + "app/src/main/res/values/themes.xml", + ]; + + for file in files_to_check { + let path = android_dir.join(file); + let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file)); + + // Check for unreplaced placeholders + let has_placeholder = contents.contains("{{") && contents.contains("}}"); + assert!( + !has_placeholder, + "File {} contains unreplaced template placeholders: {}", + file, + contents + ); + } + + // Verify specific substitutions were made + let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap(); + assert!( + settings.contains("my-bench-project-android") || settings.contains("my_bench_project-android"), + "settings.gradle should contain project name" + ); + + let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap(); + assert!( + build_gradle.contains("dev.world.my-bench-project") || build_gradle.contains("dev.world.my_bench_project"), + "build.gradle should contain package name" + ); + + let manifest = fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap(); + assert!( + manifest.contains("Theme.MyBenchProject"), + "AndroidManifest.xml should contain PascalCase theme name" + ); + + let strings = fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap(); + assert!( + strings.contains("Benchmark"), + "strings.xml should contain app name with Benchmark" + ); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_is_template_file() { + assert!(is_template_file(Path::new("settings.gradle"))); + assert!(is_template_file(Path::new("app/build.gradle"))); + assert!(is_template_file(Path::new("AndroidManifest.xml"))); + assert!(is_template_file(Path::new("strings.xml"))); + assert!(is_template_file(Path::new("MainActivity.kt.template"))); + assert!(is_template_file(Path::new("project.yml"))); + assert!(is_template_file(Path::new("Info.plist"))); + assert!(!is_template_file(Path::new("libfoo.so"))); + assert!(!is_template_file(Path::new("image.png"))); + } + + #[test] + fn test_validate_no_unreplaced_placeholders() { + // Should pass with no placeholders + assert!(validate_no_unreplaced_placeholders("hello world", Path::new("test.txt")).is_ok()); + + // Should pass with Gradle variables (not our placeholders) + assert!(validate_no_unreplaced_placeholders("${ENV_VAR}", Path::new("test.txt")).is_ok()); + + // Should fail with unreplaced template placeholders + let result = validate_no_unreplaced_placeholders("hello {{NAME}}", Path::new("test.txt")); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("{{NAME}}")); + } + + #[test] + fn test_to_pascal_case() { + assert_eq!(to_pascal_case("my-project"), "MyProject"); + assert_eq!(to_pascal_case("my_project"), "MyProject"); + assert_eq!(to_pascal_case("myproject"), "Myproject"); + assert_eq!(to_pascal_case("my-bench-project"), "MyBenchProject"); + } + + #[test] + fn test_detect_default_function_finds_benchmark() { + let temp_dir = env::temp_dir().join("mobench-sdk-detect-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(temp_dir.join("src")).unwrap(); + + // Create a lib.rs with a benchmark function + let lib_content = r#" +use mobench_sdk::benchmark; + +/// Some docs +#[benchmark] +fn my_benchmark_func() { + // benchmark code +} + +fn helper_func() {} +"#; + fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap(); + fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); + + let result = detect_default_function(&temp_dir, "my_crate"); + assert_eq!(result, Some("my_crate::my_benchmark_func".to_string())); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_detect_default_function_no_benchmark() { + let temp_dir = env::temp_dir().join("mobench-sdk-detect-none-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(temp_dir.join("src")).unwrap(); + + // Create a lib.rs without benchmark functions + let lib_content = r#" +fn regular_function() { + // no benchmark here +} +"#; + fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap(); + + let result = detect_default_function(&temp_dir, "my_crate"); + assert!(result.is_none()); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_detect_default_function_pub_fn() { + let temp_dir = env::temp_dir().join("mobench-sdk-detect-pub-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(temp_dir.join("src")).unwrap(); + + // Create a lib.rs with a public benchmark function + let lib_content = r#" +#[benchmark] +pub fn public_bench() { + // benchmark code +} +"#; + fs::write(temp_dir.join("src/lib.rs"), lib_content).unwrap(); + + let result = detect_default_function(&temp_dir, "test-crate"); + assert_eq!(result, Some("test_crate::public_bench".to_string())); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_resolve_default_function_fallback() { + let temp_dir = env::temp_dir().join("mobench-sdk-resolve-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + // No lib.rs exists, should fall back to default + let result = resolve_default_function(&temp_dir, "my-crate", None); + assert_eq!(result, "my_crate::example_benchmark"); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } } diff --git a/crates/mobench-sdk/templates/android/.gitignore b/crates/mobench-sdk/templates/android/.gitignore new file mode 100644 index 0000000..ad17687 --- /dev/null +++ b/crates/mobench-sdk/templates/android/.gitignore @@ -0,0 +1,20 @@ +# Gradle +.gradle/ +build/ +local.properties + +# IDE +.idea/ +*.iml + +# Android +*.apk +*.aab +*.dex +*.class + +# Kotlin +*.kotlin_module + +# Local configuration +local.properties diff --git a/crates/mobench-sdk/templates/android/README.md b/crates/mobench-sdk/templates/android/README.md new file mode 100644 index 0000000..d88d1d9 --- /dev/null +++ b/crates/mobench-sdk/templates/android/README.md @@ -0,0 +1,24 @@ +# {{PROJECT_NAME}} Android Benchmark App + +This is an auto-generated Android app for running Rust benchmarks on real devices. + +## Building + +```bash +# Build debug APK +./gradlew assembleDebug + +# Build release APK +./gradlew assembleRelease +``` + +## Running Benchmarks + +The app reads benchmark configuration from: +1. Intent extras (`bench_function`, `bench_iterations`, `bench_warmup`) +2. `assets/bench_spec.json` +3. Default values in code + +## Generated by + +[mobench](https://crates.io/crates/mobench) - Mobile benchmarking SDK for Rust diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index ca577dc..ee1f5c0 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -157,7 +157,8 @@ class MainActivity : AppCompatActivity() { json.optInt("warmup", DEFAULT_WARMUP.toInt()).toUInt(), ) } - } catch (_: Exception) { + } catch (e: Exception) { + android.util.Log.w("MainActivity", "Failed to load bench_spec.json from assets", e) null } } diff --git a/crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml b/crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml index fb5b86a..1cf1e00 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml +++ b/crates/mobench-sdk/templates/android/app/src/main/res/values/themes.xml @@ -1,5 +1,5 @@ - diff --git a/crates/mobench-sdk/templates/android/build.gradle b/crates/mobench-sdk/templates/android/build.gradle index c3e6c75..571ffeb 100644 --- a/crates/mobench-sdk/templates/android/build.gradle +++ b/crates/mobench-sdk/templates/android/build.gradle @@ -4,7 +4,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.13.2' + classpath 'com.android.tools.build:gradle:8.2.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22" } } diff --git a/crates/mobench-sdk/templates/android/gradle.properties b/crates/mobench-sdk/templates/android/gradle.properties new file mode 100644 index 0000000..b665e1a --- /dev/null +++ b/crates/mobench-sdk/templates/android/gradle.properties @@ -0,0 +1,7 @@ +android.useAndroidX=true +android.enableJetifier=true +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +kotlin.code.style=official diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore b/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore new file mode 100644 index 0000000..be86720 --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore @@ -0,0 +1,18 @@ +# Xcode +build/ +DerivedData/ +*.xcworkspace +xcuserdata/ +*.xcuserstate + +# CocoaPods (if used) +Pods/ + +# Generated +*.ipa +*.dSYM.zip +*.dSYM + +# IDE +.idea/ +*.swp diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/README.md b/crates/mobench-sdk/templates/ios/BenchRunner/README.md new file mode 100644 index 0000000..b739b03 --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/README.md @@ -0,0 +1,28 @@ +# {{PROJECT_NAME_PASCAL}} iOS Benchmark App + +This is an auto-generated iOS app for running Rust benchmarks on real devices. + +## Building + +```bash +# Generate Xcode project (if using xcodegen) +xcodegen generate + +# Build for simulator +xcodebuild -scheme BenchRunner -destination 'platform=iOS Simulator,name=iPhone 15' build + +# Build for device +xcodebuild -scheme BenchRunner -destination 'generic/platform=iOS' build +``` + +## Running Benchmarks + +The app reads benchmark configuration from: +1. Environment variables +2. Launch arguments +3. `bench_spec.json` in bundle +4. Default values in code + +## Generated by + +[mobench](https://crates.io/crates/mobench) - Mobile benchmarking SDK for Rust diff --git a/mobench-0.1.9-dx-report.md b/mobench-0.1.9-dx-report.md new file mode 100644 index 0000000..b3502ce --- /dev/null +++ b/mobench-0.1.9-dx-report.md @@ -0,0 +1,388 @@ +# mobench 0.1.9 DX (Developer Experience) Report + +**Date:** 2026-01-19 +**Tested Version:** mobench-sdk 0.1.9, mobench CLI 0.1.9 +**Platform:** macOS Darwin 25.1.0 (arm64) +**Crate:** bench-mobile (World ID ZK Proof Benchmarks) + +## Executive Summary + +End-to-end testing of mobench 0.1.9 for both Android and iOS builds revealed **12 critical bugs**, **8 high-severity issues**, and multiple DX improvement opportunities. The primary problems involve template substitution failures, missing configuration files, and silent failures that mask underlying errors. + +**Build Status:** +- Android: Built successfully after 4 manual fixes +- iOS: Built successfully for arm64 simulator; x86_64 not supported + +--- + +## Critical Bugs + +### Bug 1: `{{PROJECT_NAME}}` Placeholder Not Replaced +**Severity:** CRITICAL +**File:** `target/mobench/android/settings.gradle` + +The `PROJECT_NAME` template variable is **not defined** in the codegen template variable list, leaving the placeholder literally in the generated file: + +```gradle +rootProject.name = "{{PROJECT_NAME}}-android" // NOT REPLACED +``` + +**Impact:** Gradle shows the project as "{{PROJECT_NAME}}-android" in IDE. + +**Fix:** Add `PROJECT_NAME` to template variables in `codegen.rs`. + +--- + +### Bug 2: `{{PACKAGE_NAME}}` Placeholder Not Replaced +**Severity:** CRITICAL +**File:** `target/mobench/android/app/build.gradle` + +Multiple occurrences of `{{PACKAGE_NAME}}` not substituted: +- Line 5: `namespace = "{{PACKAGE_NAME}}"` +- Line 15: `applicationId "{{PACKAGE_NAME}}"` + +**Impact:** Gradle build fails with "{{PACKAGE_NAME}} is not a valid Java identifier". + +**Manual Fix Applied:** +```gradle +namespace = "dev.world.bench_mobile" +applicationId "dev.world.bench_mobile" +``` + +--- + +### Bug 3: `{{LIBRARY_NAME}}` Placeholder Not Replaced +**Severity:** CRITICAL +**File:** `target/mobench/android/app/build.gradle` (line 57) + +```gradle +keepDebugSymbols += ["**/lib{{LIBRARY_NAME}}.so"] // NOT REPLACED +``` + +**Manual Fix Applied:** +```gradle +keepDebugSymbols += ["**/libbench_mobile.so"] +``` + +--- + +### Bug 4: `{{PROJECT_NAME_PASCAL}}` Placeholder Not Replaced +**Severity:** CRITICAL +**File:** `target/mobench/android/app/src/main/AndroidManifest.xml` + +```xml +android:theme="@style/Theme.{{PROJECT_NAME_PASCAL}}" // NOT REPLACED +``` + +**Impact:** Android resource linking fails with "style/Theme.{{PROJECT_NAME_PASCAL}} not found". + +**Manual Fix Applied:** +```xml +android:theme="@style/Theme.MobileBench" +``` + +--- + +### Bug 5: `{{APP_NAME}}` Placeholder Not Replaced +**Severity:** CRITICAL +**File:** `target/mobench/android/app/src/main/res/values/strings.xml` + +```xml +{{APP_NAME}} // NOT REPLACED +``` + +**Impact:** App displays "{{APP_NAME}}" as its title. + +--- + +### Bug 6: Missing `gradle.properties` +**Severity:** CRITICAL +**Expected:** `target/mobench/android/gradle.properties` + +The file doesn't exist in the scaffolded output, causing: +``` +Configuration contains AndroidX dependencies, but android.useAndroidX property is not enabled +``` + +**Manual Fix Applied:** Created file with: +```properties +android.useAndroidX=true +android.enableJetifier=true +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +kotlin.code.style=official +``` + +--- + +### Bug 7: Missing Gradle Wrapper (gradlew) +**Severity:** CRITICAL +**Expected:** `target/mobench/android/gradlew` + +The scaffolded project doesn't include the Gradle wrapper files, causing: +``` +Command: ./gradlew assembleRelease +Error: No such file or directory +``` + +**Manual Fix Applied:** Generated wrapper with `gradle wrapper --gradle-version 8.5` + +--- + +### Bug 8: Invalid Android Gradle Plugin Version +**Severity:** CRITICAL +**File:** `target/mobench/android/build.gradle` + +Template uses: +```gradle +classpath 'com.android.tools.build:gradle:8.13.2' // DOES NOT EXIST +``` + +AGP version 8.13.2 doesn't exist. Latest stable is 8.2.x. + +**Manual Fix Applied:** +```gradle +classpath 'com.android.tools.build:gradle:8.2.2' +``` + +--- + +### Bug 9: Package Name Mismatch +**Severity:** HIGH +**Files:** build.gradle vs Kotlin sources + +- build.gradle template uses `{{PACKAGE_NAME}}` placeholder +- Kotlin sources are hardcoded to `dev.world.bench_mobile` + +These must match or builds fail. The Kotlin files don't use template variables. + +--- + +### Bug 10: Missing x86_64 iOS Simulator Architecture +**Severity:** HIGH +**File:** `target/mobench/ios/bench_mobile.xcframework/Info.plist` + +The xcframework only includes: +- `ios-arm64` (device) +- `ios-simulator-arm64` (Apple Silicon simulator) + +Missing: `ios-simulator-x86_64` (Intel Mac simulator) + +**Impact:** Build fails on Intel Macs and older CI runners: +``` +ld: symbol(s) not found for architecture x86_64 +``` + +--- + +### Bug 11: Path Handling Bug +**Severity:** HIGH + +Running `mobench build --target ios` from within `target/mobench/android/` causes iOS project to be generated at `target/mobench/android/target/mobench/ios/` instead of `target/mobench/ios/`. + +--- + +### Bug 12: Default Benchmark Function Mismatch +**Severity:** HIGH +**Files:** MainActivity.kt, BenchRunnerFFI.swift + +Both use `DEFAULT_FUNCTION = "example_fibonacci"` but the actual benchmarks are: +- `bench_mobile::bench_query_proof_generation` +- `bench_mobile::bench_nullifier_proof_generation` + +**Impact:** Fresh app launch fails with "unknown benchmark function" error. + +--- + +## High Severity Issues + +### Issue 1: No Post-Template Validation +Template rendering doesn't validate that all `{{PLACEHOLDER}}` patterns were replaced. Files are written with literal placeholder text. + +### Issue 2: Silent Codesign Failure +`codesign_xcframework` returns `Ok(())` even when signing fails, just printing a warning. + +### Issue 3: Silent xcodegen Failure +`generate_xcode_project` returns `Ok(())` when xcodegen fails, just printing a warning. + +### Issue 4: Silent Native Library Skip +Missing `.so` files are silently skipped without `--verbose`, leading to runtime crashes. + +### Issue 5: No Path Validation +No validation that the project root exists, is a directory, or contains Cargo.toml. + +### Issue 6: cargo metadata Fallback Hides Errors +Falls back to `crate_dir/target` silently when workspace metadata parsing fails. + +### Issue 7: Empty Catch Block in Kotlin +```kotlin +} catch (_: Exception) { + null // Swallows all exceptions +} +``` + +### Issue 8: No Build Completion Validation +No verification that all expected artifacts exist after build completes. + +--- + +## Medium/Low Severity Issues + +| Issue | Severity | Description | +|-------|----------|-------------| +| Missing .gitignore | MEDIUM | No .gitignore in scaffolded projects | +| local.properties committed | MEDIUM | Contains machine-specific SDK path | +| No README generated | LOW | No documentation in scaffold output | +| Benchmark naming inconsistency | MEDIUM | Tests use `world_id_mobile_bench::` prefix, examples use `bench_mobile::` | + +--- + +## Manual Fixes Applied During Testing + +### Android Fixes (in order): + +1. **build.gradle (root):** + ```diff + - classpath 'com.android.tools.build:gradle:8.13.2' + + classpath 'com.android.tools.build:gradle:8.2.2' + ``` + +2. **app/build.gradle:** + ```diff + - namespace = "{{PACKAGE_NAME}}" + + namespace = "dev.world.bench_mobile" + + - applicationId "{{PACKAGE_NAME}}" + + applicationId "dev.world.bench_mobile" + + - keepDebugSymbols += ["**/lib{{LIBRARY_NAME}}.so"] + + keepDebugSymbols += ["**/libbench_mobile.so"] + ``` + +3. **AndroidManifest.xml:** + ```diff + - android:theme="@style/Theme.{{PROJECT_NAME_PASCAL}}" + + android:theme="@style/Theme.MobileBench" + ``` + +4. **Created gradle.properties** (entire file) + +5. **Generated Gradle wrapper:** + ```bash + cd target/mobench/android && gradle wrapper --gradle-version 8.5 + ``` + +### iOS Fixes: +None required - build completed for arm64 simulator. Device builds require signing configuration. + +--- + +## Recommended Priority Fixes for mobench-sdk + +### P0 (Blocker - Fix Immediately) + +1. **Fix template variable substitution** + - Add `PROJECT_NAME`, `PROJECT_NAME_PASCAL`, `APP_NAME`, `PACKAGE_NAME`, `LIBRARY_NAME` to all template contexts + - Ensure all placeholders are defined before rendering + +2. **Add placeholder validation** + ```rust + // After render_template(), validate no {{...}} remain + if output.contains("{{") && output.contains("}}") { + return Err(BenchError::Build("Unreplaced placeholder found")); + } + ``` + +3. **Include gradle.properties in templates** + ```properties + android.useAndroidX=true + android.enableJetifier=true + ``` + +4. **Include Gradle wrapper files or generate them** + ```rust + // Generate wrapper if gradle available + Command::new("gradle").arg("wrapper").arg("--gradle-version").arg("8.5") + ``` + +5. **Fix AGP version to valid value (8.2.2)** + +### P1 (High - Fix Before Next Release) + +6. **Add x86_64 iOS simulator support** + ```rust + // Add to iOS build targets + "x86_64-apple-ios" + ``` + +7. **Make error handling explicit** + - Remove `Ok(())` returns on codesign/xcodegen failures + - Add `--skip-signing` and `--skip-xcodegen` flags instead + +8. **Add path validation** + - Verify project_root exists and contains Cargo.toml + - Warn if running from unexpected directory + +9. **Update default benchmark function** + - Generate from discovered benchmarks + - Or use a known-working default + +### P2 (Medium - Nice to Have) + +10. Generate .gitignore files +11. Generate README.md with usage instructions +12. Add build completion validation step +13. Improve error messages with actionable fixes + +--- + +## Test Commands Used + +```bash +# Update mobench CLI +cargo install mobench --version 0.1.9 --force + +# Update SDK dependency +mobench-sdk = "0.1.9" # in Cargo.toml +cargo update -p mobench-sdk + +# Clean and build Android +rm -rf target/mobench +mobench build --target android --release --verbose + +# Build iOS +mobench build --target ios --release --verbose + +# Build Android APK (after fixes) +cd target/mobench/android +gradle wrapper --gradle-version 8.5 +./gradlew assembleRelease + +# Build iOS app for simulator +cd target/mobench/ios/BenchRunner +xcodebuild -scheme BenchRunner -configuration Release \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +``` + +--- + +## Appendix: File Locations + +| File | Purpose | Status | +|------|---------|--------| +| `bench-mobile/Cargo.toml` | Benchmark crate config | OK | +| `bench-mobile/src/lib.rs` | Benchmark implementations | OK | +| `target/mobench/android/` | Generated Android project | Needs 5 fixes | +| `target/mobench/ios/` | Generated iOS project | OK for arm64 | +| `target/mobench/android/app/build/outputs/apk/release/app-release-unsigned.apk` | Android APK (133MB) | Built | +| `target/mobench/ios/bench_mobile.xcframework/` | iOS framework | Built | + +--- + +## Conclusion + +mobench 0.1.9 has significant DX issues with template substitution being the most critical. The tool generates project scaffolding but fails to replace 5+ template placeholders, omits required configuration files, and uses an invalid AGP version. After manual fixes, both platforms build successfully. + +**Recommendation:** Do not use 0.1.9 in CI/CD without the fixes documented above. Wait for 0.1.10 or later with these issues resolved. From 99cfd6c5bad61efada17c6370372f61ca832b984 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 20:53:08 +0100 Subject: [PATCH 038/196] Update documentation for v0.1.9 and fix inconsistencies - Fix all path references to use target/mobench/ as default output dir - Update CLAUDE.md version reference to v0.1.9 - Fix PROJECT_PLAN.md to reflect mobench-runner consolidation into mobench-sdk - Remove dead links to BROWSERSTACK_RUN_2.md (use BROWSERSTACK_METRICS.md) - Fix all init-sdk references to use correct init command - Fix duplicate command and step numbering in TESTING.md - Update iOS artifact paths in BENCH_SDK_INTEGRATION.md --- BENCH_SDK_INTEGRATION.md | 6 +-- BROWSERSTACK_CI_INTEGRATION.md | 2 +- BUILD.md | 20 ++++---- CLAUDE.md | 2 +- FETCH_RESULTS_GUIDE.md | 2 +- PROJECT_PLAN.md | 7 +-- TESTING.md | 53 +++++++++++----------- crates/mobench-sdk/src/builders/android.rs | 6 +-- crates/mobench-sdk/src/builders/ios.rs | 2 +- crates/mobench/README.md | 2 +- 10 files changed, 51 insertions(+), 51 deletions(-) diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index c831c42..ca6e304 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -58,7 +58,7 @@ Benchmarks are identified by name at runtime. You can call them by: From your repo root, create a mobile harness with the CLI: ```bash -cargo mobench init-sdk --target both --project-name my-bench --output-dir . +cargo mobench init --target android --output bench-config.toml ``` This generates: @@ -175,8 +175,8 @@ cargo mobench run \ --iterations 100 \ --warmup 10 \ --devices "iPhone 14-16" \ - --ios-app target/ios/BenchRunner.ipa \ - --ios-test-suite target/ios/BenchRunnerUITests.zip + --ios-app target/mobench/ios/BenchRunner.ipa \ + --ios-test-suite target/mobench/ios/BenchRunnerUITests.zip ``` **IPA Signing Methods:** diff --git a/BROWSERSTACK_CI_INTEGRATION.md b/BROWSERSTACK_CI_INTEGRATION.md index 4584884..8703f65 100644 --- a/BROWSERSTACK_CI_INTEGRATION.md +++ b/BROWSERSTACK_CI_INTEGRATION.md @@ -307,6 +307,6 @@ match client.wait_and_fetch_all_results(build_id, "espresso", Some(600)) { ## Next Steps -- See `BROWSERSTACK_RUN_2.md` for current test run results +- See `BROWSERSTACK_METRICS.md` for metrics and performance documentation - Check `crates/mobench/src/browserstack.rs` for full API documentation - Run `cargo doc --open -p mobench` for detailed API docs diff --git a/BUILD.md b/BUILD.md index d149016..3909f7c 100644 --- a/BUILD.md +++ b/BUILD.md @@ -79,7 +79,7 @@ xcodebuild -version cargo mobench build --target android # Install on connected device or emulator -adb install -r android/app/build/outputs/apk/debug/app-debug.apk +adb install -r target/mobench/android/app/build/outputs/apk/debug/app-debug.apk # Launch the app adb shell am start -n dev.world.bench/.MainActivity @@ -146,7 +146,7 @@ adb shell am start -n dev.world.bench/.MainActivity \ cargo mobench build --target android # If only Kotlin/Java changed -cd android && ./gradlew :app:assembleDebug +cd target/mobench/android && ./gradlew :app:assembleDebug # Full clean rebuild cargo clean @@ -161,7 +161,7 @@ cargo mobench build --target android cargo mobench build --target ios # Generate Xcode project -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate # Open in Xcode @@ -257,10 +257,10 @@ The app will launch and display benchmark results. #### Method 2: Command Line (Simulator) ```bash # Build for simulator -xcodebuild -project ios/BenchRunner/BenchRunner.xcodeproj \ +xcodebuild -project target/mobench/ios/BenchRunner/BenchRunner.xcodeproj \ -scheme BenchRunner \ -destination 'platform=iOS Simulator,name=iPhone 15' \ - -derivedDataPath ios/build + -derivedDataPath target/mobench/ios/build # Launch with arguments xcrun simctl launch booted dev.world.bench \ @@ -277,14 +277,14 @@ cargo mobench build --target ios # If Swift code changed, just rebuild in Xcode (⌘+B) # If project.yml changed -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate open BenchRunner.xcodeproj # Full clean rebuild cargo clean cargo mobench build --target ios -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate # Clean in Xcode (⌘+Shift+K) then build (⌘+B) ``` @@ -426,9 +426,9 @@ cargo build -p sample-fns cargo mobench build --target android # This updates: -# - android/app/src/main/java/uniffi/sample_fns/sample_fns.kt (Kotlin) -# - ios/BenchRunner/BenchRunner/Generated/sample_fns.swift (Swift) -# - ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h (C header) +# - target/mobench/android/app/src/main/java/uniffi/sample_fns/sample_fns.kt (Kotlin) +# - target/mobench/ios/BenchRunner/BenchRunner/Generated/sample_fns.swift (Swift) +# - target/mobench/ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h (C header) # Then rebuild mobile apps cargo mobench build --target android diff --git a/CLAUDE.md b/CLAUDE.md index 3e7ca09..aa3ae02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.8):** +**Published on crates.io as the mobench ecosystem (v0.1.9):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation diff --git a/FETCH_RESULTS_GUIDE.md b/FETCH_RESULTS_GUIDE.md index 8d25d8a..8185e7f 100644 --- a/FETCH_RESULTS_GUIDE.md +++ b/FETCH_RESULTS_GUIDE.md @@ -268,5 +268,5 @@ cargo mobench run \ ## See Also - `BROWSERSTACK_CI_INTEGRATION.md` - Programmatic API for custom workflows -- `BROWSERSTACK_RUN_2.md` - Example test run documentation +- `BROWSERSTACK_METRICS.md` - Metrics and performance documentation - `cargo mobench run --help` - Full CLI options diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 2463f71..7b2af74 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -15,8 +15,9 @@ ## Architecture Outline -- `mobench`: Orchestrates builds, packaging, upload, AppAutomate sessions, and result collation. -- `mobench-runner`: Minimal Rust harness compiled into mobile libs; exposes FFI entrypoints for target functions and collects timings. +- `mobench`: CLI tool that orchestrates builds, packaging, upload, AppAutomate sessions, and result collation. +- `mobench-sdk`: Core SDK library with timing harness (consolidated from the former `mobench-runner`), builders, registry, and codegen. Compiled into mobile libs; exposes FFI entrypoints for target functions and collects timings. +- `mobench-macros`: Proc macro crate providing the `#[benchmark]` attribute for marking functions. - Mobile bindings: - Android: Kotlin wrapper + APK test harness embedding Rust lib (cargo-ndk); uses Espresso/Appium-style entrypoints for AppAutomate. - iOS: Swift wrapper + test host app/xcframework; invokes Rust via C-ABI bindings. @@ -31,7 +32,7 @@ ## Task Backlog -- [x] Repo bootstrap: Cargo workspace, `mobench` binary crate, `mobench-runner` library crate, example `sample-fns` crate. +- [x] Repo bootstrap: Cargo workspace, `mobench` CLI crate, `mobench-sdk` library crate (timing module consolidated from former `mobench-runner`), `mobench-macros` proc macro crate, example `sample-fns` crate. - [x] Define FFI boundary: macro/attribute to mark benchmarkable Rust functions; export through C ABI; basic timing harness. - [x] Android packaging: cargo-ndk config, Kotlin wrapper module, minimal test/activity to trigger Rust bench entrypoint. - [x] iOS packaging: xcframework build script (cargo lipo or cargo-apple), C header generation (cbindgen), Swift wrapper, test host. diff --git a/TESTING.md b/TESTING.md index 9e8a87b..e5131ad 100644 --- a/TESTING.md +++ b/TESTING.md @@ -57,7 +57,6 @@ xcode-select --install # Install xcodegen brew install xcodegen # https://github.com/yonaskolb/XcodeGen -brew install xcodegen ``` ## Host Testing @@ -88,7 +87,7 @@ The `Mobile Bench (manual)` workflow uploads summary artifacts: cargo mobench build --target android # Install on connected device/emulator -adb install -r android/app/build/outputs/apk/debug/app-debug.apk +adb install -r target/mobench/android/app/build/outputs/apk/debug/app-debug.apk # Launch app adb shell am start -n dev.world.bench/.MainActivity @@ -101,12 +100,12 @@ adb shell am start -n dev.world.bench/.MainActivity cargo mobench build --target android # Step 2: Build APK -cd android +cd target/mobench/android ./gradlew :app:assembleDebug -cd .. +cd ../../.. -# Step 4: Install and launch -adb install -r android/app/build/outputs/apk/debug/app-debug.apk +# Step 3: Install and launch +adb install -r target/mobench/android/app/build/outputs/apk/debug/app-debug.apk adb shell am start -n dev.world.bench/.MainActivity ``` @@ -117,7 +116,7 @@ adb shell am start -n dev.world.bench/.MainActivity cargo mobench build --target android ``` -2. Open `android/` directory in Android Studio +2. Open `target/mobench/android/` directory in Android Studio 3. Let Gradle sync complete @@ -185,7 +184,7 @@ cargo mobench build --target ios # This script: # - Compiles Rust for aarch64-apple-ios (device) and aarch64-apple-ios-sim (simulator) # - Creates xcframework with proper structure: -# target/ios/sample_fns.xcframework/ +# target/mobench/ios/sample_fns.xcframework/ # ├── Info.plist # ├── ios-arm64/ # │ └── sample_fns.framework/ @@ -206,7 +205,7 @@ cargo mobench build --target ios # - Automatically code-signs the xcframework # Step 2: Generate Xcode project from project.yml -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate # Step 3: Open in Xcode @@ -244,10 +243,10 @@ In Xcode: First, build and install to simulator: ```bash # Build for simulator -xcodebuild -project ios/BenchRunner/BenchRunner.xcodeproj \ +xcodebuild -project target/mobench/ios/BenchRunner/BenchRunner.xcodeproj \ -scheme BenchRunner \ -destination 'platform=iOS Simulator,name=iPhone 15' \ - -derivedDataPath ios/build + -derivedDataPath target/mobench/ios/build # Launch with arguments xcrun simctl launch booted dev.world.bench.BenchRunner \ @@ -259,7 +258,7 @@ xcrun simctl launch booted dev.world.bench.BenchRunner \ #### Method 3: Edit bench_spec.json Bundle Resource Add `bench_spec.json` to the app bundle: -1. Create `ios/BenchRunner/BenchRunner/Resources/bench_spec.json`: +1. Create `target/mobench/ios/BenchRunner/BenchRunner/Resources/bench_spec.json`: ```json { "function": "sample_fns::checksum", @@ -320,7 +319,7 @@ cargo mobench build --target android ```bash # Solution: Ensure .so files are in the APK cargo mobench build --target android -cd android && ./gradlew clean assembleDebug +cd target/mobench/android && ./gradlew clean assembleDebug ``` **Problem**: App shows "Error: UnknownFunction" @@ -338,11 +337,11 @@ brew install xcodegen **Problem**: "The Framework 'sample_fns.xcframework' is unsigned" ```bash # Solution: Code-sign the xcframework -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework # The build step includes signing, but if you built manually: cargo mobench build --target ios -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate # Clean build in Xcode (⌘+Shift+K) then build (⌘+B) ``` @@ -358,10 +357,10 @@ xcodegen generate ```bash # Solution: Ensure the bridging header is configured # Check that BenchRunner-Bridging-Header.h exists at: -# ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h +# target/mobench/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h # If missing, create it with: -cat > ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h << 'EOF' +cat > target/mobench/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h << 'EOF' // // BenchRunner-Bridging-Header.h // BenchRunner @@ -373,19 +372,19 @@ cat > ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h << 'EOF' EOF # Then regenerate the Xcode project: -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodegen generate ``` **Problem**: Build fails with "library not found for -lsample_fns" or "framework 'ios-simulator-arm64' not found" ```bash # Solution: Ensure xcframework was built correctly with proper structure -rm -rf target/ios/sample_fns.xcframework +rm -rf target/mobench/ios/sample_fns.xcframework cargo mobench build --target ios -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework # Verify structure: -ls -la target/ios/sample_fns.xcframework/ +ls -la target/mobench/ios/sample_fns.xcframework/ # Should show: # ios-arm64/sample_fns.framework/ # ios-simulator-arm64/sample_fns.framework/ @@ -395,12 +394,12 @@ ls -la target/ios/sample_fns.xcframework/ **Problem**: "While building for iOS Simulator, no library for this platform was found" ```bash # Solution: Rebuild the xcframework - the structure may be incorrect -rm -rf target/ios/sample_fns.xcframework +rm -rf target/mobench/ios/sample_fns.xcframework cargo mobench build --target ios -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework # Clean Xcode build folder -cd ios/BenchRunner +cd target/mobench/ios/BenchRunner xcodebuild clean -project BenchRunner.xcodeproj -scheme BenchRunner # Then build in Xcode ``` @@ -411,7 +410,7 @@ xcodebuild clean -project BenchRunner.xcodeproj -scheme BenchRunner # Check the iOS builder uses `dev.world.sample-fns` for the framework # Rebuild: cargo mobench build --target ios -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework ``` **Problem**: Simulator crashes with "Symbol not found" @@ -419,7 +418,7 @@ codesign --force --deep --sign - target/ios/sample_fns.xcframework # Solution: Clean and rebuild for simulator architecture cargo clean cargo mobench build --target ios -codesign --force --deep --sign - target/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework # In Xcode, clean (⌘+Shift+K) then build (⌘+B) ``` @@ -428,7 +427,7 @@ codesign --force --deep --sign - target/ios/sample_fns.xcframework - Ensure proper code signing is configured in Xcode - Select your development team in Xcode → Project Settings → Signing & Capabilities - Trust developer certificate on device: Settings → General → VPN & Device Management -- The xcframework must be signed: `codesign --force --deep --sign - target/ios/sample_fns.xcframework` +- The xcframework must be signed: `codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework` ### UniFFI Bindings (Proc Macros) diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index c985555..d70d602 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -346,7 +346,7 @@ impl AndroidBuilder { - Typo in crate name (check Cargo.toml [package] name)\n\ - Wrong working directory (run from project root)\n\ - Missing Cargo.toml in the crate directory\n\n\ - Run 'cargo mobench init-sdk --help' to generate a new benchmark project.", + Run 'cargo mobench init --help' to generate a new benchmark project.", self.crate_name, bench_mobile_manifest.display(), crates_manifest.display() @@ -829,7 +829,7 @@ impl AndroidBuilder { return Err(BenchError::Build(format!( "Android project not found at {}.\n\n\ Expected a Gradle project under the output directory.\n\ - Run `cargo mobench init-sdk --target android` or `cargo mobench build --target android` from the project root to generate it.", + Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.", android_dir.display() ))); } @@ -922,7 +922,7 @@ impl AndroidBuilder { return Err(BenchError::Build(format!( "Android project not found at {}.\n\n\ Expected a Gradle project under the output directory.\n\ - Run `cargo mobench init-sdk --target android` or `cargo mobench build --target android` from the project root to generate it.", + Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.", android_dir.display() ))); } diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 626c08c..131fa0d 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -413,7 +413,7 @@ impl IosBuilder { - Typo in crate name (check Cargo.toml [package] name)\n\ - Wrong working directory (run from project root)\n\ - Missing Cargo.toml in the crate directory\n\n\ - Run 'cargo mobench init-sdk --help' to generate a new benchmark project.", + Run 'cargo mobench init --help' to generate a new benchmark project.", self.crate_name, bench_mobile_manifest.display(), crates_manifest.display() diff --git a/crates/mobench/README.md b/crates/mobench/README.md index ef6c5be..10dfd50 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -36,7 +36,7 @@ This creates: - `bench-mobile/` - FFI wrapper crate with UniFFI bindings - `android/` or `ios/` - Platform-specific app projects (generated to output directory) - `bench-config.toml` - Run configuration file -- `mobench.toml` - Project configuration file (when using `init-sdk`) +- `mobench.toml` - Project configuration file (when using `init`) - `benches/example.rs` - Example benchmarks (with `--examples`) ### 2. Write Benchmarks From 9d9e4f15718aab4ddb45a3dbdec6283571e59c14 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 20:57:59 +0100 Subject: [PATCH 039/196] Bump version to 0.1.10 for release --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 663080e..49ea0f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.9" +version = "0.1.10" dependencies = [ "anyhow", "clap", @@ -820,7 +820,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.9" +version = "0.1.10" dependencies = [ "proc-macro2", "quote", @@ -829,7 +829,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.9" +version = "0.1.10" dependencies = [ "anyhow", "include_dir", @@ -1154,7 +1154,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.9" +version = "0.1.10" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index e81691d..36c50f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.9" +version = "0.1.10" [workspace.dependencies] anyhow = "1" diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index db1fa9c..3d62153 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.9", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.10", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 03aa66f..164ad3b 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.9", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.10", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true From 48f5b8cd8574785632b2dc241c73d3a7de6d1307 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 21:03:33 +0100 Subject: [PATCH 040/196] Fix CI workflow paths to use target/mobench/ output directory --- .github/workflows/mobile-bench.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index 2d55817..b8b46b5 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -86,14 +86,14 @@ jobs: gradle-version: 8.7 - name: Assemble APK - working-directory: android + working-directory: target/mobench/android run: gradle :app:assembleDebug - name: Upload APK artifact uses: actions/upload-artifact@v4 with: name: mobile-bench-android-apk - path: android/app/build/outputs/apk/debug/*.apk + path: target/mobench/android/app/build/outputs/apk/debug/*.apk ios: if: ${{ github.event.inputs.platform == 'ios' || github.event.inputs.platform == 'both' || github.event.inputs.platform == '' }} @@ -116,9 +116,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: mobile-bench-ios - path: | - target/ios/sample_fns.xcframework - target/ios/include/sample_fns.h + path: target/mobench/ios/sample_fns.xcframework browserstack: name: BrowserStack run From 752b6a5bbdc5717af0182d50667c3354c41cda6d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 21:25:24 +0100 Subject: [PATCH 041/196] Fix DX issues from 0.1.10 report Android fixes: - Add APK detection for both signed and unsigned builds - Parse output-metadata.json for actual APK filename - Add proguard-rules.pro template for release builds - Update .gitignore to exclude jniLibs and uniffi bindings iOS fixes: - Sanitize bundle identifiers (remove hyphens/underscores) - Framework bundle IDs now use alphanumeric chars only - Add logging for config loading failures - Update .gitignore to exclude xcodeproj, frameworks, bindings Config fixes: - Add logging for missing/invalid JSON keys in templates - Android MainActivity logs warnings for missing config values - iOS BenchRunnerFFI prints errors when JSON parsing fails --- crates/mobench-sdk/src/builders/android.rs | 217 ++++++++-- crates/mobench-sdk/src/builders/ios.rs | 8 +- crates/mobench-sdk/src/codegen.rs | 58 ++- .../mobench-sdk/templates/android/.gitignore | 7 +- .../templates/android/app/proguard-rules.pro | 22 + .../src/main/java/MainActivity.kt.template | 46 ++- .../templates/ios/BenchRunner/.gitignore | 9 + .../BenchRunner/BenchRunnerFFI.swift.template | 4 + mobench-0.1.10-dx-report.md | 242 +++++++++++ mobench-0.1.9-dx-report.md | 388 ------------------ 10 files changed, 571 insertions(+), 430 deletions(-) create mode 100644 crates/mobench-sdk/templates/android/app/proguard-rules.pro create mode 100644 mobench-0.1.10-dx-report.md delete mode 100644 mobench-0.1.9-dx-report.md diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index d70d602..d7ecd8f 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -894,26 +894,118 @@ impl AndroidBuilder { BuildProfile::Release => "release", }; - let apk_path = android_dir - .join("app/build/outputs/apk") - .join(profile_name) - .join(format!("app-{}.apk", profile_name)); + let apk_dir = android_dir.join("app/build/outputs/apk").join(profile_name); - if !apk_path.exists() { - return Err(BenchError::Build(format!( - "APK not found at expected location: {}.\n\n\ - Gradle task {} reported success but no APK was produced.\n\ - Check app/build/outputs/apk/{} and rerun ./gradlew {} if needed.", - apk_path.display(), - gradle_task, - profile_name, - gradle_task - ))); - } + // Try to find APK - check multiple possible filenames + // Gradle produces different names depending on signing configuration: + // - app-release.apk (signed) + // - app-release-unsigned.apk (unsigned release) + // - app-debug.apk (debug) + let apk_path = self.find_apk(&apk_dir, profile_name, gradle_task)?; Ok(apk_path) } + /// Finds the APK file in the build output directory + /// + /// Gradle produces different APK filenames depending on signing configuration: + /// - `app-release.apk` - signed release build + /// - `app-release-unsigned.apk` - unsigned release build + /// - `app-debug.apk` - debug build + /// + /// This method also checks for `output-metadata.json` which contains the actual + /// output filename when present. + fn find_apk(&self, apk_dir: &Path, profile_name: &str, gradle_task: &str) -> Result { + // First, try to read output-metadata.json for the actual APK name + let metadata_path = apk_dir.join("output-metadata.json"); + if metadata_path.exists() { + if let Ok(metadata_content) = fs::read_to_string(&metadata_path) { + // Parse the JSON to find the outputFile + // Format: {"elements":[{"outputFile":"app-release-unsigned.apk",...}]} + if let Some(apk_name) = self.parse_output_metadata(&metadata_content) { + let apk_path = apk_dir.join(&apk_name); + if apk_path.exists() { + if self.verbose { + println!(" Found APK from output-metadata.json: {}", apk_path.display()); + } + return Ok(apk_path); + } + } + } + } + + // Define candidates in order of preference + let candidates = if profile_name == "release" { + vec![ + format!("app-{}.apk", profile_name), // Signed release + format!("app-{}-unsigned.apk", profile_name), // Unsigned release + ] + } else { + vec![ + format!("app-{}.apk", profile_name), // Debug + ] + }; + + // Check each candidate + for candidate in &candidates { + let apk_path = apk_dir.join(candidate); + if apk_path.exists() { + if self.verbose { + println!(" Found APK: {}", apk_path.display()); + } + return Ok(apk_path); + } + } + + // No APK found - provide helpful error message + Err(BenchError::Build(format!( + "APK not found in {}.\n\n\ + Gradle task {} reported success but no APK was produced.\n\ + Searched for:\n{}\n\n\ + Check the build output directory and rerun ./gradlew {} if needed.", + apk_dir.display(), + gradle_task, + candidates.iter().map(|c| format!(" - {}", c)).collect::>().join("\n"), + gradle_task + ))) + } + + /// Parses output-metadata.json to extract the APK filename + /// + /// The JSON format is: + /// ```json + /// { + /// "elements": [ + /// { + /// "outputFile": "app-release-unsigned.apk", + /// ... + /// } + /// ] + /// } + /// ``` + fn parse_output_metadata(&self, content: &str) -> Option { + // Simple JSON parsing without external dependencies + // Look for "outputFile":"" + let pattern = "\"outputFile\""; + if let Some(pos) = content.find(pattern) { + let after_key = &content[pos + pattern.len()..]; + // Skip whitespace and colon + let after_colon = after_key.trim_start().strip_prefix(':')?; + let after_ws = after_colon.trim_start(); + // Extract the string value + if after_ws.starts_with('"') { + let value_start = &after_ws[1..]; + if let Some(end_quote) = value_start.find('"') { + let filename = &value_start[..end_quote]; + if filename.ends_with(".apk") { + return Some(filename.to_string()); + } + } + } + } + None + } + /// Builds the Android test APK using Gradle fn build_test_apk(&self, config: &BuildConfig) -> Result { let android_dir = self.output_dir.join("android"); @@ -981,25 +1073,60 @@ impl AndroidBuilder { BuildProfile::Release => "release", }; - let apk_path = android_dir + let test_apk_dir = android_dir .join("app/build/outputs/apk/androidTest") - .join(profile_name) - .join(format!("app-{}-androidTest.apk", profile_name)); + .join(profile_name); - if !apk_path.exists() { - return Err(BenchError::Build(format!( - "Android test APK not found at expected location: {}.\n\n\ - Gradle task {} reported success but no test APK was produced.\n\ - Check app/build/outputs/apk/androidTest/{} and rerun ./gradlew {} if needed.", - apk_path.display(), - gradle_task, - profile_name, - gradle_task - ))); - } + // Find the test APK - use similar logic to main APK + let apk_path = self.find_test_apk(&test_apk_dir, profile_name, gradle_task)?; Ok(apk_path) } + + /// Finds the test APK file in the build output directory + /// + /// Test APKs can have different naming patterns depending on the build: + /// - `app-debug-androidTest.apk` + /// - `app-release-androidTest.apk` + fn find_test_apk(&self, apk_dir: &Path, profile_name: &str, gradle_task: &str) -> Result { + // First, try to read output-metadata.json for the actual APK name + let metadata_path = apk_dir.join("output-metadata.json"); + if metadata_path.exists() { + if let Ok(metadata_content) = fs::read_to_string(&metadata_path) { + if let Some(apk_name) = self.parse_output_metadata(&metadata_content) { + let apk_path = apk_dir.join(&apk_name); + if apk_path.exists() { + if self.verbose { + println!(" Found test APK from output-metadata.json: {}", apk_path.display()); + } + return Ok(apk_path); + } + } + } + } + + // Check standard naming pattern + let apk_path = apk_dir.join(format!("app-{}-androidTest.apk", profile_name)); + if apk_path.exists() { + if self.verbose { + println!(" Found test APK: {}", apk_path.display()); + } + return Ok(apk_path); + } + + // No test APK found + Err(BenchError::Build(format!( + "Android test APK not found in {}.\n\n\ + Gradle task {} reported success but no test APK was produced.\n\ + Expected: app-{}-androidTest.apk\n\n\ + Check app/build/outputs/apk/androidTest/{} and rerun ./gradlew {} if needed.", + apk_dir.display(), + gradle_task, + profile_name, + profile_name, + gradle_task + ))) + } } #[cfg(test)] @@ -1028,4 +1155,36 @@ mod tests { .output_dir("/custom/output"); assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); } + + #[test] + fn test_parse_output_metadata_unsigned() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + let metadata = r#"{"version":3,"artifactType":{"type":"APK","kind":"Directory"},"applicationId":"dev.world.bench","variantName":"release","elements":[{"type":"SINGLE","filters":[],"attributes":[],"versionCode":1,"versionName":"0.1","outputFile":"app-release-unsigned.apk"}],"elementType":"File"}"#; + let result = builder.parse_output_metadata(metadata); + assert_eq!(result, Some("app-release-unsigned.apk".to_string())); + } + + #[test] + fn test_parse_output_metadata_signed() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + let metadata = r#"{"version":3,"elements":[{"outputFile":"app-release.apk"}]}"#; + let result = builder.parse_output_metadata(metadata); + assert_eq!(result, Some("app-release.apk".to_string())); + } + + #[test] + fn test_parse_output_metadata_no_apk() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + let metadata = r#"{"version":3,"elements":[]}"#; + let result = builder.parse_output_metadata(metadata); + assert_eq!(result, None); + } + + #[test] + fn test_parse_output_metadata_invalid_json() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + let metadata = "not valid json"; + let result = builder.parse_output_metadata(metadata); + assert_eq!(result, None); + } } diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 131fa0d..874b380 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -916,7 +916,13 @@ impl IosBuilder { framework_name: &str, platform: &str, ) -> Result<(), BenchError> { - let bundle_id = framework_name.replace('_', "-"); + // Sanitize bundle ID to only contain alphanumeric characters (no hyphens or underscores) + // iOS bundle identifiers should be alphanumeric with dots separating components + let bundle_id: String = framework_name + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::() + .to_lowercase(); let plist_content = format!( r#" diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index f2889d3..1efd6e7 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -40,7 +40,9 @@ pub fn generate_project(config: &InitConfig) -> Result { let output_dir = &config.output_dir; let project_slug = sanitize_package_name(&config.project_name); let project_pascal = to_pascal_case(&project_slug); - let bundle_prefix = format!("dev.world.{}", project_slug); + // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues + let bundle_id_component = sanitize_bundle_id_component(&project_slug); + let bundle_prefix = format!("dev.world.{}", bundle_id_component); // Create base directories fs::create_dir_all(output_dir)?; @@ -343,6 +345,16 @@ pub fn generate_ios_project( default_function: &str, ) -> Result<(), BenchError> { let target_dir = output_dir.join("ios"); + // Sanitize bundle ID components to ensure they only contain alphanumeric characters + // iOS bundle identifiers should not contain hyphens or underscores + let sanitized_bundle_prefix = { + let parts: Vec<&str> = bundle_prefix.split('.').collect(); + parts.iter() + .map(|part| sanitize_bundle_id_component(part)) + .collect::>() + .join(".") + }; + let sanitized_project_slug = sanitize_bundle_id_component(project_slug); let vars = vec![ TemplateVar { name: "DEFAULT_FUNCTION", @@ -354,11 +366,11 @@ pub fn generate_ios_project( }, TemplateVar { name: "BUNDLE_ID_PREFIX", - value: bundle_prefix.to_string(), + value: sanitized_bundle_prefix.clone(), }, TemplateVar { name: "BUNDLE_ID", - value: format!("{}.{}", bundle_prefix, project_slug), + value: format!("{}.{}", sanitized_bundle_prefix, sanitized_project_slug), }, TemplateVar { name: "LIBRARY_NAME", @@ -585,6 +597,23 @@ fn render_template(input: &str, vars: &[TemplateVar]) -> String { output } +/// Sanitizes a string to be a valid iOS bundle identifier component +/// +/// Bundle identifiers can only contain alphanumeric characters (A-Z, a-z, 0-9), +/// hyphens (-), and dots (.). However, to avoid issues and maintain consistency, +/// this function converts all non-alphanumeric characters to lowercase letters only. +/// +/// Examples: +/// - "bench-mobile" -> "benchmobile" +/// - "bench_mobile" -> "benchmobile" +/// - "my-project_name" -> "myprojectname" +pub fn sanitize_bundle_id_component(name: &str) -> String { + name.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::() + .to_lowercase() +} + fn sanitize_package_name(name: &str) -> String { name.chars() .map(|c| { @@ -824,7 +853,10 @@ pub fn ensure_ios_project_with_options( let project_pascal = "BenchRunner"; // Derive library name and bundle prefix from crate name let library_name = crate_name.replace('-', "_"); - let bundle_prefix = format!("dev.world.{}", library_name.replace('_', "-")); + // Use sanitized bundle ID component (alphanumeric only) to avoid iOS validation issues + // e.g., "bench-mobile" or "bench_mobile" -> "benchmobile" + let bundle_id_component = sanitize_bundle_id_component(crate_name); + let bundle_prefix = format!("dev.world.{}", bundle_id_component); // Resolve the default function by auto-detecting from source let effective_root = project_root.unwrap_or_else(|| { @@ -1050,4 +1082,22 @@ pub fn public_bench() { // Cleanup fs::remove_dir_all(&temp_dir).ok(); } + + #[test] + fn test_sanitize_bundle_id_component() { + // Hyphens should be removed + assert_eq!(sanitize_bundle_id_component("bench-mobile"), "benchmobile"); + // Underscores should be removed + assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile"); + // Mixed separators should all be removed + assert_eq!(sanitize_bundle_id_component("my-project_name"), "myprojectname"); + // Already valid should remain unchanged (but lowercase) + assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile"); + // Numbers should be preserved + assert_eq!(sanitize_bundle_id_component("bench2mobile"), "bench2mobile"); + // Uppercase should be lowercased + assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile"); + // Complex case + assert_eq!(sanitize_bundle_id_component("My-Complex_Project-123"), "mycomplexproject123"); + } } diff --git a/crates/mobench-sdk/templates/android/.gitignore b/crates/mobench-sdk/templates/android/.gitignore index ad17687..8154a1d 100644 --- a/crates/mobench-sdk/templates/android/.gitignore +++ b/crates/mobench-sdk/templates/android/.gitignore @@ -16,5 +16,8 @@ local.properties # Kotlin *.kotlin_module -# Local configuration -local.properties +# Native libraries (copied by mobench build) +app/src/main/jniLibs/ + +# Generated bindings (regenerated by mobench build) +app/src/main/java/uniffi/ diff --git a/crates/mobench-sdk/templates/android/app/proguard-rules.pro b/crates/mobench-sdk/templates/android/app/proguard-rules.pro new file mode 100644 index 0000000..f0aea7c --- /dev/null +++ b/crates/mobench-sdk/templates/android/app/proguard-rules.pro @@ -0,0 +1,22 @@ +# ProGuard rules for mobench Android benchmark app +# These rules ensure UniFFI and JNA work correctly when minification is enabled. + +# Keep JNA classes for UniFFI +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } + +# Keep UniFFI generated bindings +-keep class uniffi.** { *; } + +# Keep application benchmark classes +-keepclassmembers class * { + @uniffi.* ; +} + +# Keep native method names (required for JNI) +-keepclasseswithmembernames class * { + native ; +} + +# Keep Kotlin metadata for reflection +-keep class kotlin.Metadata { *; } diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index ee1f5c0..c334df6 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -148,17 +148,51 @@ class MainActivity : AppCompatActivity() { return try { val raw = assets.open(SPEC_ASSET).bufferedReader().use { it.readText() } if (raw.isBlank()) { + android.util.Log.w("BenchRunner", "bench_spec.json exists but is empty, using defaults") null } else { val json = JSONObject(raw) - BenchParams( - json.optString("function", DEFAULT_FUNCTION), - json.optInt("iterations", DEFAULT_ITERATIONS.toInt()).toUInt(), - json.optInt("warmup", DEFAULT_WARMUP.toInt()).toUInt(), - ) + + // Log warnings for missing or invalid config values + val function = if (json.has("function")) { + json.getString("function") + } else { + android.util.Log.w("BenchRunner", "Config missing 'function' key, using default: $DEFAULT_FUNCTION") + DEFAULT_FUNCTION + } + + val iterations = if (json.has("iterations")) { + try { + json.getInt("iterations").toUInt() + } catch (e: Exception) { + android.util.Log.w("BenchRunner", "Config 'iterations' is not a valid integer: ${json.opt("iterations")}, using default: $DEFAULT_ITERATIONS") + DEFAULT_ITERATIONS + } + } else { + android.util.Log.w("BenchRunner", "Config missing 'iterations' key, using default: $DEFAULT_ITERATIONS") + DEFAULT_ITERATIONS + } + + val warmup = if (json.has("warmup")) { + try { + json.getInt("warmup").toUInt() + } catch (e: Exception) { + android.util.Log.w("BenchRunner", "Config 'warmup' is not a valid integer: ${json.opt("warmup")}, using default: $DEFAULT_WARMUP") + DEFAULT_WARMUP + } + } else { + android.util.Log.w("BenchRunner", "Config missing 'warmup' key, using default: $DEFAULT_WARMUP") + DEFAULT_WARMUP + } + + android.util.Log.i("BenchRunner", "Loaded config from bench_spec.json: function=$function, iterations=$iterations, warmup=$warmup") + BenchParams(function, iterations, warmup) } + } catch (e: java.io.FileNotFoundException) { + android.util.Log.d("BenchRunner", "No bench_spec.json in assets, will use intent extras or defaults") + null } catch (e: Exception) { - android.util.Log.w("MainActivity", "Failed to load bench_spec.json from assets", e) + android.util.Log.e("BenchRunner", "Failed to parse bench_spec.json from assets", e) null } } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore b/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore index be86720..b4f2cba 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore +++ b/crates/mobench-sdk/templates/ios/BenchRunner/.gitignore @@ -4,6 +4,7 @@ DerivedData/ *.xcworkspace xcuserdata/ *.xcuserstate +*.xcodeproj/ # CocoaPods (if used) Pods/ @@ -16,3 +17,11 @@ Pods/ # IDE .idea/ *.swp + +# Native libraries and frameworks (copied/built by mobench) +*.xcframework/ +*.framework/ +*.a + +# Generated bindings (regenerated by mobench build) +BenchRunner/Generated/ diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index e52509d..3ae7978 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -17,13 +17,17 @@ struct BenchParams { static func fromBundle() -> BenchParams? { guard let url = Bundle.main.url(forResource: "bench_spec", withExtension: "json") else { + print("[BenchRunner] No bench_spec.json found in bundle, will use process info or defaults") return nil } do { let data = try Data(contentsOf: url) let decoded = try JSONDecoder().decode(EncodedBenchSpec.self, from: data) + print("[BenchRunner] Loaded config from bench_spec.json: function=\(decoded.function), iterations=\(decoded.iterations), warmup=\(decoded.warmup)") return BenchParams(function: decoded.function, iterations: decoded.iterations, warmup: decoded.warmup) } catch { + print("[BenchRunner] ERROR: Failed to parse bench_spec.json: \(error)") + print("[BenchRunner] Will fall back to process info or defaults") return nil } } diff --git a/mobench-0.1.10-dx-report.md b/mobench-0.1.10-dx-report.md new file mode 100644 index 0000000..5e34e91 --- /dev/null +++ b/mobench-0.1.10-dx-report.md @@ -0,0 +1,242 @@ +# mobench 0.1.10 DX (Developer Experience) Report + +**Date:** 2026-01-19 +**Tested Version:** mobench-sdk 0.1.10, mobench CLI 0.1.10 +**Platform:** macOS Darwin 25.1.0 (arm64) +**Previous Version Tested:** 0.1.9 + +## Executive Summary + +mobench 0.1.10 represents a **major improvement** over 0.1.9, fixing all 12 critical template placeholder bugs identified in the previous version. Both Android and iOS builds now complete successfully without manual intervention. + +**Build Status:** +- Android: APK built successfully (133MB) +- iOS: xcframework and app built successfully with universal simulator support + +**Remaining Issues:** 2 critical, 4 high, 5 medium severity + +--- + +## Improvements from 0.1.9 to 0.1.10 + +### Fixed Issues (12 total from 0.1.9) + +| # | Issue | Status in 0.1.10 | +|---|-------|------------------| +| 1 | `{{PACKAGE_NAME}}` not replaced | ✅ FIXED - Uses `dev.world.bench_mobile` | +| 2 | `{{LIBRARY_NAME}}` not replaced | ✅ FIXED - Uses `libbench_mobile.so` | +| 3 | `{{PROJECT_NAME}}` not replaced | ✅ FIXED - Uses `bench_mobile-android` | +| 4 | `{{PROJECT_NAME_PASCAL}}` not replaced | ✅ FIXED - Theme uses `Theme.BenchMobile` | +| 5 | `{{APP_NAME}}` not replaced | ✅ FIXED - Uses "BenchMobile Benchmark" | +| 6 | Missing `gradle.properties` | ✅ FIXED - Now generated with AndroidX settings | +| 7 | Missing Gradle wrapper | ✅ FIXED - Now auto-generated | +| 8 | Invalid AGP version 8.13.2 | ✅ FIXED - Uses valid 8.2.2 | +| 9 | Package name mismatch | ✅ FIXED - Consistent naming | +| 10 | Missing x86_64 iOS simulator | ✅ FIXED - Universal binary created | +| 11 | Path handling bug | ✅ FIXED - Proper path resolution | +| 12 | Default benchmark function mismatch | ✅ FIXED - Uses actual benchmark name | + +--- + +## New/Remaining Issues in 0.1.10 + +### Critical Issues (2) + +#### Issue 1: APK Filename Mismatch (NEW) +**Severity:** CRITICAL +**Type:** Silent Failure + +The Android build creates `app-release-unsigned.apk` but mobench expects `app-release.apk`, causing a false build failure message. + +**Actual output:** +``` +target/mobench/android/app/build/outputs/apk/release/app-release-unsigned.apk (133MB) +``` + +**mobench error:** +``` +build error: APK not found at expected location: .../app-release.apk +``` + +**Impact:** Build succeeds but mobench reports failure. APK exists and is usable. + +**Fix Required:** Either add signing config to produce `app-release.apk`, or update mobench to check for `app-release-unsigned.apk` fallback. + +--- + +#### Issue 2: iOS Bundle Identifier Contains Invalid Characters +**Severity:** CRITICAL +**File:** `target/mobench/ios/BenchRunner/BenchRunner.xcodeproj/project.pbxproj` + +Bundle identifier `dev.world.bench-mobile.bench_mobile` contains both hyphens and underscores. + +**Impact:** +- App Store submission will be rejected +- Code signing issues on physical devices +- Xcode warning: "invalid character in Bundle Identifier" + +**Fix Required:** Use `dev.world.benchmobile.benchmobile` (no hyphens or underscores). + +--- + +### High Severity Issues (4) + +#### Issue 3: Missing ProGuard Configuration +**File:** `target/mobench/android/app/proguard-rules.pro` (missing) + +The `build.gradle` references `proguard-rules.pro` but the file doesn't exist. Builds fail if ProGuard is enabled. + +**Recommended content:** +```proguard +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } +-keep class uniffi.bench_mobile.** { *; } +-keep class dev.world.bench_mobile.** { *; } +``` + +--- + +#### Issue 4: Silent Config Loading Failures (iOS) +**File:** `target/mobench/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift` (lines 18-29) + +`BenchParams.fromBundle()` silently returns `nil` on JSON parse errors, falling back to defaults without logging. + +--- + +#### Issue 5: Silent Config Loading Failures (Android) +**File:** `target/mobench/android/app/src/main/java/MainActivity.kt` (lines 147-164) + +`optString`/`optInt` methods silently use defaults for missing/mistyped JSON keys. + +--- + +#### Issue 6: Machine-Specific Path in local.properties +**File:** `target/mobench/android/local.properties` + +Contains hardcoded developer-specific SDK path that won't work on other machines. + +--- + +### Medium Severity Issues (5) + +| # | Issue | Location | +|---|-------|----------| +| 7 | Bundle ID inconsistency between platforms | Android: `dev.world.bench_mobile`, iOS: `dev.world.bench-mobile` | +| 8 | Version string format mismatch | Android: "0.1", iOS: "0.1.0" | +| 9 | Deployment target gap | Android minSdk 24 (2016), iOS 15.0 (2021) | +| 10 | UniFFI dispose errors swallowed | `bench_mobile.kt` line 895-905 | +| 11 | Incomplete .gitignore files | Missing native artifact patterns | + +--- + +## Build Output Summary + +### Android +``` +Location: target/mobench/android/ +APK: app/build/outputs/apk/release/app-release-unsigned.apk +Size: 133,561,304 bytes (127 MB) +Architectures: arm64-v8a, armeabi-v7a, x86_64 +``` + +### iOS +``` +Location: target/mobench/ios/ +Framework: bench_mobile.xcframework/ +App: BenchRunner.app (built in DerivedData) +Architectures: ios-arm64, ios-arm64_x86_64-simulator +Size: 267 MB total +``` + +--- + +## Comparison: 0.1.9 vs 0.1.10 + +| Metric | 0.1.9 | 0.1.10 | +|--------|-------|--------| +| **Critical Bugs** | 12 | 2 | +| **Manual Fixes Required** | 5 | 0 | +| **Android Build** | Fails without fixes | Builds (reports false failure) | +| **iOS Build** | Builds (arm64 only) | Builds (universal) | +| **x86_64 Simulator** | ❌ Missing | ✅ Included | +| **Gradle Wrapper** | ❌ Missing | ✅ Generated | +| **gradle.properties** | ❌ Missing | ✅ Generated | +| **Template Placeholders** | 5 unreplaced | All replaced | +| **Default Benchmark** | Wrong name | Correct name | + +--- + +## Test Commands Used + +```bash +# Upgrade mobench CLI +cargo install mobench --version 0.1.10 --force + +# Update SDK dependency +# In bench-mobile/Cargo.toml: mobench-sdk = "0.1.10" +cargo update -p mobench-sdk + +# Clean and build +rm -rf target/mobench +mobench build --target android --release --verbose +mobench build --target ios --release --verbose + +# Verify Android APK (despite mobench error) +ls -la target/mobench/android/app/build/outputs/apk/release/ + +# Build iOS app with Xcode +cd target/mobench/ios/BenchRunner +xcodebuild -scheme BenchRunner -configuration Release \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +``` + +--- + +## Recommended Priority Fixes for 0.1.11 + +### P0 (Critical - Fix Immediately) + +1. **APK filename detection** - Check for both `app-release.apk` and `app-release-unsigned.apk`, or parse `output-metadata.json` + +2. **Bundle identifier fix** - Use consistent naming without hyphens/underscores: `dev.world.benchmobile.benchmobile` + +### P1 (High - Fix Before Release) + +3. **Add proguard-rules.pro** to Android templates + +4. **Log config loading errors** instead of silently falling back to defaults + +5. **Don't generate local.properties** with hardcoded SDK paths + +### P2 (Medium - Nice to Have) + +6. Standardize bundle ID across platforms +7. Standardize version string format (semver) +8. Improve .gitignore completeness +9. Add logging for UniFFI cleanup errors + +--- + +## Agent Analysis Summary + +Three debugging agents were deployed in parallel: + +1. **Code Reviewer Agent** - Found APK naming, ProGuard, and local.properties issues +2. **Silent Failure Hunter Agent** - Found config loading fallbacks and bundle ID issues +3. **Explorer Agent** - Found cross-platform inconsistencies and missing files + +All agents converged on the same critical issues, confirming their validity. + +--- + +## Conclusion + +**mobench 0.1.10 is a significant improvement** over 0.1.9. All template placeholder bugs are fixed, and both platforms build successfully without manual intervention. + +The remaining issues are primarily: +1. APK filename detection (false failure report) +2. iOS bundle identifier format + +**Recommendation:** 0.1.10 is usable for development. The APK is built correctly despite the error message. Fix the bundle identifier before App Store submission. + +**Overall Score:** 8.5/10 (up from 4/10 for 0.1.9) diff --git a/mobench-0.1.9-dx-report.md b/mobench-0.1.9-dx-report.md deleted file mode 100644 index b3502ce..0000000 --- a/mobench-0.1.9-dx-report.md +++ /dev/null @@ -1,388 +0,0 @@ -# mobench 0.1.9 DX (Developer Experience) Report - -**Date:** 2026-01-19 -**Tested Version:** mobench-sdk 0.1.9, mobench CLI 0.1.9 -**Platform:** macOS Darwin 25.1.0 (arm64) -**Crate:** bench-mobile (World ID ZK Proof Benchmarks) - -## Executive Summary - -End-to-end testing of mobench 0.1.9 for both Android and iOS builds revealed **12 critical bugs**, **8 high-severity issues**, and multiple DX improvement opportunities. The primary problems involve template substitution failures, missing configuration files, and silent failures that mask underlying errors. - -**Build Status:** -- Android: Built successfully after 4 manual fixes -- iOS: Built successfully for arm64 simulator; x86_64 not supported - ---- - -## Critical Bugs - -### Bug 1: `{{PROJECT_NAME}}` Placeholder Not Replaced -**Severity:** CRITICAL -**File:** `target/mobench/android/settings.gradle` - -The `PROJECT_NAME` template variable is **not defined** in the codegen template variable list, leaving the placeholder literally in the generated file: - -```gradle -rootProject.name = "{{PROJECT_NAME}}-android" // NOT REPLACED -``` - -**Impact:** Gradle shows the project as "{{PROJECT_NAME}}-android" in IDE. - -**Fix:** Add `PROJECT_NAME` to template variables in `codegen.rs`. - ---- - -### Bug 2: `{{PACKAGE_NAME}}` Placeholder Not Replaced -**Severity:** CRITICAL -**File:** `target/mobench/android/app/build.gradle` - -Multiple occurrences of `{{PACKAGE_NAME}}` not substituted: -- Line 5: `namespace = "{{PACKAGE_NAME}}"` -- Line 15: `applicationId "{{PACKAGE_NAME}}"` - -**Impact:** Gradle build fails with "{{PACKAGE_NAME}} is not a valid Java identifier". - -**Manual Fix Applied:** -```gradle -namespace = "dev.world.bench_mobile" -applicationId "dev.world.bench_mobile" -``` - ---- - -### Bug 3: `{{LIBRARY_NAME}}` Placeholder Not Replaced -**Severity:** CRITICAL -**File:** `target/mobench/android/app/build.gradle` (line 57) - -```gradle -keepDebugSymbols += ["**/lib{{LIBRARY_NAME}}.so"] // NOT REPLACED -``` - -**Manual Fix Applied:** -```gradle -keepDebugSymbols += ["**/libbench_mobile.so"] -``` - ---- - -### Bug 4: `{{PROJECT_NAME_PASCAL}}` Placeholder Not Replaced -**Severity:** CRITICAL -**File:** `target/mobench/android/app/src/main/AndroidManifest.xml` - -```xml -android:theme="@style/Theme.{{PROJECT_NAME_PASCAL}}" // NOT REPLACED -``` - -**Impact:** Android resource linking fails with "style/Theme.{{PROJECT_NAME_PASCAL}} not found". - -**Manual Fix Applied:** -```xml -android:theme="@style/Theme.MobileBench" -``` - ---- - -### Bug 5: `{{APP_NAME}}` Placeholder Not Replaced -**Severity:** CRITICAL -**File:** `target/mobench/android/app/src/main/res/values/strings.xml` - -```xml -{{APP_NAME}} // NOT REPLACED -``` - -**Impact:** App displays "{{APP_NAME}}" as its title. - ---- - -### Bug 6: Missing `gradle.properties` -**Severity:** CRITICAL -**Expected:** `target/mobench/android/gradle.properties` - -The file doesn't exist in the scaffolded output, causing: -``` -Configuration contains AndroidX dependencies, but android.useAndroidX property is not enabled -``` - -**Manual Fix Applied:** Created file with: -```properties -android.useAndroidX=true -android.enableJetifier=true -org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC -org.gradle.daemon=true -org.gradle.parallel=true -org.gradle.caching=true -kotlin.code.style=official -``` - ---- - -### Bug 7: Missing Gradle Wrapper (gradlew) -**Severity:** CRITICAL -**Expected:** `target/mobench/android/gradlew` - -The scaffolded project doesn't include the Gradle wrapper files, causing: -``` -Command: ./gradlew assembleRelease -Error: No such file or directory -``` - -**Manual Fix Applied:** Generated wrapper with `gradle wrapper --gradle-version 8.5` - ---- - -### Bug 8: Invalid Android Gradle Plugin Version -**Severity:** CRITICAL -**File:** `target/mobench/android/build.gradle` - -Template uses: -```gradle -classpath 'com.android.tools.build:gradle:8.13.2' // DOES NOT EXIST -``` - -AGP version 8.13.2 doesn't exist. Latest stable is 8.2.x. - -**Manual Fix Applied:** -```gradle -classpath 'com.android.tools.build:gradle:8.2.2' -``` - ---- - -### Bug 9: Package Name Mismatch -**Severity:** HIGH -**Files:** build.gradle vs Kotlin sources - -- build.gradle template uses `{{PACKAGE_NAME}}` placeholder -- Kotlin sources are hardcoded to `dev.world.bench_mobile` - -These must match or builds fail. The Kotlin files don't use template variables. - ---- - -### Bug 10: Missing x86_64 iOS Simulator Architecture -**Severity:** HIGH -**File:** `target/mobench/ios/bench_mobile.xcframework/Info.plist` - -The xcframework only includes: -- `ios-arm64` (device) -- `ios-simulator-arm64` (Apple Silicon simulator) - -Missing: `ios-simulator-x86_64` (Intel Mac simulator) - -**Impact:** Build fails on Intel Macs and older CI runners: -``` -ld: symbol(s) not found for architecture x86_64 -``` - ---- - -### Bug 11: Path Handling Bug -**Severity:** HIGH - -Running `mobench build --target ios` from within `target/mobench/android/` causes iOS project to be generated at `target/mobench/android/target/mobench/ios/` instead of `target/mobench/ios/`. - ---- - -### Bug 12: Default Benchmark Function Mismatch -**Severity:** HIGH -**Files:** MainActivity.kt, BenchRunnerFFI.swift - -Both use `DEFAULT_FUNCTION = "example_fibonacci"` but the actual benchmarks are: -- `bench_mobile::bench_query_proof_generation` -- `bench_mobile::bench_nullifier_proof_generation` - -**Impact:** Fresh app launch fails with "unknown benchmark function" error. - ---- - -## High Severity Issues - -### Issue 1: No Post-Template Validation -Template rendering doesn't validate that all `{{PLACEHOLDER}}` patterns were replaced. Files are written with literal placeholder text. - -### Issue 2: Silent Codesign Failure -`codesign_xcframework` returns `Ok(())` even when signing fails, just printing a warning. - -### Issue 3: Silent xcodegen Failure -`generate_xcode_project` returns `Ok(())` when xcodegen fails, just printing a warning. - -### Issue 4: Silent Native Library Skip -Missing `.so` files are silently skipped without `--verbose`, leading to runtime crashes. - -### Issue 5: No Path Validation -No validation that the project root exists, is a directory, or contains Cargo.toml. - -### Issue 6: cargo metadata Fallback Hides Errors -Falls back to `crate_dir/target` silently when workspace metadata parsing fails. - -### Issue 7: Empty Catch Block in Kotlin -```kotlin -} catch (_: Exception) { - null // Swallows all exceptions -} -``` - -### Issue 8: No Build Completion Validation -No verification that all expected artifacts exist after build completes. - ---- - -## Medium/Low Severity Issues - -| Issue | Severity | Description | -|-------|----------|-------------| -| Missing .gitignore | MEDIUM | No .gitignore in scaffolded projects | -| local.properties committed | MEDIUM | Contains machine-specific SDK path | -| No README generated | LOW | No documentation in scaffold output | -| Benchmark naming inconsistency | MEDIUM | Tests use `world_id_mobile_bench::` prefix, examples use `bench_mobile::` | - ---- - -## Manual Fixes Applied During Testing - -### Android Fixes (in order): - -1. **build.gradle (root):** - ```diff - - classpath 'com.android.tools.build:gradle:8.13.2' - + classpath 'com.android.tools.build:gradle:8.2.2' - ``` - -2. **app/build.gradle:** - ```diff - - namespace = "{{PACKAGE_NAME}}" - + namespace = "dev.world.bench_mobile" - - - applicationId "{{PACKAGE_NAME}}" - + applicationId "dev.world.bench_mobile" - - - keepDebugSymbols += ["**/lib{{LIBRARY_NAME}}.so"] - + keepDebugSymbols += ["**/libbench_mobile.so"] - ``` - -3. **AndroidManifest.xml:** - ```diff - - android:theme="@style/Theme.{{PROJECT_NAME_PASCAL}}" - + android:theme="@style/Theme.MobileBench" - ``` - -4. **Created gradle.properties** (entire file) - -5. **Generated Gradle wrapper:** - ```bash - cd target/mobench/android && gradle wrapper --gradle-version 8.5 - ``` - -### iOS Fixes: -None required - build completed for arm64 simulator. Device builds require signing configuration. - ---- - -## Recommended Priority Fixes for mobench-sdk - -### P0 (Blocker - Fix Immediately) - -1. **Fix template variable substitution** - - Add `PROJECT_NAME`, `PROJECT_NAME_PASCAL`, `APP_NAME`, `PACKAGE_NAME`, `LIBRARY_NAME` to all template contexts - - Ensure all placeholders are defined before rendering - -2. **Add placeholder validation** - ```rust - // After render_template(), validate no {{...}} remain - if output.contains("{{") && output.contains("}}") { - return Err(BenchError::Build("Unreplaced placeholder found")); - } - ``` - -3. **Include gradle.properties in templates** - ```properties - android.useAndroidX=true - android.enableJetifier=true - ``` - -4. **Include Gradle wrapper files or generate them** - ```rust - // Generate wrapper if gradle available - Command::new("gradle").arg("wrapper").arg("--gradle-version").arg("8.5") - ``` - -5. **Fix AGP version to valid value (8.2.2)** - -### P1 (High - Fix Before Next Release) - -6. **Add x86_64 iOS simulator support** - ```rust - // Add to iOS build targets - "x86_64-apple-ios" - ``` - -7. **Make error handling explicit** - - Remove `Ok(())` returns on codesign/xcodegen failures - - Add `--skip-signing` and `--skip-xcodegen` flags instead - -8. **Add path validation** - - Verify project_root exists and contains Cargo.toml - - Warn if running from unexpected directory - -9. **Update default benchmark function** - - Generate from discovered benchmarks - - Or use a known-working default - -### P2 (Medium - Nice to Have) - -10. Generate .gitignore files -11. Generate README.md with usage instructions -12. Add build completion validation step -13. Improve error messages with actionable fixes - ---- - -## Test Commands Used - -```bash -# Update mobench CLI -cargo install mobench --version 0.1.9 --force - -# Update SDK dependency -mobench-sdk = "0.1.9" # in Cargo.toml -cargo update -p mobench-sdk - -# Clean and build Android -rm -rf target/mobench -mobench build --target android --release --verbose - -# Build iOS -mobench build --target ios --release --verbose - -# Build Android APK (after fixes) -cd target/mobench/android -gradle wrapper --gradle-version 8.5 -./gradlew assembleRelease - -# Build iOS app for simulator -cd target/mobench/ios/BenchRunner -xcodebuild -scheme BenchRunner -configuration Release \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build -``` - ---- - -## Appendix: File Locations - -| File | Purpose | Status | -|------|---------|--------| -| `bench-mobile/Cargo.toml` | Benchmark crate config | OK | -| `bench-mobile/src/lib.rs` | Benchmark implementations | OK | -| `target/mobench/android/` | Generated Android project | Needs 5 fixes | -| `target/mobench/ios/` | Generated iOS project | OK for arm64 | -| `target/mobench/android/app/build/outputs/apk/release/app-release-unsigned.apk` | Android APK (133MB) | Built | -| `target/mobench/ios/bench_mobile.xcframework/` | iOS framework | Built | - ---- - -## Conclusion - -mobench 0.1.9 has significant DX issues with template substitution being the most critical. The tool generates project scaffolding but fails to replace 5+ template placeholders, omits required configuration files, and uses an invalid AGP version. After manual fixes, both platforms build successfully. - -**Recommendation:** Do not use 0.1.9 in CI/CD without the fixes documented above. Wait for 0.1.10 or later with these issues resolved. From 1f321890936312d2696f6b848f890bf80a9af3da Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 21:27:01 +0100 Subject: [PATCH 042/196] Bump version to 0.1.11 for release --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 49ea0f2..dadd2d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "clap", @@ -820,7 +820,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.10" +version = "0.1.11" dependencies = [ "proc-macro2", "quote", @@ -829,7 +829,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "include_dir", @@ -1154,7 +1154,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.10" +version = "0.1.11" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 36c50f8..a3c6cb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.10" +version = "0.1.11" [workspace.dependencies] anyhow = "1" diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 3d62153..80ff7a2 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.10", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.11", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 164ad3b..37e276c 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.10", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.11", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true From 72a1d90c4a56e7e5ffd06c7cc019919e107b544e Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 21:28:12 +0100 Subject: [PATCH 043/196] Update CLAUDE.md version reference to 0.1.11 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa3ae02..c7556f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.9):** +**Published on crates.io as the mobench ecosystem (v0.1.11):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation From c8e345807e746a66a1696a6439e819355080a371 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 21:50:28 +0100 Subject: [PATCH 044/196] Fix DX issues from 0.1.11 report P0 fixes: - Add testBuildType "release" to build.gradle templates - Gradle now creates assembleReleaseAndroidTest task P1 fixes: - local.properties only uses ANDROID_HOME/ANDROID_SDK_ROOT env vars - No longer probes filesystem for hardcoded SDK paths - Kotlin files moved to correct package directory structure P2 fixes: - iOS bundle ID now uses app name: dev.world.benchmobile.BenchRunner - No longer duplicates crate name in bundle ID --- android/app/build.gradle | 2 + crates/mobench-sdk/src/builders/android.rs | 99 ++++--- crates/mobench-sdk/src/codegen.rs | 147 ++++++++++- .../templates/android/app/build.gradle | 2 + mobench-0.1.10-dx-report.md | 242 ------------------ mobench-0.1.11-dx-report.md | 206 +++++++++++++++ templates/android/app/build.gradle | 2 + 7 files changed, 399 insertions(+), 301 deletions(-) delete mode 100644 mobench-0.1.10-dx-report.md create mode 100644 mobench-0.1.11-dx-report.md diff --git a/android/app/build.gradle b/android/app/build.gradle index 34b1371..ff44149 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -18,6 +18,8 @@ android { versionCode 1 versionName "0.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) + testBuildType "release" } buildTypes { diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index d7ecd8f..9fa2409 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -651,8 +651,13 @@ impl AndroidBuilder { /// Ensures local.properties exists with sdk.dir set /// /// Gradle requires this file to know where the Android SDK is located. - /// This function auto-generates the file if missing by detecting the SDK path - /// from environment variables or common installation locations. + /// This function only generates the file if ANDROID_HOME or ANDROID_SDK_ROOT + /// environment variables are set. We intentionally avoid probing filesystem + /// paths to prevent writing machine-specific paths that would break builds + /// on other machines. + /// + /// If neither environment variable is set, we skip generating the file and + /// let Android Studio or Gradle handle SDK detection. fn ensure_local_properties(&self, android_dir: &Path) -> Result<(), BenchError> { let local_props = android_dir.join("local.properties"); @@ -661,81 +666,63 @@ impl AndroidBuilder { return Ok(()); } - // Try to find Android SDK path - let sdk_dir = self.find_android_sdk()?; + // Only generate local.properties if an environment variable is set. + // This avoids writing machine-specific paths that break on other machines. + let sdk_dir = self.find_android_sdk_from_env(); - // Write local.properties - let content = format!("sdk.dir={}\n", sdk_dir.display()); - fs::write(&local_props, content).map_err(|e| { - BenchError::Build(format!( - "Failed to write local.properties at {:?}: {}. Check output directory permissions.", - local_props, e - )) - })?; + match sdk_dir { + Some(path) => { + // Write local.properties with the SDK path from env var + let content = format!("sdk.dir={}\n", path.display()); + fs::write(&local_props, content).map_err(|e| { + BenchError::Build(format!( + "Failed to write local.properties at {:?}: {}. Check output directory permissions.", + local_props, e + )) + })?; - if self.verbose { - println!(" Generated local.properties with sdk.dir={}", sdk_dir.display()); + if self.verbose { + println!(" Generated local.properties with sdk.dir={}", path.display()); + } + } + None => { + // No env var set - skip generating local.properties + // Gradle/Android Studio will auto-detect the SDK or prompt the user + if self.verbose { + println!(" Skipping local.properties generation (ANDROID_HOME/ANDROID_SDK_ROOT not set)"); + println!(" Gradle will auto-detect SDK or you can create local.properties manually"); + } + } } Ok(()) } - /// Finds the Android SDK installation path - fn find_android_sdk(&self) -> Result { - let mut searched = Vec::new(); - + /// Finds the Android SDK installation path from environment variables only + /// + /// Returns Some(path) if ANDROID_HOME or ANDROID_SDK_ROOT is set and the path exists. + /// Returns None if neither is set or the paths don't exist. + /// + /// We intentionally avoid probing common filesystem locations to prevent + /// writing machine-specific paths that would break builds on other machines. + fn find_android_sdk_from_env(&self) -> Option { // Check ANDROID_HOME first (standard) if let Ok(path) = env::var("ANDROID_HOME") { let sdk_path = PathBuf::from(&path); if sdk_path.exists() { - return Ok(sdk_path); + return Some(sdk_path); } - searched.push(sdk_path); } // Check ANDROID_SDK_ROOT (alternative) if let Ok(path) = env::var("ANDROID_SDK_ROOT") { let sdk_path = PathBuf::from(&path); if sdk_path.exists() { - return Ok(sdk_path); - } - searched.push(sdk_path); - } - - // Check common installation locations - if let Ok(home) = env::var("HOME") { - let home_path = PathBuf::from(home); - let candidates = [ - home_path.join("Library/Android/sdk"), // macOS (Android Studio) - home_path.join("Android/Sdk"), // Linux (Android Studio) - home_path.join(".android/sdk"), // Alternative Linux - ]; - - for candidate in &candidates { - if candidate.exists() { - return Ok(candidate.clone()); - } - searched.push(candidate.clone()); + return Some(sdk_path); } } - let searched_list = if searched.is_empty() { - " - (no candidates found)".to_string() - } else { - searched - .iter() - .map(|path| format!(" - {}", path.display())) - .collect::>() - .join("\n") - }; - - Err(BenchError::Build(format!( - "Android SDK not found.\n\n\ - Searched:\n{}\n\n\ - Set ANDROID_HOME or ANDROID_SDK_ROOT to your SDK path (for example: $HOME/Library/Android/sdk).\n\ - You can also install the SDK via Android Studio.", - searched_list - ))) + None } /// Ensures the Gradle wrapper (gradlew) exists in the Android project diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 1efd6e7..56e558d 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -291,6 +291,7 @@ pub fn generate_android_project( let target_dir = output_dir.join("android"); let library_name = project_slug.replace('-', "_"); let project_pascal = to_pascal_case(project_slug); + let package_name = format!("dev.world.{}", project_slug); let vars = vec![ TemplateVar { name: "PROJECT_NAME", @@ -306,7 +307,7 @@ pub fn generate_android_project( }, TemplateVar { name: "PACKAGE_NAME", - value: format!("dev.world.{}", project_slug), + value: package_name.clone(), }, TemplateVar { name: "UNIFFI_NAMESPACE", @@ -322,6 +323,73 @@ pub fn generate_android_project( }, ]; render_dir(&ANDROID_TEMPLATES, &target_dir, &vars)?; + + // Move Kotlin files to the correct package directory structure + // The package "dev.world.{project_slug}" maps to directory "dev/world/{project_slug}/" + move_kotlin_files_to_package_dir(&target_dir, &package_name)?; + + Ok(()) +} + +/// Moves Kotlin source files to the correct package directory structure +/// +/// Android requires source files to be in directories matching their package declaration. +/// For example, a file with `package dev.world.my_project` must be in +/// `app/src/main/java/dev/world/my_project/`. +/// +/// This function moves: +/// - MainActivity.kt from `app/src/main/java/` to `app/src/main/java/{package_path}/` +/// - MainActivityTest.kt from `app/src/androidTest/java/` to `app/src/androidTest/java/{package_path}/` +fn move_kotlin_files_to_package_dir(android_dir: &Path, package_name: &str) -> Result<(), BenchError> { + // Convert package name to directory path (e.g., "dev.world.my_project" -> "dev/world/my_project") + let package_path = package_name.replace('.', "/"); + + // Move main source files + let main_java_dir = android_dir.join("app/src/main/java"); + let main_package_dir = main_java_dir.join(&package_path); + move_kotlin_file(&main_java_dir, &main_package_dir, "MainActivity.kt")?; + + // Move test source files + let test_java_dir = android_dir.join("app/src/androidTest/java"); + let test_package_dir = test_java_dir.join(&package_path); + move_kotlin_file(&test_java_dir, &test_package_dir, "MainActivityTest.kt")?; + + Ok(()) +} + +/// Moves a single Kotlin file from source directory to package directory +fn move_kotlin_file(src_dir: &Path, dest_dir: &Path, filename: &str) -> Result<(), BenchError> { + let src_file = src_dir.join(filename); + if !src_file.exists() { + // File doesn't exist in source, nothing to move + return Ok(()); + } + + // Create the package directory if it doesn't exist + fs::create_dir_all(dest_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to create package directory {:?}: {}", + dest_dir, e + )) + })?; + + let dest_file = dest_dir.join(filename); + + // Move the file (copy + delete for cross-filesystem compatibility) + fs::copy(&src_file, &dest_file).map_err(|e| { + BenchError::Build(format!( + "Failed to copy {} to {:?}: {}", + filename, dest_file, e + )) + })?; + + fs::remove_file(&src_file).map_err(|e| { + BenchError::Build(format!( + "Failed to remove original file {:?}: {}", + src_file, e + )) + })?; + Ok(()) } @@ -354,7 +422,9 @@ pub fn generate_ios_project( .collect::>() .join(".") }; - let sanitized_project_slug = sanitize_bundle_id_component(project_slug); + // Use the actual app name (project_pascal, e.g., "BenchRunner") for the bundle ID suffix, + // not the crate name again. This prevents duplication like "dev.world.benchmobile.benchmobile" + // and produces the correct "dev.world.benchmobile.BenchRunner" let vars = vec![ TemplateVar { name: "DEFAULT_FUNCTION", @@ -370,7 +440,7 @@ pub fn generate_ios_project( }, TemplateVar { name: "BUNDLE_ID", - value: format!("{}.{}", sanitized_bundle_prefix, sanitized_project_slug), + value: format!("{}.{}", sanitized_bundle_prefix, project_pascal), }, TemplateVar { name: "LIBRARY_NAME", @@ -958,6 +1028,32 @@ mod tests { "strings.xml should contain app name with Benchmark" ); + // Verify Kotlin files are in the correct package directory structure + // For package "dev.world.my-bench-project", files should be in "dev/world/my-bench-project/" + let main_activity_path = android_dir.join("app/src/main/java/dev/world/my-bench-project/MainActivity.kt"); + assert!( + main_activity_path.exists(), + "MainActivity.kt should be in package directory: {:?}", + main_activity_path + ); + + let test_activity_path = android_dir.join("app/src/androidTest/java/dev/world/my-bench-project/MainActivityTest.kt"); + assert!( + test_activity_path.exists(), + "MainActivityTest.kt should be in package directory: {:?}", + test_activity_path + ); + + // Verify the files are NOT in the root java directory + assert!( + !android_dir.join("app/src/main/java/MainActivity.kt").exists(), + "MainActivity.kt should not be in root java directory" + ); + assert!( + !android_dir.join("app/src/androidTest/java/MainActivityTest.kt").exists(), + "MainActivityTest.kt should not be in root java directory" + ); + // Cleanup fs::remove_dir_all(&temp_dir).ok(); } @@ -1100,4 +1196,49 @@ pub fn public_bench() { // Complex case assert_eq!(sanitize_bundle_id_component("My-Complex_Project-123"), "mycomplexproject123"); } + + #[test] + fn test_generate_ios_project_bundle_id_not_duplicated() { + let temp_dir = env::temp_dir().join("mobench-sdk-ios-bundle-test"); + // Clean up any previous test run + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + // Use a crate name that would previously cause duplication + let crate_name = "bench-mobile"; + let bundle_prefix = "dev.world.benchmobile"; + let project_pascal = "BenchRunner"; + + let result = generate_ios_project( + &temp_dir, + crate_name, + project_pascal, + bundle_prefix, + "bench_mobile::test_func", + ); + assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err()); + + // Verify project.yml was created + let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml"); + assert!(project_yml_path.exists(), "project.yml should exist"); + + // Read and verify the bundle ID is correct (not duplicated) + let project_yml = fs::read_to_string(&project_yml_path).unwrap(); + + // The bundle ID should be "dev.world.benchmobile.BenchRunner" + // NOT "dev.world.benchmobile.benchmobile" + assert!( + project_yml.contains("dev.world.benchmobile.BenchRunner"), + "Bundle ID should be 'dev.world.benchmobile.BenchRunner', got:\n{}", + project_yml + ); + assert!( + !project_yml.contains("dev.world.benchmobile.benchmobile"), + "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}", + project_yml + ); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } } diff --git a/crates/mobench-sdk/templates/android/app/build.gradle b/crates/mobench-sdk/templates/android/app/build.gradle index 7180e74..8296a5f 100644 --- a/crates/mobench-sdk/templates/android/app/build.gradle +++ b/crates/mobench-sdk/templates/android/app/build.gradle @@ -18,6 +18,8 @@ android { versionCode 1 versionName "0.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) + testBuildType "release" } buildTypes { diff --git a/mobench-0.1.10-dx-report.md b/mobench-0.1.10-dx-report.md deleted file mode 100644 index 5e34e91..0000000 --- a/mobench-0.1.10-dx-report.md +++ /dev/null @@ -1,242 +0,0 @@ -# mobench 0.1.10 DX (Developer Experience) Report - -**Date:** 2026-01-19 -**Tested Version:** mobench-sdk 0.1.10, mobench CLI 0.1.10 -**Platform:** macOS Darwin 25.1.0 (arm64) -**Previous Version Tested:** 0.1.9 - -## Executive Summary - -mobench 0.1.10 represents a **major improvement** over 0.1.9, fixing all 12 critical template placeholder bugs identified in the previous version. Both Android and iOS builds now complete successfully without manual intervention. - -**Build Status:** -- Android: APK built successfully (133MB) -- iOS: xcframework and app built successfully with universal simulator support - -**Remaining Issues:** 2 critical, 4 high, 5 medium severity - ---- - -## Improvements from 0.1.9 to 0.1.10 - -### Fixed Issues (12 total from 0.1.9) - -| # | Issue | Status in 0.1.10 | -|---|-------|------------------| -| 1 | `{{PACKAGE_NAME}}` not replaced | ✅ FIXED - Uses `dev.world.bench_mobile` | -| 2 | `{{LIBRARY_NAME}}` not replaced | ✅ FIXED - Uses `libbench_mobile.so` | -| 3 | `{{PROJECT_NAME}}` not replaced | ✅ FIXED - Uses `bench_mobile-android` | -| 4 | `{{PROJECT_NAME_PASCAL}}` not replaced | ✅ FIXED - Theme uses `Theme.BenchMobile` | -| 5 | `{{APP_NAME}}` not replaced | ✅ FIXED - Uses "BenchMobile Benchmark" | -| 6 | Missing `gradle.properties` | ✅ FIXED - Now generated with AndroidX settings | -| 7 | Missing Gradle wrapper | ✅ FIXED - Now auto-generated | -| 8 | Invalid AGP version 8.13.2 | ✅ FIXED - Uses valid 8.2.2 | -| 9 | Package name mismatch | ✅ FIXED - Consistent naming | -| 10 | Missing x86_64 iOS simulator | ✅ FIXED - Universal binary created | -| 11 | Path handling bug | ✅ FIXED - Proper path resolution | -| 12 | Default benchmark function mismatch | ✅ FIXED - Uses actual benchmark name | - ---- - -## New/Remaining Issues in 0.1.10 - -### Critical Issues (2) - -#### Issue 1: APK Filename Mismatch (NEW) -**Severity:** CRITICAL -**Type:** Silent Failure - -The Android build creates `app-release-unsigned.apk` but mobench expects `app-release.apk`, causing a false build failure message. - -**Actual output:** -``` -target/mobench/android/app/build/outputs/apk/release/app-release-unsigned.apk (133MB) -``` - -**mobench error:** -``` -build error: APK not found at expected location: .../app-release.apk -``` - -**Impact:** Build succeeds but mobench reports failure. APK exists and is usable. - -**Fix Required:** Either add signing config to produce `app-release.apk`, or update mobench to check for `app-release-unsigned.apk` fallback. - ---- - -#### Issue 2: iOS Bundle Identifier Contains Invalid Characters -**Severity:** CRITICAL -**File:** `target/mobench/ios/BenchRunner/BenchRunner.xcodeproj/project.pbxproj` - -Bundle identifier `dev.world.bench-mobile.bench_mobile` contains both hyphens and underscores. - -**Impact:** -- App Store submission will be rejected -- Code signing issues on physical devices -- Xcode warning: "invalid character in Bundle Identifier" - -**Fix Required:** Use `dev.world.benchmobile.benchmobile` (no hyphens or underscores). - ---- - -### High Severity Issues (4) - -#### Issue 3: Missing ProGuard Configuration -**File:** `target/mobench/android/app/proguard-rules.pro` (missing) - -The `build.gradle` references `proguard-rules.pro` but the file doesn't exist. Builds fail if ProGuard is enabled. - -**Recommended content:** -```proguard --keep class com.sun.jna.** { *; } --keep class * implements com.sun.jna.** { *; } --keep class uniffi.bench_mobile.** { *; } --keep class dev.world.bench_mobile.** { *; } -``` - ---- - -#### Issue 4: Silent Config Loading Failures (iOS) -**File:** `target/mobench/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift` (lines 18-29) - -`BenchParams.fromBundle()` silently returns `nil` on JSON parse errors, falling back to defaults without logging. - ---- - -#### Issue 5: Silent Config Loading Failures (Android) -**File:** `target/mobench/android/app/src/main/java/MainActivity.kt` (lines 147-164) - -`optString`/`optInt` methods silently use defaults for missing/mistyped JSON keys. - ---- - -#### Issue 6: Machine-Specific Path in local.properties -**File:** `target/mobench/android/local.properties` - -Contains hardcoded developer-specific SDK path that won't work on other machines. - ---- - -### Medium Severity Issues (5) - -| # | Issue | Location | -|---|-------|----------| -| 7 | Bundle ID inconsistency between platforms | Android: `dev.world.bench_mobile`, iOS: `dev.world.bench-mobile` | -| 8 | Version string format mismatch | Android: "0.1", iOS: "0.1.0" | -| 9 | Deployment target gap | Android minSdk 24 (2016), iOS 15.0 (2021) | -| 10 | UniFFI dispose errors swallowed | `bench_mobile.kt` line 895-905 | -| 11 | Incomplete .gitignore files | Missing native artifact patterns | - ---- - -## Build Output Summary - -### Android -``` -Location: target/mobench/android/ -APK: app/build/outputs/apk/release/app-release-unsigned.apk -Size: 133,561,304 bytes (127 MB) -Architectures: arm64-v8a, armeabi-v7a, x86_64 -``` - -### iOS -``` -Location: target/mobench/ios/ -Framework: bench_mobile.xcframework/ -App: BenchRunner.app (built in DerivedData) -Architectures: ios-arm64, ios-arm64_x86_64-simulator -Size: 267 MB total -``` - ---- - -## Comparison: 0.1.9 vs 0.1.10 - -| Metric | 0.1.9 | 0.1.10 | -|--------|-------|--------| -| **Critical Bugs** | 12 | 2 | -| **Manual Fixes Required** | 5 | 0 | -| **Android Build** | Fails without fixes | Builds (reports false failure) | -| **iOS Build** | Builds (arm64 only) | Builds (universal) | -| **x86_64 Simulator** | ❌ Missing | ✅ Included | -| **Gradle Wrapper** | ❌ Missing | ✅ Generated | -| **gradle.properties** | ❌ Missing | ✅ Generated | -| **Template Placeholders** | 5 unreplaced | All replaced | -| **Default Benchmark** | Wrong name | Correct name | - ---- - -## Test Commands Used - -```bash -# Upgrade mobench CLI -cargo install mobench --version 0.1.10 --force - -# Update SDK dependency -# In bench-mobile/Cargo.toml: mobench-sdk = "0.1.10" -cargo update -p mobench-sdk - -# Clean and build -rm -rf target/mobench -mobench build --target android --release --verbose -mobench build --target ios --release --verbose - -# Verify Android APK (despite mobench error) -ls -la target/mobench/android/app/build/outputs/apk/release/ - -# Build iOS app with Xcode -cd target/mobench/ios/BenchRunner -xcodebuild -scheme BenchRunner -configuration Release \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build -``` - ---- - -## Recommended Priority Fixes for 0.1.11 - -### P0 (Critical - Fix Immediately) - -1. **APK filename detection** - Check for both `app-release.apk` and `app-release-unsigned.apk`, or parse `output-metadata.json` - -2. **Bundle identifier fix** - Use consistent naming without hyphens/underscores: `dev.world.benchmobile.benchmobile` - -### P1 (High - Fix Before Release) - -3. **Add proguard-rules.pro** to Android templates - -4. **Log config loading errors** instead of silently falling back to defaults - -5. **Don't generate local.properties** with hardcoded SDK paths - -### P2 (Medium - Nice to Have) - -6. Standardize bundle ID across platforms -7. Standardize version string format (semver) -8. Improve .gitignore completeness -9. Add logging for UniFFI cleanup errors - ---- - -## Agent Analysis Summary - -Three debugging agents were deployed in parallel: - -1. **Code Reviewer Agent** - Found APK naming, ProGuard, and local.properties issues -2. **Silent Failure Hunter Agent** - Found config loading fallbacks and bundle ID issues -3. **Explorer Agent** - Found cross-platform inconsistencies and missing files - -All agents converged on the same critical issues, confirming their validity. - ---- - -## Conclusion - -**mobench 0.1.10 is a significant improvement** over 0.1.9. All template placeholder bugs are fixed, and both platforms build successfully without manual intervention. - -The remaining issues are primarily: -1. APK filename detection (false failure report) -2. iOS bundle identifier format - -**Recommendation:** 0.1.10 is usable for development. The APK is built correctly despite the error message. Fix the bundle identifier before App Store submission. - -**Overall Score:** 8.5/10 (up from 4/10 for 0.1.9) diff --git a/mobench-0.1.11-dx-report.md b/mobench-0.1.11-dx-report.md new file mode 100644 index 0000000..1ea5728 --- /dev/null +++ b/mobench-0.1.11-dx-report.md @@ -0,0 +1,206 @@ +# mobench 0.1.11 DX (Developer Experience) Report + +**Date:** 2026-01-19 +**Tested Version:** mobench-sdk 0.1.11, mobench CLI 0.1.11 +**Platform:** macOS Darwin 25.1.0 (arm64) +**Previous Versions Tested:** 0.1.9, 0.1.10 + +## Executive Summary + +mobench 0.1.11 fixes 3 major issues from 0.1.10 but introduces 1 new critical bug. The APK build succeeds, but the new test APK build step fails because it uses a non-existent Gradle task. + +**Build Status:** +- Android APK: ✅ Built successfully (133MB) +- Android Test APK: ❌ Failed (`assembleReleaseAndroidTest` task not found) +- iOS: ✅ Built successfully + +--- + +## Version Comparison Summary + +| Issue | 0.1.9 | 0.1.10 | 0.1.11 | +|-------|-------|--------|--------| +| Template placeholders | ❌ 5 unfilled | ✅ Fixed | ✅ Fixed | +| Gradle wrapper | ❌ Missing | ✅ Generated | ✅ Generated | +| gradle.properties | ❌ Missing | ✅ Generated | ✅ Generated | +| AGP version | ❌ Invalid | ✅ Fixed | ✅ Fixed | +| x86_64 iOS simulator | ❌ Missing | ✅ Included | ✅ Included | +| APK filename detection | N/A | ❌ Wrong name | ✅ Parses metadata | +| iOS bundle ID chars | N/A | ❌ Invalid | ✅ Fixed | +| proguard-rules.pro | ❌ Missing | ❌ Missing | ✅ Generated | +| Test APK build | N/A | N/A | ❌ **NEW BUG** | + +--- + +## Improvements in 0.1.11 (Fixed from 0.1.10) + +### 1. APK Filename Detection - FIXED +**Previous:** Expected `app-release.apk`, build reported failure +**Now:** Parses `output-metadata.json` to find correct filename `app-release-unsigned.apk` + +### 2. iOS Bundle Identifier - FIXED +**Previous:** `dev.world.bench-mobile.bench_mobile` (invalid chars) +**Now:** `dev.world.benchmobile.benchmobile` (valid) + +### 3. ProGuard Rules File - FIXED +**Previous:** File missing, ProGuard would fail if enabled +**Now:** `proguard-rules.pro` generated with proper JNA/UniFFI keep rules + +### 4. iOS Config Logging - IMPROVED +**Previous:** Silent fallback to defaults +**Now:** Logs warnings when config file missing or invalid + +--- + +## New Bug in 0.1.11 + +### CRITICAL: `assembleReleaseAndroidTest` Task Not Found + +**Symptom:** +``` +Task 'assembleReleaseAndroidTest' not found in root project 'bench_mobile-android' +``` + +**Root Cause:** +mobench 0.1.11 now attempts to build a test APK after the main APK. It uses `assembleReleaseAndroidTest` for release builds, but this Gradle task doesn't exist unless explicitly configured. + +**Why:** Android Gradle Plugin only creates test APK tasks for the debug build type by default. The release test task requires `testBuildType "release"` in `build.gradle`. + +**Impact:** +- Main APK builds successfully (133MB) +- mobench reports overall failure due to test APK step +- BrowserStack Espresso tests cannot run against release builds + +**Fix Required in mobench-sdk:** +Either: +1. Always use `assembleDebugAndroidTest` (test APKs are debug anyway) +2. Add `testBuildType "release"` to generated `build.gradle` +3. Make test APK build optional + +**Workaround:** +Add to `app/build.gradle`: +```gradle +android { + defaultConfig { + testBuildType "release" + } +} +``` + +--- + +## Remaining Issues (Not Fixed) + +### HIGH: Hardcoded local.properties +**File:** `target/mobench/android/local.properties` +**Issue:** Contains machine-specific SDK path +```properties +sdk.dir=/Users/dcbuilder/Library/Android/sdk +``` +**Impact:** Breaks builds on other machines + +### MEDIUM: Bundle ID Duplication (iOS) +**Current:** `dev.world.benchmobile.benchmobile` +**Expected:** `dev.world.benchmobile.BenchRunner` +**Impact:** Cosmetic, doesn't break builds + +### MEDIUM: Test File Wrong Directory (Android) +**Current:** `app/src/androidTest/java/MainActivityTest.kt` +**Expected:** `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` +**Impact:** Test may not compile correctly + +### MEDIUM: Silent Error Fallbacks +Both Android and iOS catch exceptions broadly and lose error type information. + +--- + +## Build Outputs + +### Android +``` +Location: target/mobench/android/ +APK: app/build/outputs/apk/release/app-release-unsigned.apk +Size: 133,561,304 bytes (127 MB) +Status: ✅ Built successfully +``` + +### iOS +``` +Location: target/mobench/ios/ +Framework: bench_mobile.xcframework/ +Architectures: ios-arm64, ios-arm64_x86_64-simulator +Status: ✅ Built successfully +``` + +--- + +## Agent Analysis Summary + +Three debugging agents were deployed: + +1. **Android Reviewer** - Found test task configuration issue, confirmed proguard-rules.pro fix +2. **iOS Reviewer** - Confirmed bundle ID fix, found duplication issue +3. **Silent Failure Hunter** - Found error handling patterns, test directory structure issue + +All agents identified the `assembleReleaseAndroidTest` as the critical new bug. + +--- + +## Test Commands + +```bash +# Upgrade +cargo install mobench --version 0.1.11 --force + +# Update SDK +# In bench-mobile/Cargo.toml: mobench-sdk = "0.1.11" +cargo update -p mobench-sdk + +# Clean and build +rm -rf target/mobench +mobench build --target android --release --verbose # Fails at test APK +mobench build --target ios --release --verbose # Succeeds + +# Verify APK exists despite error +ls -la target/mobench/android/app/build/outputs/apk/release/ + +# Workaround: Add testBuildType to build.gradle and retry +echo 'android { defaultConfig { testBuildType "release" } }' >> target/mobench/android/app/build.gradle +cd target/mobench/android && ./gradlew assembleReleaseAndroidTest +``` + +--- + +## Priority Fixes for 0.1.12 + +### P0 (Blocker) +1. **Fix test APK task** - Use `assembleDebugAndroidTest` or add `testBuildType` config + +### P1 (High) +2. **Don't generate local.properties** with hardcoded paths +3. **Fix test file directory** - Place in correct package directory + +### P2 (Medium) +4. Fix iOS bundle ID duplication +5. Improve error handling to preserve error types + +--- + +## Overall Score + +| Version | Score | Builds Without Fixes | +|---------|-------|---------------------| +| 0.1.9 | 4/10 | ❌ No | +| 0.1.10 | 8/10 | ✅ Yes (false failure) | +| 0.1.11 | 7/10 | ⚠️ Partial (APK yes, test APK no) | + +**Note:** 0.1.11 would be 9/10 if the test APK task issue is fixed. The other improvements (APK detection, proguard, iOS bundle ID) are significant. + +--- + +## Conclusion + +mobench 0.1.11 makes good progress on DX issues but introduces a regression with the test APK build. The main APK builds successfully and is usable. For production use, either: +1. Ignore the test APK failure if not using BrowserStack Espresso +2. Apply the workaround to add `testBuildType "release"` +3. Wait for 0.1.12 with the fix diff --git a/templates/android/app/build.gradle b/templates/android/app/build.gradle index 7180e74..8296a5f 100644 --- a/templates/android/app/build.gradle +++ b/templates/android/app/build.gradle @@ -18,6 +18,8 @@ android { versionCode 1 versionName "0.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) + testBuildType "release" } buildTypes { From 3102a8f2bab169a88ec99a25ccf6eed2349e503f Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 19 Jan 2026 22:05:35 +0100 Subject: [PATCH 045/196] Fix all DX issues from bug reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crate detection (P0): - Check current directory Cargo.toml before nested paths - Add read_package_name() helper for parsing Cargo.toml - Search order: current dir → bench-mobile/ → crates/{name}/ → {name}/ - Add 10 new tests for crate detection logic Error handling improvements: - Add Log.e() calls for Android exception handlers - Add print() statements for iOS config parsing failures - Log config sources (intent, bench_spec.json, default) - Show warnings when using fallback defaults Cross-platform consistency: - Standardize Android package names (remove underscores like iOS) - Both platforms now use dev.world.benchmobile format - Standardize version strings to 1.0.0 on both platforms - Add 3 new consistency tests Tests added: - test_find_crate_dir_current_directory_is_crate - test_find_crate_dir_nested_bench_mobile - test_find_crate_dir_crates_subdir - test_find_crate_dir_not_found - test_find_crate_dir_explicit_crate_path - test_read_package_name_standard - test_read_package_name_with_single_quotes - test_read_package_name_not_found - test_read_package_name_no_package_section - test_cross_platform_naming_consistency - test_cross_platform_version_consistency - test_bundle_id_prefix_consistency --- android/app/build.gradle | 2 +- crates/mobench-sdk/src/builders/android.rs | 185 ++++++++++++- crates/mobench-sdk/src/builders/common.rs | 132 ++++++++++ crates/mobench-sdk/src/builders/ios.rs | 189 +++++++++++++- crates/mobench-sdk/src/codegen.rs | 152 ++++++++++- .../templates/android/app/build.gradle | 2 +- .../src/main/java/MainActivity.kt.template | 45 +++- .../BenchRunner/BenchRunnerFFI.swift.template | 64 ++++- .../ios/BenchRunner/project.yml.template | 3 + ios/BenchRunner/BenchRunner/Info.plist | 2 +- mobench-0.1.11-dx-report.md | 206 --------------- mobench-bugs-summary.md | 208 +++++++++++++++ mobench-local-build-dx-report.md | 244 ++++++++++++++++++ 13 files changed, 1189 insertions(+), 245 deletions(-) delete mode 100644 mobench-0.1.11-dx-report.md create mode 100644 mobench-bugs-summary.md create mode 100644 mobench-local-build-dx-report.md diff --git a/android/app/build.gradle b/android/app/build.gradle index ff44149..669776d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -16,7 +16,7 @@ android { minSdk 24 targetSdk 34 versionCode 1 - versionName "0.1" + versionName "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) testBuildType "release" diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index 9fa2409..d5c4a69 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -132,9 +132,11 @@ impl AndroidBuilder { /// Sets the explicit crate directory /// - /// By default, the builder searches for the crate in: - /// - `{project_root}/bench-mobile/` - /// - `{project_root}/crates/{crate_name}/` + /// By default, the builder searches for the crate in this order: + /// 1. `{project_root}/Cargo.toml` - if it exists and has `[package] name` matching `crate_name` + /// 2. `{project_root}/bench-mobile/` - SDK-generated projects + /// 3. `{project_root}/crates/{crate_name}/` - workspace structure + /// 4. `{project_root}/{crate_name}/` - simple nested structure /// /// Use this to override auto-detection and point directly to the crate. pub fn crate_dir(mut self, dir: impl Into) -> Self { @@ -305,7 +307,13 @@ impl AndroidBuilder { Ok(()) } - /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) + /// Finds the benchmark crate directory. + /// + /// Search order: + /// 1. Explicit `crate_dir` if set via `.crate_dir()` builder method + /// 2. Current directory (`project_root`) if its Cargo.toml has a matching package name + /// 3. `{project_root}/bench-mobile/` (SDK projects) + /// 4. `{project_root}/crates/{crate_name}/` (repository structure) fn find_crate_dir(&self) -> Result { // If explicit crate_dir was provided, use it if let Some(ref dir) = self.crate_dir { @@ -319,7 +327,18 @@ impl AndroidBuilder { ))); } - // Try bench-mobile/ first (SDK projects) + // Check if the current directory (project_root) IS the crate + // This handles the case where user runs `cargo mobench build` from within the crate directory + let root_cargo_toml = self.project_root.join("Cargo.toml"); + if root_cargo_toml.exists() { + if let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) { + if pkg_name == self.crate_name { + return Ok(self.project_root.clone()); + } + } + } + + // Try bench-mobile/ (SDK projects) let bench_mobile_dir = self.project_root.join("bench-mobile"); if bench_mobile_dir.exists() { return Ok(bench_mobile_dir); @@ -331,16 +350,27 @@ impl AndroidBuilder { return Ok(crates_dir); } + // Also try {crate_name}/ in project root (common pattern) + let named_dir = self.project_root.join(&self.crate_name); + if named_dir.exists() { + return Ok(named_dir); + } + + let root_manifest = root_cargo_toml; let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml"); let crates_manifest = crates_dir.join("Cargo.toml"); + let named_manifest = named_dir.join("Cargo.toml"); Err(BenchError::Build(format!( "Benchmark crate '{}' not found.\n\n\ Searched locations:\n\ + - {} (checked [package] name)\n\ + - {}\n\ - {}\n\ - {}\n\n\ To fix this:\n\ - 1. Create a bench-mobile/ directory with your benchmark crate, or\n\ - 2. Use --crate-path to specify the benchmark crate location:\n\ + 1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\ + 2. Create a bench-mobile/ directory with your benchmark crate, or\n\ + 3. Use --crate-path to specify the benchmark crate location:\n\ cargo mobench build --target android --crate-path ./my-benchmarks\n\n\ Common issues:\n\ - Typo in crate name (check Cargo.toml [package] name)\n\ @@ -348,8 +378,11 @@ impl AndroidBuilder { - Missing Cargo.toml in the crate directory\n\n\ Run 'cargo mobench init --help' to generate a new benchmark project.", self.crate_name, + root_manifest.display(), bench_mobile_manifest.display(), - crates_manifest.display() + crates_manifest.display(), + named_manifest.display(), + self.crate_name, ))) } @@ -1174,4 +1207,140 @@ mod tests { let result = builder.parse_output_metadata(metadata); assert_eq!(result, None); } + + #[test] + fn test_find_crate_dir_current_directory_is_crate() { + // Test case 1: Current directory IS the crate with matching package name + let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-current"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Create Cargo.toml with matching package name + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[package] +name = "bench-mobile" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = AndroidBuilder::new(&temp_dir, "bench-mobile"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in current directory"); + assert_eq!(result.unwrap(), temp_dir); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_nested_bench_mobile() { + // Test case 2: Crate is in bench-mobile/ subdirectory + let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-nested"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap(); + + // Create parent Cargo.toml (workspace or different crate) + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[workspace] +members = ["bench-mobile"] +"#, + ) + .unwrap(); + + // Create bench-mobile/Cargo.toml + std::fs::write( + temp_dir.join("bench-mobile/Cargo.toml"), + r#"[package] +name = "bench-mobile" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = AndroidBuilder::new(&temp_dir, "bench-mobile"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in bench-mobile/ directory"); + assert_eq!(result.unwrap(), temp_dir.join("bench-mobile")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_crates_subdir() { + // Test case 3: Crate is in crates/{name}/ subdirectory + let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-crates"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap(); + + // Create workspace Cargo.toml + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[workspace] +members = ["crates/*"] +"#, + ) + .unwrap(); + + // Create crates/my-bench/Cargo.toml + std::fs::write( + temp_dir.join("crates/my-bench/Cargo.toml"), + r#"[package] +name = "my-bench" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = AndroidBuilder::new(&temp_dir, "my-bench"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in crates/ directory"); + assert_eq!(result.unwrap(), temp_dir.join("crates/my-bench")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_not_found() { + // Test case 4: Crate doesn't exist anywhere + let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-notfound"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Create Cargo.toml with DIFFERENT package name + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[package] +name = "some-other-crate" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = AndroidBuilder::new(&temp_dir, "nonexistent-crate"); + let result = builder.find_crate_dir(); + assert!(result.is_err(), "Should fail to find nonexistent crate"); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found")); + assert!(err_msg.contains("Searched locations")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_explicit_crate_path() { + // Test case 5: Explicit crate_dir overrides auto-detection + let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-explicit"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap(); + + let builder = AndroidBuilder::new(&temp_dir, "any-name") + .crate_dir(temp_dir.join("custom-location")); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should use explicit crate_dir"); + assert_eq!(result.unwrap(), temp_dir.join("custom-location")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } } diff --git a/crates/mobench-sdk/src/builders/common.rs b/crates/mobench-sdk/src/builders/common.rs index 5b19309..d64efad 100644 --- a/crates/mobench-sdk/src/builders/common.rs +++ b/crates/mobench-sdk/src/builders/common.rs @@ -246,6 +246,64 @@ pub fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError Ok(()) } +/// Reads the package name from a Cargo.toml file. +/// +/// This function parses the `[package]` section of a Cargo.toml and extracts +/// the `name` field. It uses simple string parsing to avoid adding toml +/// dependencies. +/// +/// # Arguments +/// * `cargo_toml_path` - Path to the Cargo.toml file +/// +/// # Returns +/// `Some(name)` if the package name is found, `None` otherwise. +/// +/// # Example +/// ```ignore +/// let name = read_package_name(Path::new("/path/to/Cargo.toml")); +/// if let Some(name) = name { +/// println!("Package name: {}", name); +/// } +/// ``` +pub fn read_package_name(cargo_toml_path: &Path) -> Option { + let content = std::fs::read_to_string(cargo_toml_path).ok()?; + + // Find [package] section + let package_start = content.find("[package]")?; + let package_section = &content[package_start..]; + + // Find the end of the package section (next section or end of file) + let section_end = package_section[1..] + .find("\n[") + .map(|i| i + 1) + .unwrap_or(package_section.len()); + let package_section = &package_section[..section_end]; + + // Find name = "..." or name = '...' + for line in package_section.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("name") { + // Parse: name = "value" or name = 'value' + if let Some(eq_pos) = trimmed.find('=') { + let value_part = trimmed[eq_pos + 1..].trim(); + // Extract string value (handle both " and ') + let (quote_char, start) = if value_part.starts_with('"') { + ('"', 1) + } else if value_part.starts_with('\'') { + ('\'', 1) + } else { + continue; + }; + if let Some(end) = value_part[start..].find(quote_char) { + return Some(value_part[start..start + end].to_string()); + } + } + } + } + + None +} + #[cfg(test)] mod tests { use super::*; @@ -277,4 +335,78 @@ mod tests { let msg = format!("{}", err); assert!(msg.contains("Failed to start")); } + + #[test] + fn test_read_package_name_standard() { + let temp_dir = std::env::temp_dir().join("mobench-test-read-package"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let cargo_toml = temp_dir.join("Cargo.toml"); + std::fs::write( + &cargo_toml, + r#"[package] +name = "my-awesome-crate" +version = "0.1.0" +edition = "2021" + +[dependencies] +"#, + ) + .unwrap(); + + let result = read_package_name(&cargo_toml); + assert_eq!(result, Some("my-awesome-crate".to_string())); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_read_package_name_with_single_quotes() { + let temp_dir = std::env::temp_dir().join("mobench-test-read-package-sq"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let cargo_toml = temp_dir.join("Cargo.toml"); + std::fs::write( + &cargo_toml, + r#"[package] +name = 'single-quoted-crate' +version = "0.1.0" +"#, + ) + .unwrap(); + + let result = read_package_name(&cargo_toml); + assert_eq!(result, Some("single-quoted-crate".to_string())); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_read_package_name_not_found() { + let result = read_package_name(Path::new("/nonexistent/Cargo.toml")); + assert_eq!(result, None); + } + + #[test] + fn test_read_package_name_no_package_section() { + let temp_dir = std::env::temp_dir().join("mobench-test-read-package-no-pkg"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let cargo_toml = temp_dir.join("Cargo.toml"); + std::fs::write( + &cargo_toml, + r#"[workspace] +members = ["crates/*"] +"#, + ) + .unwrap(); + + let result = read_package_name(&cargo_toml); + assert_eq!(result, None); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } } diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 874b380..00b5fd8 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -154,9 +154,11 @@ impl IosBuilder { /// Sets the explicit crate directory /// - /// By default, the builder searches for the crate in: - /// - `{project_root}/bench-mobile/` - /// - `{project_root}/crates/{crate_name}/` + /// By default, the builder searches for the crate in this order: + /// 1. `{project_root}/Cargo.toml` - if it exists and has `[package] name` matching `crate_name` + /// 2. `{project_root}/bench-mobile/` - SDK-generated projects + /// 3. `{project_root}/crates/{crate_name}/` - workspace structure + /// 4. `{project_root}/{crate_name}/` - simple nested structure /// /// Use this to override auto-detection and point directly to the crate. pub fn crate_dir(mut self, dir: impl Into) -> Self { @@ -372,7 +374,13 @@ impl IosBuilder { Ok(()) } - /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/) + /// Finds the benchmark crate directory. + /// + /// Search order: + /// 1. Explicit `crate_dir` if set via `.crate_dir()` builder method + /// 2. Current directory (`project_root`) if its Cargo.toml has a matching package name + /// 3. `{project_root}/bench-mobile/` (SDK projects) + /// 4. `{project_root}/crates/{crate_name}/` (repository structure) fn find_crate_dir(&self) -> Result { // If explicit crate_dir was provided, use it if let Some(ref dir) = self.crate_dir { @@ -386,7 +394,18 @@ impl IosBuilder { ))); } - // Try bench-mobile/ first (SDK projects) + // Check if the current directory (project_root) IS the crate + // This handles the case where user runs `cargo mobench build` from within the crate directory + let root_cargo_toml = self.project_root.join("Cargo.toml"); + if root_cargo_toml.exists() { + if let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) { + if pkg_name == self.crate_name { + return Ok(self.project_root.clone()); + } + } + } + + // Try bench-mobile/ (SDK projects) let bench_mobile_dir = self.project_root.join("bench-mobile"); if bench_mobile_dir.exists() { return Ok(bench_mobile_dir); @@ -398,16 +417,27 @@ impl IosBuilder { return Ok(crates_dir); } + // Also try {crate_name}/ in project root (common pattern) + let named_dir = self.project_root.join(&self.crate_name); + if named_dir.exists() { + return Ok(named_dir); + } + + let root_manifest = root_cargo_toml; let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml"); let crates_manifest = crates_dir.join("Cargo.toml"); + let named_manifest = named_dir.join("Cargo.toml"); Err(BenchError::Build(format!( "Benchmark crate '{}' not found.\n\n\ Searched locations:\n\ + - {} (checked [package] name)\n\ + - {}\n\ - {}\n\ - {}\n\n\ To fix this:\n\ - 1. Create a bench-mobile/ directory with your benchmark crate, or\n\ - 2. Use --crate-path to specify the benchmark crate location:\n\ + 1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\ + 2. Create a bench-mobile/ directory with your benchmark crate, or\n\ + 3. Use --crate-path to specify the benchmark crate location:\n\ cargo mobench build --target ios --crate-path ./my-benchmarks\n\n\ Common issues:\n\ - Typo in crate name (check Cargo.toml [package] name)\n\ @@ -415,8 +445,11 @@ impl IosBuilder { - Missing Cargo.toml in the crate directory\n\n\ Run 'cargo mobench init --help' to generate a new benchmark project.", self.crate_name, + root_manifest.display(), bench_mobile_manifest.display(), - crates_manifest.display() + crates_manifest.display(), + named_manifest.display(), + self.crate_name, ))) } @@ -1762,4 +1795,144 @@ mod tests { IosBuilder::new("/tmp/test-project", "test-bench-mobile").output_dir("/custom/output"); assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); } + + #[test] + fn test_find_crate_dir_current_directory_is_crate() { + // Test case 1: Current directory IS the crate with matching package name + let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-current"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Create Cargo.toml with matching package name + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[package] +name = "bench-mobile" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = IosBuilder::new(&temp_dir, "bench-mobile"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in current directory"); + // Note: IosBuilder canonicalizes paths, so compare canonical forms + let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone()); + assert_eq!(result.unwrap(), expected); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_nested_bench_mobile() { + // Test case 2: Crate is in bench-mobile/ subdirectory + let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-nested"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap(); + + // Create parent Cargo.toml (workspace or different crate) + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[workspace] +members = ["bench-mobile"] +"#, + ) + .unwrap(); + + // Create bench-mobile/Cargo.toml + std::fs::write( + temp_dir.join("bench-mobile/Cargo.toml"), + r#"[package] +name = "bench-mobile" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = IosBuilder::new(&temp_dir, "bench-mobile"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in bench-mobile/ directory"); + let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone()).join("bench-mobile"); + assert_eq!(result.unwrap(), expected); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_crates_subdir() { + // Test case 3: Crate is in crates/{name}/ subdirectory + let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-crates"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap(); + + // Create workspace Cargo.toml + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[workspace] +members = ["crates/*"] +"#, + ) + .unwrap(); + + // Create crates/my-bench/Cargo.toml + std::fs::write( + temp_dir.join("crates/my-bench/Cargo.toml"), + r#"[package] +name = "my-bench" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = IosBuilder::new(&temp_dir, "my-bench"); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should find crate in crates/ directory"); + let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone()).join("crates/my-bench"); + assert_eq!(result.unwrap(), expected); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_not_found() { + // Test case 4: Crate doesn't exist anywhere + let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-notfound"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Create Cargo.toml with DIFFERENT package name + std::fs::write( + temp_dir.join("Cargo.toml"), + r#"[package] +name = "some-other-crate" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = IosBuilder::new(&temp_dir, "nonexistent-crate"); + let result = builder.find_crate_dir(); + assert!(result.is_err(), "Should fail to find nonexistent crate"); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found")); + assert!(err_msg.contains("Searched locations")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_find_crate_dir_explicit_crate_path() { + // Test case 5: Explicit crate_dir overrides auto-detection + let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-explicit"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap(); + + let builder = IosBuilder::new(&temp_dir, "any-name") + .crate_dir(temp_dir.join("custom-location")); + let result = builder.find_crate_dir(); + assert!(result.is_ok(), "Should use explicit crate_dir"); + assert_eq!(result.unwrap(), temp_dir.join("custom-location")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } } diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 56e558d..1659049 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -291,7 +291,10 @@ pub fn generate_android_project( let target_dir = output_dir.join("android"); let library_name = project_slug.replace('-', "_"); let project_pascal = to_pascal_case(project_slug); - let package_name = format!("dev.world.{}", project_slug); + // Use sanitized bundle ID component (alphanumeric only) for consistency with iOS + // This ensures both platforms use the same naming convention: "benchmobile" not "bench-mobile" + let package_id_component = sanitize_bundle_id_component(project_slug); + let package_name = format!("dev.world.{}", package_id_component); let vars = vec![ TemplateVar { name: "PROJECT_NAME", @@ -1011,9 +1014,10 @@ mod tests { ); let build_gradle = fs::read_to_string(android_dir.join("app/build.gradle")).unwrap(); + // Package name should be sanitized (no hyphens/underscores) for consistency with iOS assert!( - build_gradle.contains("dev.world.my-bench-project") || build_gradle.contains("dev.world.my_bench_project"), - "build.gradle should contain package name" + build_gradle.contains("dev.world.mybenchproject"), + "build.gradle should contain sanitized package name 'dev.world.mybenchproject'" ); let manifest = fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap(); @@ -1029,15 +1033,15 @@ mod tests { ); // Verify Kotlin files are in the correct package directory structure - // For package "dev.world.my-bench-project", files should be in "dev/world/my-bench-project/" - let main_activity_path = android_dir.join("app/src/main/java/dev/world/my-bench-project/MainActivity.kt"); + // For package "dev.world.mybenchproject", files should be in "dev/world/mybenchproject/" + let main_activity_path = android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt"); assert!( main_activity_path.exists(), "MainActivity.kt should be in package directory: {:?}", main_activity_path ); - let test_activity_path = android_dir.join("app/src/androidTest/java/dev/world/my-bench-project/MainActivityTest.kt"); + let test_activity_path = android_dir.join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt"); assert!( test_activity_path.exists(), "MainActivityTest.kt should be in package directory: {:?}", @@ -1241,4 +1245,140 @@ pub fn public_bench() { // Cleanup fs::remove_dir_all(&temp_dir).ok(); } + + #[test] + fn test_cross_platform_naming_consistency() { + // Test that Android and iOS use the same naming convention for package/bundle IDs + let temp_dir = env::temp_dir().join("mobench-sdk-naming-consistency-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let project_name = "bench-mobile"; + + // Generate Android project + let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func"); + assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err()); + + // Generate iOS project (mimicking how ensure_ios_project does it) + let bundle_id_component = sanitize_bundle_id_component(project_name); + let bundle_prefix = format!("dev.world.{}", bundle_id_component); + let result = generate_ios_project( + &temp_dir, + &project_name.replace('-', "_"), + "BenchRunner", + &bundle_prefix, + "bench_mobile::test_func", + ); + assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err()); + + // Read Android build.gradle to extract package name + let android_build_gradle = fs::read_to_string( + temp_dir.join("android/app/build.gradle") + ).expect("Failed to read Android build.gradle"); + + // Read iOS project.yml to extract bundle ID prefix + let ios_project_yml = fs::read_to_string( + temp_dir.join("ios/BenchRunner/project.yml") + ).expect("Failed to read iOS project.yml"); + + // Both should use "benchmobile" (without hyphens or underscores) + // Android: namespace = "dev.world.benchmobile" + // iOS: bundleIdPrefix: dev.world.benchmobile + assert!( + android_build_gradle.contains("dev.world.benchmobile"), + "Android package should be 'dev.world.benchmobile', got:\n{}", + android_build_gradle + ); + assert!( + ios_project_yml.contains("dev.world.benchmobile"), + "iOS bundle prefix should contain 'dev.world.benchmobile', got:\n{}", + ios_project_yml + ); + + // Ensure Android doesn't use hyphens or underscores in the package ID component + assert!( + !android_build_gradle.contains("dev.world.bench-mobile"), + "Android package should NOT contain hyphens" + ); + assert!( + !android_build_gradle.contains("dev.world.bench_mobile"), + "Android package should NOT contain underscores" + ); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_cross_platform_version_consistency() { + // Test that Android and iOS use the same version strings + let temp_dir = env::temp_dir().join("mobench-sdk-version-consistency-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let project_name = "test-project"; + + // Generate Android project + let result = generate_android_project(&temp_dir, project_name, "test_project::test_func"); + assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err()); + + // Generate iOS project + let bundle_id_component = sanitize_bundle_id_component(project_name); + let bundle_prefix = format!("dev.world.{}", bundle_id_component); + let result = generate_ios_project( + &temp_dir, + &project_name.replace('-', "_"), + "BenchRunner", + &bundle_prefix, + "test_project::test_func", + ); + assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err()); + + // Read Android build.gradle + let android_build_gradle = fs::read_to_string( + temp_dir.join("android/app/build.gradle") + ).expect("Failed to read Android build.gradle"); + + // Read iOS project.yml + let ios_project_yml = fs::read_to_string( + temp_dir.join("ios/BenchRunner/project.yml") + ).expect("Failed to read iOS project.yml"); + + // Both should use version "1.0.0" + assert!( + android_build_gradle.contains("versionName \"1.0.0\""), + "Android versionName should be '1.0.0', got:\n{}", + android_build_gradle + ); + assert!( + ios_project_yml.contains("CFBundleShortVersionString: \"1.0.0\""), + "iOS CFBundleShortVersionString should be '1.0.0', got:\n{}", + ios_project_yml + ); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_bundle_id_prefix_consistency() { + // Test that the bundle ID prefix format is consistent across platforms + let test_cases = vec![ + ("my-project", "dev.world.myproject"), + ("bench_mobile", "dev.world.benchmobile"), + ("TestApp", "dev.world.testapp"), + ("app-with-many-dashes", "dev.world.appwithmanydashes"), + ("app_with_many_underscores", "dev.world.appwithmanyunderscores"), + ]; + + for (input, expected_prefix) in test_cases { + let sanitized = sanitize_bundle_id_component(input); + let full_prefix = format!("dev.world.{}", sanitized); + assert_eq!( + full_prefix, expected_prefix, + "For input '{}', expected '{}' but got '{}'", + input, expected_prefix, full_prefix + ); + } + } } diff --git a/crates/mobench-sdk/templates/android/app/build.gradle b/crates/mobench-sdk/templates/android/app/build.gradle index 8296a5f..81b14c4 100644 --- a/crates/mobench-sdk/templates/android/app/build.gradle +++ b/crates/mobench-sdk/templates/android/app/build.gradle @@ -16,7 +16,7 @@ android { minSdk 24 targetSdk 34 versionCode 1 - versionName "0.1" + versionName "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) testBuildType "release" diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index c334df6..c83f8b8 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -55,8 +55,10 @@ class MainActivity : AppCompatActivity() { formatBenchReport(report) } catch (e: BenchException) { // Generic handler for all benchmark errors (InvalidIterations, UnknownFunction, etc.) + android.util.Log.e("BenchRunner", "Benchmark error: ${e.message}", e) "Benchmark error: ${e.message}" } catch (e: Exception) { + android.util.Log.e("BenchRunner", "Unexpected error during benchmark execution", e) "Unexpected error: ${e.message}" } @@ -129,18 +131,45 @@ class MainActivity : AppCompatActivity() { } private fun resolveBenchParams(): BenchParams { - val defaults = loadBenchParamsFromAssets() ?: BenchParams( + val assetParams = loadBenchParamsFromAssets() + val defaults = assetParams ?: BenchParams( DEFAULT_FUNCTION, DEFAULT_ITERATIONS, DEFAULT_WARMUP ) - val fn = intent?.getStringExtra(FUNCTION_EXTRA) - ?.takeUnless { it.isBlank() } - ?: defaults.function - val iterations = intent?.getIntExtra(ITERATIONS_EXTRA, defaults.iterations.toInt())?.toUInt() - ?: defaults.iterations - val warmup = intent?.getIntExtra(WARMUP_EXTRA, defaults.warmup.toInt())?.toUInt() - ?: defaults.warmup + + // Check for intent extras (used for test automation and BrowserStack) + val intentFunction = intent?.getStringExtra(FUNCTION_EXTRA)?.takeUnless { it.isBlank() } + val intentIterations = intent?.let { + val value = it.getIntExtra(ITERATIONS_EXTRA, -1) + if (value >= 0) value.toUInt() else null + } + val intentWarmup = intent?.let { + val value = it.getIntExtra(WARMUP_EXTRA, -1) + if (value >= 0) value.toUInt() else null + } + + // Resolve final values with logging + val fn = intentFunction ?: defaults.function + val iterations = intentIterations ?: defaults.iterations + val warmup = intentWarmup ?: defaults.warmup + + // Log the resolution source for debugging + if (assetParams == null && intentFunction == null && intentIterations == null && intentWarmup == null) { + android.util.Log.i("BenchRunner", "Using hardcoded defaults: function=$fn, iterations=$iterations, warmup=$warmup") + } else { + val sources = mutableListOf() + if (intentFunction != null) sources.add("function from intent") + if (intentIterations != null) sources.add("iterations from intent") + if (intentWarmup != null) sources.add("warmup from intent") + if (assetParams != null) { + if (intentFunction == null) sources.add("function from bench_spec.json") + if (intentIterations == null) sources.add("iterations from bench_spec.json") + if (intentWarmup == null) sources.add("warmup from bench_spec.json") + } + android.util.Log.i("BenchRunner", "Resolved params: function=$fn, iterations=$iterations, warmup=$warmup (sources: ${sources.joinToString(", ")})") + } + return BenchParams(fn, iterations, warmup) } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index 3ae7978..be78fae 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -34,20 +34,70 @@ struct BenchParams { static func fromProcessInfo() -> BenchParams { let info = ProcessInfo.processInfo - var function = info.environment["BENCH_FUNCTION"] ?? defaultFunction - var iterations = UInt32(info.environment["BENCH_ITERATIONS"] ?? "") ?? defaultIterations - var warmup = UInt32(info.environment["BENCH_WARMUP"] ?? "") ?? defaultWarmup + var function = defaultFunction + var iterations = defaultIterations + var warmup = defaultWarmup + var sources: [String] = [] + + // Check environment variables + if let envFunction = info.environment["BENCH_FUNCTION"], !envFunction.isEmpty { + function = envFunction + sources.append("function from BENCH_FUNCTION env") + } + if let envIterations = info.environment["BENCH_ITERATIONS"], !envIterations.isEmpty { + if let parsed = UInt32(envIterations) { + iterations = parsed + sources.append("iterations from BENCH_ITERATIONS env") + } else { + print("[BenchRunner] WARNING: Failed to parse BENCH_ITERATIONS='\(envIterations)' as integer, using default: \(defaultIterations)") + } + } + + if let envWarmup = info.environment["BENCH_WARMUP"], !envWarmup.isEmpty { + if let parsed = UInt32(envWarmup) { + warmup = parsed + sources.append("warmup from BENCH_WARMUP env") + } else { + print("[BenchRunner] WARNING: Failed to parse BENCH_WARMUP='\(envWarmup)' as integer, using default: \(defaultWarmup)") + } + } + + // Check launch arguments (override environment variables) for arg in info.arguments { if arg.hasPrefix("--bench-function=") { - function = String(arg.split(separator: "=", maxSplits: 1).last ?? Substring(function)) + if let value = arg.split(separator: "=", maxSplits: 1).last { + function = String(value) + sources.append("function from --bench-function arg") + } } else if arg.hasPrefix("--bench-iterations=") { - iterations = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? iterations + if let value = arg.split(separator: "=", maxSplits: 1).last { + if let parsed = UInt32(value) { + iterations = parsed + sources.append("iterations from --bench-iterations arg") + } else { + print("[BenchRunner] WARNING: Failed to parse --bench-iterations='\(value)' as integer, using current value: \(iterations)") + } + } } else if arg.hasPrefix("--bench-warmup=") { - warmup = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? warmup + if let value = arg.split(separator: "=", maxSplits: 1).last { + if let parsed = UInt32(value) { + warmup = parsed + sources.append("warmup from --bench-warmup arg") + } else { + print("[BenchRunner] WARNING: Failed to parse --bench-warmup='\(value)' as integer, using current value: \(warmup)") + } + } } } + // Log resolution source + if sources.isEmpty { + print("[BenchRunner] Using hardcoded defaults: function=\(function), iterations=\(iterations), warmup=\(warmup)") + } else { + print("[BenchRunner] Resolved params from process info: function=\(function), iterations=\(iterations), warmup=\(warmup) (sources: \(sources.joined(separator: ", ")))") + } + return BenchParams(function: function, iterations: iterations, warmup: warmup) } @@ -76,8 +126,10 @@ enum {{PROJECT_NAME_PASCAL}}FFI { let report = try runBenchmark(spec: spec) return formatBenchReport(report) } catch let error as BenchError { + print("[BenchRunner] ERROR: Benchmark failed: \(error)") return formatBenchError(error) } catch { + print("[BenchRunner] ERROR: Unexpected error during benchmark execution: \(error)") return "Unexpected error: \(error.localizedDescription)" } } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template index e3ed4f3..75c9ae3 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template @@ -18,6 +18,9 @@ targets: optional: true info: path: {{PROJECT_NAME_PASCAL}}/Info.plist + properties: + CFBundleShortVersionString: "1.0.0" + CFBundleVersion: "1" settings: base: PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID}} diff --git a/ios/BenchRunner/BenchRunner/Info.plist b/ios/BenchRunner/BenchRunner/Info.plist index 47ef031..657d601 100644 --- a/ios/BenchRunner/BenchRunner/Info.plist +++ b/ios/BenchRunner/BenchRunner/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + 1.0.0 CFBundleVersion 1 diff --git a/mobench-0.1.11-dx-report.md b/mobench-0.1.11-dx-report.md deleted file mode 100644 index 1ea5728..0000000 --- a/mobench-0.1.11-dx-report.md +++ /dev/null @@ -1,206 +0,0 @@ -# mobench 0.1.11 DX (Developer Experience) Report - -**Date:** 2026-01-19 -**Tested Version:** mobench-sdk 0.1.11, mobench CLI 0.1.11 -**Platform:** macOS Darwin 25.1.0 (arm64) -**Previous Versions Tested:** 0.1.9, 0.1.10 - -## Executive Summary - -mobench 0.1.11 fixes 3 major issues from 0.1.10 but introduces 1 new critical bug. The APK build succeeds, but the new test APK build step fails because it uses a non-existent Gradle task. - -**Build Status:** -- Android APK: ✅ Built successfully (133MB) -- Android Test APK: ❌ Failed (`assembleReleaseAndroidTest` task not found) -- iOS: ✅ Built successfully - ---- - -## Version Comparison Summary - -| Issue | 0.1.9 | 0.1.10 | 0.1.11 | -|-------|-------|--------|--------| -| Template placeholders | ❌ 5 unfilled | ✅ Fixed | ✅ Fixed | -| Gradle wrapper | ❌ Missing | ✅ Generated | ✅ Generated | -| gradle.properties | ❌ Missing | ✅ Generated | ✅ Generated | -| AGP version | ❌ Invalid | ✅ Fixed | ✅ Fixed | -| x86_64 iOS simulator | ❌ Missing | ✅ Included | ✅ Included | -| APK filename detection | N/A | ❌ Wrong name | ✅ Parses metadata | -| iOS bundle ID chars | N/A | ❌ Invalid | ✅ Fixed | -| proguard-rules.pro | ❌ Missing | ❌ Missing | ✅ Generated | -| Test APK build | N/A | N/A | ❌ **NEW BUG** | - ---- - -## Improvements in 0.1.11 (Fixed from 0.1.10) - -### 1. APK Filename Detection - FIXED -**Previous:** Expected `app-release.apk`, build reported failure -**Now:** Parses `output-metadata.json` to find correct filename `app-release-unsigned.apk` - -### 2. iOS Bundle Identifier - FIXED -**Previous:** `dev.world.bench-mobile.bench_mobile` (invalid chars) -**Now:** `dev.world.benchmobile.benchmobile` (valid) - -### 3. ProGuard Rules File - FIXED -**Previous:** File missing, ProGuard would fail if enabled -**Now:** `proguard-rules.pro` generated with proper JNA/UniFFI keep rules - -### 4. iOS Config Logging - IMPROVED -**Previous:** Silent fallback to defaults -**Now:** Logs warnings when config file missing or invalid - ---- - -## New Bug in 0.1.11 - -### CRITICAL: `assembleReleaseAndroidTest` Task Not Found - -**Symptom:** -``` -Task 'assembleReleaseAndroidTest' not found in root project 'bench_mobile-android' -``` - -**Root Cause:** -mobench 0.1.11 now attempts to build a test APK after the main APK. It uses `assembleReleaseAndroidTest` for release builds, but this Gradle task doesn't exist unless explicitly configured. - -**Why:** Android Gradle Plugin only creates test APK tasks for the debug build type by default. The release test task requires `testBuildType "release"` in `build.gradle`. - -**Impact:** -- Main APK builds successfully (133MB) -- mobench reports overall failure due to test APK step -- BrowserStack Espresso tests cannot run against release builds - -**Fix Required in mobench-sdk:** -Either: -1. Always use `assembleDebugAndroidTest` (test APKs are debug anyway) -2. Add `testBuildType "release"` to generated `build.gradle` -3. Make test APK build optional - -**Workaround:** -Add to `app/build.gradle`: -```gradle -android { - defaultConfig { - testBuildType "release" - } -} -``` - ---- - -## Remaining Issues (Not Fixed) - -### HIGH: Hardcoded local.properties -**File:** `target/mobench/android/local.properties` -**Issue:** Contains machine-specific SDK path -```properties -sdk.dir=/Users/dcbuilder/Library/Android/sdk -``` -**Impact:** Breaks builds on other machines - -### MEDIUM: Bundle ID Duplication (iOS) -**Current:** `dev.world.benchmobile.benchmobile` -**Expected:** `dev.world.benchmobile.BenchRunner` -**Impact:** Cosmetic, doesn't break builds - -### MEDIUM: Test File Wrong Directory (Android) -**Current:** `app/src/androidTest/java/MainActivityTest.kt` -**Expected:** `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` -**Impact:** Test may not compile correctly - -### MEDIUM: Silent Error Fallbacks -Both Android and iOS catch exceptions broadly and lose error type information. - ---- - -## Build Outputs - -### Android -``` -Location: target/mobench/android/ -APK: app/build/outputs/apk/release/app-release-unsigned.apk -Size: 133,561,304 bytes (127 MB) -Status: ✅ Built successfully -``` - -### iOS -``` -Location: target/mobench/ios/ -Framework: bench_mobile.xcframework/ -Architectures: ios-arm64, ios-arm64_x86_64-simulator -Status: ✅ Built successfully -``` - ---- - -## Agent Analysis Summary - -Three debugging agents were deployed: - -1. **Android Reviewer** - Found test task configuration issue, confirmed proguard-rules.pro fix -2. **iOS Reviewer** - Confirmed bundle ID fix, found duplication issue -3. **Silent Failure Hunter** - Found error handling patterns, test directory structure issue - -All agents identified the `assembleReleaseAndroidTest` as the critical new bug. - ---- - -## Test Commands - -```bash -# Upgrade -cargo install mobench --version 0.1.11 --force - -# Update SDK -# In bench-mobile/Cargo.toml: mobench-sdk = "0.1.11" -cargo update -p mobench-sdk - -# Clean and build -rm -rf target/mobench -mobench build --target android --release --verbose # Fails at test APK -mobench build --target ios --release --verbose # Succeeds - -# Verify APK exists despite error -ls -la target/mobench/android/app/build/outputs/apk/release/ - -# Workaround: Add testBuildType to build.gradle and retry -echo 'android { defaultConfig { testBuildType "release" } }' >> target/mobench/android/app/build.gradle -cd target/mobench/android && ./gradlew assembleReleaseAndroidTest -``` - ---- - -## Priority Fixes for 0.1.12 - -### P0 (Blocker) -1. **Fix test APK task** - Use `assembleDebugAndroidTest` or add `testBuildType` config - -### P1 (High) -2. **Don't generate local.properties** with hardcoded paths -3. **Fix test file directory** - Place in correct package directory - -### P2 (Medium) -4. Fix iOS bundle ID duplication -5. Improve error handling to preserve error types - ---- - -## Overall Score - -| Version | Score | Builds Without Fixes | -|---------|-------|---------------------| -| 0.1.9 | 4/10 | ❌ No | -| 0.1.10 | 8/10 | ✅ Yes (false failure) | -| 0.1.11 | 7/10 | ⚠️ Partial (APK yes, test APK no) | - -**Note:** 0.1.11 would be 9/10 if the test APK task issue is fixed. The other improvements (APK detection, proguard, iOS bundle ID) are significant. - ---- - -## Conclusion - -mobench 0.1.11 makes good progress on DX issues but introduces a regression with the test APK build. The main APK builds successfully and is usable. For production use, either: -1. Ignore the test APK failure if not using BrowserStack Espresso -2. Apply the workaround to add `testBuildType "release"` -3. Wait for 0.1.12 with the fix diff --git a/mobench-bugs-summary.md b/mobench-bugs-summary.md new file mode 100644 index 0000000..e3ffc7f --- /dev/null +++ b/mobench-bugs-summary.md @@ -0,0 +1,208 @@ +# mobench Bug Summary for Development + +**Last Updated:** 2026-01-19 +**Tested Versions:** 0.1.9, 0.1.10, 0.1.11, Local Build +**Test Crate:** bench-mobile (World ID ZK Proof Benchmarks) + +--- + +## Quick Status + +| Version | Critical Bugs | Builds Without Fixes | Recommendation | +|---------|---------------|---------------------|----------------| +| 0.1.9 | 12 | ❌ No | Do not use | +| 0.1.10 | 2 | ✅ Yes (false failure) | Usable | +| 0.1.11 | 1 | ⚠️ Partial | Usable with workaround | +| Local | 2 | ⚠️ Requires --crate-path | Needs crate detection fix | + +--- + +## Version Evolution + +### 0.1.9 → 0.1.10 (12 bugs fixed) +All template placeholder bugs fixed, Gradle wrapper and properties added. + +### 0.1.10 → 0.1.11 (3 bugs fixed, 1 new bug) + +**Fixed:** +- ✅ APK filename detection (now parses output-metadata.json) +- ✅ iOS bundle identifier (removed invalid hyphens/underscores) +- ✅ proguard-rules.pro now generated + +**New Bug:** +- ❌ `assembleReleaseAndroidTest` task not found + +### 0.1.11 → Local Build (0 bugs fixed, 1 new bug) + +**New Bug:** +- ❌ Crate detection fails - requires `--crate-path .` flag + +**Still Present:** +- ❌ `assembleReleaseAndroidTest` task not found +- ❌ Hardcoded local.properties +- ❌ Source files not in package directory +- ❌ iOS bundle ID duplication + +--- + +## Current Bugs in Local Build + +### CRITICAL + +#### Bug 0: Crate Detection Fails (NEW in Local Build) +**Location:** mobench-sdk crate detection logic +**Symptom:** +``` +build error: Benchmark crate 'bench-mobile' not found. + +Searched locations: +- /path/bench-mobile/bench-mobile/Cargo.toml +- /path/bench-mobile/crates/bench-mobile/Cargo.toml +``` + +**Cause:** mobench looks for nested directories instead of checking if the current directory is a valid crate. + +**Impact:** +- Build fails to start without workaround +- Must use `--crate-path .` flag + +**Workaround:** +```bash +mobench build --target android --release --crate-path . +``` + +--- + +#### Bug 1: Test APK Task Not Found (from 0.1.11) +**Location:** mobench-sdk android.rs +**Symptom:** +``` +Task 'assembleReleaseAndroidTest' not found in root project +``` + +**Cause:** Android Gradle only creates test tasks for debug by default. Release test task requires `testBuildType "release"`. + +**Impact:** +- Main APK builds ✅ +- Test APK fails ❌ +- mobench reports overall failure + +**Workaround:** +```gradle +// Add to app/build.gradle +android { + defaultConfig { + testBuildType "release" + } +} +``` + +**Or:** Use debug build type for tests (recommended) + +--- + +### HIGH + +#### Bug 2: Hardcoded local.properties (Still present) +**File:** `target/mobench/android/local.properties` +```properties +sdk.dir=/Users/dcbuilder/Library/Android/sdk +``` + +**Impact:** Breaks builds on other machines + +**Fix:** Don't generate this file + +--- + +#### Bug 3: Test File Wrong Directory +**Current:** `app/src/androidTest/java/MainActivityTest.kt` +**Expected:** `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` + +**Impact:** Test compilation may fail + +--- + +### MEDIUM + +#### Bug 4: iOS Bundle ID Duplication +**Current:** `dev.world.benchmobile.benchmobile` +**Expected:** `dev.world.benchmobile.BenchRunner` + +**Impact:** Cosmetic, works but non-standard + +--- + +#### Bug 5: Silent Error Fallbacks +Both platforms catch exceptions broadly and lose error context. + +--- + +## Fixed Bugs (Historical) + +### Fixed in 0.1.11 +| Bug | Description | Status | +|-----|-------------|--------| +| APK filename | Expected wrong name | ✅ Fixed | +| iOS bundle ID | Invalid chars | ✅ Fixed | +| proguard-rules.pro | Missing file | ✅ Fixed | + +### Fixed in 0.1.10 +| Bug | Description | Status | +|-----|-------------|--------| +| `{{PACKAGE_NAME}}` | Not replaced | ✅ Fixed | +| `{{LIBRARY_NAME}}` | Not replaced | ✅ Fixed | +| `{{PROJECT_NAME}}` | Not replaced | ✅ Fixed | +| `{{APP_NAME}}` | Not replaced | ✅ Fixed | +| gradle.properties | Missing | ✅ Fixed | +| Gradle wrapper | Missing | ✅ Fixed | +| AGP version | Invalid 8.13.2 | ✅ Fixed | +| x86_64 iOS sim | Missing | ✅ Fixed | + +--- + +## Verification Commands + +```bash +# Build Android (APK succeeds, test APK fails) +mobench build --target android --release --verbose +ls -la target/mobench/android/app/build/outputs/apk/release/ + +# Build iOS (succeeds fully) +mobench build --target ios --release --verbose +ls -la target/mobench/ios/bench_mobile.xcframework/ + +# Workaround for test APK +cd target/mobench/android +# Add testBuildType to build.gradle, then: +./gradlew assembleReleaseAndroidTest +``` + +--- + +## Recommended Priority Fixes for Next Release + +### P0 (Blocker) +1. **Fix crate detection** - Check current directory Cargo.toml, not just nested paths +2. **Fix test APK task** - Use debug or add testBuildType config + +### P1 (Should Fix) +3. Don't generate local.properties with hardcoded paths +4. Fix source file directory structure (place in package path) +5. Log UniFFI cleanup errors instead of swallowing + +### P2 (Nice to Have) +6. Fix iOS bundle ID duplication +7. Standardize package naming across platforms +8. Improve error handling to preserve types + +--- + +## Files Changed + +- `bench-mobile/Cargo.toml` - Using local mobench-sdk path +- `docs/mobench-0.1.9-dx-report.md` - Full 0.1.9 report +- `docs/mobench-0.1.10-dx-report.md` - Full 0.1.10 report +- `docs/mobench-0.1.11-dx-report.md` - Full 0.1.11 report +- `docs/mobench-local-build-dx-report.md` - Full local build report +- `docs/mobench-bugs-summary.md` - This file diff --git a/mobench-local-build-dx-report.md b/mobench-local-build-dx-report.md new file mode 100644 index 0000000..d46d95f --- /dev/null +++ b/mobench-local-build-dx-report.md @@ -0,0 +1,244 @@ +# mobench Local Build DX Report + +**Date:** 2026-01-19 +**Tested Version:** Local build from `../mobile-bench-rs` (based on 0.1.11) +**Platform:** macOS Darwin 25.1.0 (arm64) + +## Executive Summary + +Testing the local mobench build revealed **1 new bug** (crate detection) while confirming that previous bugs from 0.1.11 remain unfixed. Both platforms build successfully, but the test APK step still fails. + +**Build Status:** +- Android APK: ✅ Built successfully (133MB) +- Android Test APK: ❌ Failed (`assembleReleaseAndroidTest` task not found) +- iOS: ✅ Built successfully + +--- + +## New Bug Found + +### BUG: Crate Detection Fails Without `--crate-path` + +**Severity:** CRITICAL (build won't start) + +**Symptom:** +``` +build error: Benchmark crate 'bench-mobile' not found. + +Searched locations: +- /Users/.../bench-mobile/bench-mobile/Cargo.toml +- /Users/.../bench-mobile/crates/bench-mobile/Cargo.toml +``` + +**Root Cause:** mobench looks for nested directories (`bench-mobile/bench-mobile/` or `bench-mobile/crates/bench-mobile/`) instead of checking if the current directory contains a valid crate with a matching name. + +**Workaround:** Use `--crate-path .` flag: +```bash +mobench build --target android --release --crate-path . +``` + +**Fix Required:** mobench should check if the current directory's `Cargo.toml` has a matching `[package] name`. + +--- + +## Confirmed Bugs (Still Present from 0.1.11) + +### 1. Test APK Task Not Found + +**Status:** STILL PRESENT +**Impact:** Android build reports failure even though main APK builds successfully + +``` +Task 'assembleReleaseAndroidTest' not found in root project 'bench_mobile-android' +``` + +**Workaround:** Add to `app/build.gradle`: +```gradle +android { + defaultConfig { + testBuildType "release" + } +} +``` + +--- + +### 2. Hardcoded local.properties + +**Status:** STILL PRESENT +**File:** `target/mobench/android/local.properties` +```properties +sdk.dir=/Users/dcbuilder/Library/Android/sdk +``` + +**Impact:** Breaks builds on other machines. + +--- + +### 3. Source Files Not in Package Directory + +**Status:** STILL PRESENT + +**Current:** +- `app/src/main/java/MainActivity.kt` +- `app/src/androidTest/java/MainActivityTest.kt` + +**Expected:** +- `app/src/main/java/dev/world/bench_mobile/MainActivity.kt` +- `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` + +**Impact:** Kotlin files with `package dev.world.bench_mobile` must be in matching directory structure. + +--- + +### 4. iOS Bundle ID Duplication + +**Status:** STILL PRESENT +**File:** `target/mobench/ios/BenchRunner/project.yml` + +**Current:** `dev.world.benchmobile.benchmobile` (duplicated) +**Expected:** `dev.world.benchmobile` + +--- + +### 5. Cross-Platform Naming Inconsistency + +**Android:** `dev.world.bench_mobile` (snake_case) +**iOS:** `dev.world.benchmobile` (camelCase) + +--- + +### 6. Version String Mismatch + +**Android:** `0.1` +**iOS:** `1.0` + +--- + +## Silent Failure Issues + +### CRITICAL: UniFFI Cleanup Errors Swallowed + +**File:** `app/src/main/java/uniffi/bench_mobile/bench_mobile.kt:895-905` +```kotlin +} catch (e: Throwable) { + // swallow +} +``` + +All exceptions during `destroy()` are silently discarded, hiding memory leaks and native crashes. + +--- + +### CRITICAL: Broad Exception Catch Without Logging + +**File:** `app/src/main/java/MainActivity.kt:59-61` +```kotlin +} catch (e: Exception) { + "Unexpected error: ${e.message}" +} +``` + +Catches all exceptions but doesn't log them, making production debugging impossible. + +--- + +### HIGH: Silent Config Fallbacks + +Both platforms silently fall back to defaults when config parsing fails: +- Android: `MainActivity.kt:191-197` - logs but user isn't notified +- iOS: `BenchRunnerFFI.swift:35-52` - no logging at all for invalid numeric values + +--- + +## What's Working + +✅ APK filename detection (parses `output-metadata.json`) +✅ `proguard-rules.pro` generated with correct JNA/UniFFI rules +✅ iOS bundle identifier format (no invalid characters) +✅ Gradle wrapper generated +✅ `gradle.properties` generated +✅ All template placeholders replaced +✅ x86_64 iOS simulator support (universal binary) +✅ iOS xcframework code-signed + +--- + +## Build Outputs + +### Android +``` +Location: target/mobench/android/ +APK: app/build/outputs/apk/release/app-release-unsigned.apk +Size: 133,561,304 bytes (127 MB) +Status: ✅ Built successfully +``` + +### iOS +``` +Location: target/mobench/ios/ +Framework: bench_mobile.xcframework/ +Architectures: ios-arm64, ios-arm64_x86_64-simulator +Status: ✅ Built successfully +``` + +--- + +## Priority Fixes for Next Release + +### P0 (Blocker) +1. **Fix crate detection** - Check current directory Cargo.toml, not just nested paths +2. **Fix test APK task** - Use `assembleDebugAndroidTest` or add `testBuildType` config + +### P1 (Should Fix) +3. Don't generate `local.properties` with hardcoded paths +4. Fix source file directory structure (place in package path) +5. Log UniFFI cleanup errors instead of swallowing + +### P2 (Nice to Have) +6. Fix iOS bundle ID duplication +7. Standardize package naming across platforms +8. Align version strings +9. Add user-visible config fallback warnings + +--- + +## Test Commands + +```bash +# Build with local mobench (requires --crate-path flag) +cd bench-mobile +mobench build --target android --release --verbose --crate-path . +mobench build --target ios --release --verbose --crate-path . + +# Verify APK exists despite error +ls -la target/mobench/android/app/build/outputs/apk/release/ + +# Verify iOS xcframework +ls -la target/mobench/ios/bench_mobile.xcframework/ +``` + +--- + +## Agent Analysis + +Three debugging agents were deployed in parallel: + +1. **Code Reviewer** - Confirmed test task issue, found source file directory structure bug +2. **Silent Failure Hunter** - Found 9 error handling issues (2 critical, 4 high, 3 medium) +3. **Explorer** - Found bundle ID duplication, naming inconsistencies, version mismatch + +--- + +## Comparison: 0.1.11 vs Local Build + +| Issue | 0.1.11 | Local Build | +|-------|--------|-------------| +| Crate detection | ✅ | ❌ NEW BUG | +| Test APK task | ❌ | ❌ | +| local.properties | ❌ | ❌ | +| Source file paths | ❌ | ❌ | +| iOS bundle ID dupe | ❌ | ❌ | +| proguard-rules.pro | ✅ | ✅ | +| APK detection | ✅ | ✅ | +| iOS bundle ID chars | ✅ | ✅ | From 003763ffece3a47686ec1fac515b9b034d501166 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 09:55:13 +0100 Subject: [PATCH 046/196] Add --release flag and package-xcuitest command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --release flag to mobench run command for release builds (reduces APK size from ~544MB debug to ~133MB release) - Add mobench package-xcuitest command for iOS BrowserStack testing - Add output_dir option to package-ipa command - Update timing display format from μs to ms (or seconds if >=1000ms) - Auto-package iOS artifacts in release mode when --release is set --- crates/mobench/src/lib.rs | 157 ++++++++++++++++++++++++++++++++++---- 1 file changed, 142 insertions(+), 15 deletions(-) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 882d343..ce8bfe8 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -169,6 +169,8 @@ enum Command { summary_csv: bool, #[arg(long, help = "Skip mobile builds and only run the host harness")] local_only: bool, + #[arg(long, help = "Build in release mode (recommended for BrowserStack to reduce APK size and upload time)")] + release: bool, #[arg( long, help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" @@ -249,6 +251,19 @@ enum Command { scheme: String, #[arg(long, value_enum, default_value = "adhoc", help = "Signing method")] method: IosSigningMethodArg, + #[arg(long, help = "Output directory for mobile artifacts (default: target/mobench)")] + output_dir: Option, + }, + /// Package XCUITest runner for BrowserStack testing. + /// + /// Builds the XCUITest runner using xcodebuild and zips the resulting + /// .xctest bundle for BrowserStack upload. The output is placed at + /// `target/mobench/ios/BenchRunnerUITests.zip` by default. + PackageXcuitest { + #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] + scheme: String, + #[arg(long, help = "Output directory for mobile artifacts (default: target/mobench)")] + output_dir: Option, }, /// List all discovered benchmark functions (Phase 1 MVP). List, @@ -436,6 +451,7 @@ pub fn run() -> Result<()> { output, summary_csv, local_only, + release, ios_app, ios_test_suite, fetch, @@ -453,12 +469,14 @@ pub fn run() -> Result<()> { ios_app, ios_test_suite, local_only, + release, )?; let summary_paths = resolve_summary_paths(output.as_deref())?; println!( "Preparing benchmark run for {:?}: {} (iterations={}, warmup={})", spec.target, spec.function, spec.iterations, spec.warmup ); + println!("Build profile: {}", if release { "release" } else { "debug" }); persist_mobile_spec(&spec)?; if !spec.devices.is_empty() { println!("Devices: {}", spec.devices.join(", ")); @@ -489,7 +507,7 @@ pub fn run() -> Result<()> { let ndk = std::env::var("ANDROID_NDK_HOME").context( "ANDROID_NDK_HOME must be set for Android builds. Example: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/", )?; - let build = run_android_build(&ndk)?; + let build = run_android_build(&ndk, release)?; let apk = build.app_path; println!("Built Android APK at {:?}", apk); if spec.devices.is_empty() { @@ -505,7 +523,7 @@ pub fn run() -> Result<()> { } } MobileTarget::Ios => { - let (xcframework, header) = run_ios_build()?; + let (xcframework, header) = run_ios_build(release)?; println!("Built iOS xcframework at {:?}", xcframework); let ios_xcuitest = spec.ios_xcuitest.clone(); @@ -710,8 +728,11 @@ pub fn run() -> Result<()> { } => { cmd_build(target, release, output_dir, crate_path, cli.dry_run, cli.verbose)?; } - Command::PackageIpa { scheme, method } => { - cmd_package_ipa(&scheme, method)?; + Command::PackageIpa { scheme, method, output_dir } => { + cmd_package_ipa(&scheme, method, output_dir)?; + } + Command::PackageXcuitest { scheme, output_dir } => { + cmd_package_xcuitest(&scheme, output_dir)?; } Command::List => { cmd_list()?; @@ -1016,6 +1037,7 @@ fn resolve_run_spec( ios_app: Option, ios_test_suite: Option, local_only: bool, + release: bool, ) -> Result { if let Some(cfg_path) = config { let cfg = load_config(cfg_path)?; @@ -1050,7 +1072,7 @@ fn resolve_run_spec( && !devices.is_empty() && ios_xcuitest.is_none() { - Some(package_ios_xcuitest_artifacts()?) + Some(package_ios_xcuitest_artifacts(release)?) } else { ios_xcuitest }; @@ -1126,14 +1148,19 @@ fn filter_devices_by_tags(devices: Vec, tags: &[String]) -> Result< Ok(matched) } -fn run_ios_build() -> Result<(PathBuf, PathBuf)> { +fn run_ios_build(release: bool) -> Result<(PathBuf, PathBuf)> { let root = repo_root()?; let crate_name = detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); + let profile = if release { + mobench_sdk::BuildProfile::Release + } else { + mobench_sdk::BuildProfile::Debug + }; let cfg = mobench_sdk::BuildConfig { target: mobench_sdk::Target::Ios, - profile: mobench_sdk::BuildProfile::Debug, + profile, incremental: true, }; let result = builder.build(&cfg)?; @@ -1148,14 +1175,19 @@ fn run_ios_build() -> Result<(PathBuf, PathBuf)> { Ok((result.app_path, header)) } -fn package_ios_xcuitest_artifacts() -> Result { +fn package_ios_xcuitest_artifacts(release: bool) -> Result { let root = repo_root()?; let crate_name = detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); + let profile = if release { + mobench_sdk::BuildProfile::Release + } else { + mobench_sdk::BuildProfile::Debug + }; let cfg = mobench_sdk::BuildConfig { target: mobench_sdk::Target::Ios, - profile: mobench_sdk::BuildProfile::Debug, + profile, incremental: true, }; builder @@ -1805,20 +1837,46 @@ fn render_csv_summary(summary: &SummaryReport) -> String { output } +/// Formats a duration in nanoseconds to a human-readable string. +/// +/// The function picks the appropriate unit based on the magnitude: +/// - Uses milliseconds (ms) by default +/// - Switches to seconds (s) if the value is >= 1000ms (1 second) +/// +/// Examples: +/// - 500_000 ns -> "0.500ms" +/// - 1_500_000 ns -> "1.500ms" +/// - 1_500_000_000 ns -> "1.500s" +fn format_duration_smart(ns: u64) -> String { + let ms = ns as f64 / 1_000_000.0; + if ms >= 1000.0 { + // Convert to seconds + let secs = ms / 1000.0; + format!("{:.3}s", secs) + } else { + format!("{:.3}ms", ms) + } +} + fn format_ms(value: Option) -> String { value - .map(|ns| format!("{:.3}", ns as f64 / 1_000_000.0)) + .map(format_duration_smart) .unwrap_or_else(|| "-".to_string()) } -fn run_android_build(_ndk_home: &str) -> Result { +fn run_android_build(_ndk_home: &str, release: bool) -> Result { let root = repo_root()?; let crate_name = detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); + let profile = if release { + mobench_sdk::BuildProfile::Release + } else { + mobench_sdk::BuildProfile::Debug + }; let cfg = mobench_sdk::BuildConfig { target: mobench_sdk::Target::Android, - profile: mobench_sdk::BuildProfile::Debug, + profile, incremental: true, }; let builder = mobench_sdk::builders::AndroidBuilder::new(&root, crate_name).verbose(true); @@ -2122,23 +2180,29 @@ fn cmd_list() -> Result<()> { } /// Package iOS app as IPA for distribution or testing -fn cmd_package_ipa(scheme: &str, method: IosSigningMethodArg) -> Result<()> { +fn cmd_package_ipa(scheme: &str, method: IosSigningMethodArg, output_dir: Option) -> Result<()> { println!("Packaging iOS app as IPA..."); println!(" Scheme: {}", scheme); println!(" Method: {:?}", method); + if let Some(ref dir) = output_dir { + println!(" Output: {:?}", dir); + } let project_root = repo_root()?; let crate_name = detect_bench_mobile_crate_name(&project_root) .unwrap_or_else(|_| "bench-mobile".to_string()); - let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + let mut builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + if let Some(ref dir) = output_dir { + builder = builder.output_dir(dir); + } let signing_method: mobench_sdk::builders::SigningMethod = method.into(); let ipa_path = builder .package_ipa(scheme, signing_method) .context("Failed to package IPA")?; - println!("\n✓ IPA packaged successfully!"); + println!("\n[checkmark] IPA packaged successfully!"); println!(" Path: {:?}", ipa_path); println!("\nYou can now:"); println!(" - Install on device: Use Xcode or ios-deploy"); @@ -2150,6 +2214,38 @@ fn cmd_package_ipa(scheme: &str, method: IosSigningMethodArg) -> Result<()> { Ok(()) } +/// Package XCUITest runner for BrowserStack testing +fn cmd_package_xcuitest(scheme: &str, output_dir: Option) -> Result<()> { + println!("Packaging XCUITest runner..."); + println!(" Scheme: {}", scheme); + if let Some(ref dir) = output_dir { + println!(" Output: {:?}", dir); + } + + let project_root = repo_root()?; + let crate_name = detect_bench_mobile_crate_name(&project_root) + .unwrap_or_else(|_| "bench-mobile".to_string()); + + let mut builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + if let Some(ref dir) = output_dir { + builder = builder.output_dir(dir); + } + + let zip_path = builder + .package_xcuitest(scheme) + .context("Failed to package XCUITest runner")?; + + println!("\n[checkmark] XCUITest runner packaged successfully!"); + println!(" Path: {:?}", zip_path); + println!("\nYou can now:"); + println!( + " - Test on BrowserStack: cargo mobench run --target ios --ios-test-suite {:?}", + zip_path + ); + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -2172,6 +2268,7 @@ mod tests { None, None, false, + false, // release ) .unwrap(); assert_eq!(spec.function, "sample_fns::fibonacci"); @@ -2210,6 +2307,7 @@ mod tests { None, None, false, + false, // release ) .expect("should auto-package iOS artifacts when missing"); let ios_artifacts = spec @@ -2221,4 +2319,33 @@ mod tests { "iOS test suite artifact missing" ); } + + #[test] + fn format_duration_smart_uses_milliseconds_by_default() { + // 500 microseconds = 0.5 ms + assert_eq!(format_duration_smart(500_000), "0.500ms"); + // 1.5 ms + assert_eq!(format_duration_smart(1_500_000), "1.500ms"); + // 100 ms + assert_eq!(format_duration_smart(100_000_000), "100.000ms"); + // 999.999 ms (just below threshold) + assert_eq!(format_duration_smart(999_999_000), "999.999ms"); + } + + #[test] + fn format_duration_smart_switches_to_seconds_when_large() { + // Exactly 1 second + assert_eq!(format_duration_smart(1_000_000_000), "1.000s"); + // 1.5 seconds + assert_eq!(format_duration_smart(1_500_000_000), "1.500s"); + // 10 seconds + assert_eq!(format_duration_smart(10_000_000_000), "10.000s"); + } + + #[test] + fn format_ms_handles_optional_values() { + assert_eq!(format_ms(Some(1_500_000)), "1.500ms"); + assert_eq!(format_ms(Some(1_500_000_000)), "1.500s"); + assert_eq!(format_ms(None), "-"); + } } From 8d8c8b9f06389cec529ba93168bf574be63edb35 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 09:55:19 +0100 Subject: [PATCH 047/196] Update mobile timing display and documentation - Update Android/iOS apps to display timing in ms (or seconds if >=1000ms) - Update CLAUDE.md, TESTING.md, README with --release flag examples - Document package-xcuitest command across all relevant docs - Add release build recommendations for BrowserStack workflows --- BENCH_SDK_INTEGRATION.md | 23 ++++++-- BROWSERSTACK_CI_INTEGRATION.md | 7 ++- CLAUDE.md | 55 ++++++++++++++++++- FETCH_RESULTS_GUIDE.md | 17 ++++-- TESTING.md | 52 +++++++++++++++++- .../main/java/dev/world/bench/MainActivity.kt | 31 ++++++++--- .../src/main/java/MainActivity.kt.template | 31 ++++++++--- .../BenchRunner/BenchRunnerFFI.swift.template | 29 +++++++--- crates/mobench/README.md | 38 +++++++++++-- .../BenchRunner/BenchRunnerFFI.swift | 29 +++++++--- .../src/main/java/MainActivity.kt.template | 31 ++++++++--- .../BenchRunner/BenchRunnerFFI.swift.template | 29 +++++++--- 12 files changed, 300 insertions(+), 72 deletions(-) diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index ca6e304..0680abf 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -141,11 +141,15 @@ cargo mobench run \ --function my_crate::checksum_bench \ --iterations 100 \ --warmup 10 \ - --devices "Google Pixel 7-13.0" + --devices "Google Pixel 7-13.0" \ + --release ``` +**Important**: Always use the `--release` flag for BrowserStack runs. Debug builds are significantly larger (~544MB vs ~133MB for release) and may cause upload timeouts. + The CLI will automatically: +- Build in release mode (with `--release` flag) - Upload APK and test APK to BrowserStack - Schedule the test run - Wait for completion @@ -153,7 +157,7 @@ The CLI will automatically: ## 8) BrowserStack (iOS XCUITest) -Build iOS artifacts and package as IPA: +Build iOS artifacts and package for BrowserStack: ```bash # Build xcframework @@ -162,6 +166,9 @@ cargo mobench build --target ios # Package as IPA (ad-hoc signing, no Apple ID needed) cargo mobench package-ipa --method adhoc +# Package the XCUITest runner for BrowserStack +cargo mobench package-xcuitest + # Or for development signing (requires Apple Developer account) cargo mobench package-ipa --method development ``` @@ -175,18 +182,24 @@ cargo mobench run \ --iterations 100 \ --warmup 10 \ --devices "iPhone 14-16" \ + --release \ --ios-app target/mobench/ios/BenchRunner.ipa \ --ios-test-suite target/mobench/ios/BenchRunnerUITests.zip ``` -**IPA Signing Methods:** +**Important**: Always use the `--release` flag for BrowserStack runs to reduce artifact sizes and prevent upload timeouts. + +**iOS Packaging Commands:** -- `adhoc`: No Apple ID required, works for BrowserStack device testing -- `development`: Requires Apple Developer account, for physical device testing +- `package-ipa`: Creates the app IPA bundle for device deployment + - `--method adhoc`: No Apple ID required, works for BrowserStack + - `--method development`: Requires Apple Developer account +- `package-xcuitest`: Creates the XCUITest runner zip that BrowserStack uses to drive test automation. Outputs to `target/mobench/ios/BenchRunnerUITests.zip` ## Notes - **No scripts needed**: All functionality is available via `cargo mobench` commands +- **Use `--release` for BrowserStack**: Debug builds are ~544MB, release builds are ~133MB. Large artifacts can cause upload timeouts. - If you change FFI types, the build process automatically regenerates bindings - Android emulator ABI is typically `x86_64` in Android Studio - BrowserStack credentials must be set via `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` diff --git a/BROWSERSTACK_CI_INTEGRATION.md b/BROWSERSTACK_CI_INTEGRATION.md index 8703f65..e45de78 100644 --- a/BROWSERSTACK_CI_INTEGRATION.md +++ b/BROWSERSTACK_CI_INTEGRATION.md @@ -54,18 +54,21 @@ for (device, bench_results) in results { ### Using `mobench run` with Result Fetching ```bash -# Run and fetch results +# Run and fetch results (use --release for smaller APK, faster uploads) cargo mobench run \ --target android \ --function sample_fns::fibonacci \ --iterations 30 \ --warmup 5 \ --devices "Google Pixel 7-13.0" \ + --release \ --fetch \ --fetch-timeout-secs 600 \ --output results.json ``` +**Note**: Always use the `--release` flag for BrowserStack runs. Debug builds are significantly larger (~544MB vs ~133MB for release) and may cause upload timeouts. + ## API Methods ### 1. Poll for Build Completion @@ -184,12 +187,14 @@ jobs: BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} run: | # Build and run (fetches results) + # Use --release to reduce APK size and prevent upload timeouts cargo mobench run \ --target android \ --function my_crate::my_benchmark \ --iterations 30 \ --warmup 5 \ --devices "Google Pixel 7-13.0" \ + --release \ --fetch \ --fetch-timeout-secs 600 \ --output results.json diff --git a/CLAUDE.md b/CLAUDE.md index c7556f2..cb9c6c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,13 @@ cargo mobench build --target ios # Package iOS IPA (for BrowserStack or physical devices) cargo mobench package-ipa --method adhoc + +# Package XCUITest runner (for BrowserStack iOS testing) +cargo mobench package-xcuitest + +# Build in release mode (smaller artifacts, recommended for BrowserStack) +cargo mobench build --target android --release +cargo mobench build --target ios --release ``` **What the CLI does:** @@ -209,25 +216,34 @@ cargo mobench run \ export BROWSERSTACK_USERNAME="your_username" export BROWSERSTACK_ACCESS_KEY="your_access_key" -# Run on real devices +# Run on real devices (use --release to reduce APK size for faster uploads) cargo mobench run \ --target android \ --function sample_fns::checksum \ --iterations 30 \ --warmup 5 \ --devices "Google Pixel 7-13.0" \ + --release \ --output run-summary.json ``` +**Note on `--release` flag**: Debug builds can be very large (~544MB) which may cause BrowserStack upload timeouts. The `--release` flag builds in release mode, reducing APK size significantly (~133MB), and is recommended for all BrowserStack runs. + #### BrowserStack Run (iOS) ```bash +# First, package the IPA and XCUITest runner +cargo mobench package-ipa --method adhoc +cargo mobench package-xcuitest + +# Run on real devices cargo mobench run \ --target ios \ --function sample_fns::fibonacci \ --iterations 20 \ --warmup 3 \ --devices "iPhone 14-16" \ + --release \ --ios-app target/mobench/ios/BenchRunner.ipa \ --ios-test-suite target/mobench/ios/BenchRunnerUITests.zip \ --output run-summary.json @@ -246,6 +262,43 @@ cargo mobench plan --output device-matrix.yaml cargo mobench run --config bench-config.toml ``` +#### Release Builds for BrowserStack + +Debug builds can be very large and may cause upload timeouts on BrowserStack: +- **Debug APK**: ~544MB +- **Release APK**: ~133MB + +Always use the `--release` flag when running benchmarks on BrowserStack: + +```bash +# Android - builds in release mode automatically +cargo mobench run --target android --release --devices "Google Pixel 7-13.0" ... + +# iOS - builds in release mode automatically +cargo mobench run --target ios --release --devices "iPhone 14-16" ... +``` + +The `--release` flag ensures smaller artifact sizes and faster uploads. + +#### Packaging iOS for BrowserStack + +BrowserStack iOS testing requires two packages: +1. **App IPA**: The main application bundle +2. **XCUITest Runner**: The test automation package + +```bash +# Package the app IPA +cargo mobench package-ipa --method adhoc + +# Package the XCUITest runner for BrowserStack +cargo mobench package-xcuitest +``` + +The `package-xcuitest` command: +- Builds the XCUITest target from the iOS project +- Creates a properly structured zip file for BrowserStack +- Outputs to `target/mobench/ios/BenchRunnerUITests.zip` + #### Fetch BrowserStack Results ```bash diff --git a/FETCH_RESULTS_GUIDE.md b/FETCH_RESULTS_GUIDE.md index 8185e7f..bede8eb 100644 --- a/FETCH_RESULTS_GUIDE.md +++ b/FETCH_RESULTS_GUIDE.md @@ -13,10 +13,13 @@ cargo mobench run \ --iterations 30 \ --warmup 5 \ --devices "Google Pixel 7-13.0" \ + --release \ --fetch \ --output results.json ``` +**Note**: Always use the `--release` flag for BrowserStack runs. Debug builds are significantly larger (~544MB vs ~133MB for release) and may cause upload timeouts. + ## How It Works When `--fetch` is enabled: @@ -155,12 +158,14 @@ jobs: BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/26.1.10909125 run: | + # Use --release to reduce APK size and prevent upload timeouts cargo mobench run \ --target android \ --function my_crate::my_benchmark \ --iterations 30 \ --warmup 5 \ --devices "Google Pixel 7-13.0" \ + --release \ --fetch \ --fetch-timeout-secs 900 \ --output results.json @@ -230,10 +235,11 @@ Verify your app logs JSON to stdout/logcat in the correct format. ## Best Practices 1. **Always use --fetch in CI** for automated pipelines -2. **Set reasonable timeouts** based on your benchmark duration -3. **Check exit codes** - command succeeds even if fetch warns -4. **Archive results** as CI artifacts for historical tracking -5. **Use GitHub Actions summaries** to display results inline +2. **Always use --release for BrowserStack** to reduce artifact sizes (~544MB debug vs ~133MB release) and prevent upload timeouts +3. **Set reasonable timeouts** based on your benchmark duration +4. **Check exit codes** - command succeeds even if fetch warns +5. **Archive results** as CI artifacts for historical tracking +6. **Use GitHub Actions summaries** to display results inline ## Comparison with Manual Workflow @@ -254,11 +260,12 @@ cat target/browserstack/.../device-logs.txt | grep '{"function"' ### With --fetch ```bash -# One command does everything +# One command does everything (use --release for BrowserStack) cargo mobench run \ --target android \ --function my_func \ --devices "..." \ + --release \ --fetch \ --output results.json diff --git a/TESTING.md b/TESTING.md index e5131ad..8a178d4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -467,16 +467,63 @@ cargo test --all See the main [README.md](README.md) for BrowserStack testing instructions. +#### Release Builds for BrowserStack + +Always use the `--release` flag when running benchmarks on BrowserStack. Debug builds are significantly larger and may cause upload timeouts: + +| Build Type | APK Size | +|------------|----------| +| Debug | ~544 MB | +| Release | ~133 MB | + +```bash +# Android - use --release for smaller APK +cargo mobench run \ + --target android \ + --function sample_fns::fibonacci \ + --devices "Google Pixel 7-13.0" \ + --release \ + --output results.json + +# iOS - use --release for smaller artifacts +cargo mobench run \ + --target ios \ + --function sample_fns::fibonacci \ + --devices "iPhone 14-16" \ + --release \ + --ios-app target/mobench/ios/BenchRunner.ipa \ + --ios-test-suite target/mobench/ios/BenchRunnerUITests.zip \ + --output results.json +``` + +#### Packaging iOS for BrowserStack + +BrowserStack iOS testing requires both an IPA and an XCUITest runner package: + +```bash +# 1. Build the iOS artifacts +cargo mobench build --target ios + +# 2. Package the app IPA +cargo mobench package-ipa --method adhoc + +# 3. Package the XCUITest runner +cargo mobench package-xcuitest +``` + +The `package-xcuitest` command creates `target/mobench/ios/BenchRunnerUITests.zip` which BrowserStack uses to drive the test automation. + ### Performance Regression Testing Compare benchmark results across builds: ```bash -# Run benchmark and save results +# Run benchmark and save results (use --release for BrowserStack) cargo mobench run \ --target android \ --function sample_fns::fibonacci \ --devices "Google Pixel 7-13.0" \ --iterations 100 \ + --release \ --fetch \ --output results-v1.json \ --summary-csv @@ -487,6 +534,7 @@ cargo mobench run \ --function sample_fns::fibonacci \ --devices "Google Pixel 7-13.0" \ --iterations 100 \ + --release \ --fetch \ --output results-v2.json \ --summary-csv @@ -498,6 +546,8 @@ cargo mobench compare \ --output comparison.md ``` +**Note**: The `--release` flag is recommended for BrowserStack runs to reduce APK size (debug: ~544MB, release: ~133MB) and prevent upload timeouts. + ### Adding New Test Functions 1. Add function to `crates/sample-fns/src/lib.rs` diff --git a/android/app/src/main/java/dev/world/bench/MainActivity.kt b/android/app/src/main/java/dev/world/bench/MainActivity.kt index cf46069..b65cc0e 100644 --- a/android/app/src/main/java/dev/world/bench/MainActivity.kt +++ b/android/app/src/main/java/dev/world/bench/MainActivity.kt @@ -66,6 +66,20 @@ class MainActivity : AppCompatActivity() { findViewById(R.id.result_text)?.text = display } + /** + * Formats a duration in nanoseconds to a human-readable string. + * Uses milliseconds (ms) by default, switches to seconds (s) if >= 1000ms. + */ + private fun formatDuration(ns: Long): String { + val ms = ns.toDouble() / 1_000_000.0 + return if (ms >= 1000.0) { + val secs = ms / 1000.0 + String.format("%.3fs", secs) + } else { + String.format("%.3fms", ms) + } + } + private fun formatBenchReport(report: BenchReport): String = buildString { appendLine("=== Benchmark Results ===") appendLine() @@ -75,20 +89,19 @@ class MainActivity : AppCompatActivity() { appendLine() appendLine("Samples (${report.samples.size}):") report.samples.forEachIndexed { index, sample -> - val durationUs = sample.durationNs.toDouble() / 1_000.0 - appendLine(" ${index + 1}. ${String.format("%.3f", durationUs)} μs (${sample.durationNs} ns)") + appendLine(" ${index + 1}. ${formatDuration(sample.durationNs.toLong())}") } if (report.samples.isNotEmpty()) { - val durations = report.samples.map { it.durationNs.toDouble() / 1_000.0 } - val min = durations.minOrNull() ?: 0.0 - val max = durations.maxOrNull() ?: 0.0 - val avg = durations.average() + val durations = report.samples.map { it.durationNs.toLong() } + val min = durations.minOrNull() ?: 0L + val max = durations.maxOrNull() ?: 0L + val avg = durations.sum().toDouble() / durations.size.toDouble() appendLine() appendLine("Statistics:") - appendLine(" Min: ${String.format("%.3f", min)} μs") - appendLine(" Max: ${String.format("%.3f", max)} μs") - appendLine(" Avg: ${String.format("%.3f", avg)} μs") + appendLine(" Min: ${formatDuration(min)}") + appendLine(" Max: ${formatDuration(max)}") + appendLine(" Avg: ${formatDuration(avg.toLong())}") } } diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index c83f8b8..c42c0b2 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -65,6 +65,20 @@ class MainActivity : AppCompatActivity() { findViewById(R.id.result_text)?.text = display } + /** + * Formats a duration in nanoseconds to a human-readable string. + * Uses milliseconds (ms) by default, switches to seconds (s) if >= 1000ms. + */ + private fun formatDuration(ns: Long): String { + val ms = ns.toDouble() / 1_000_000.0 + return if (ms >= 1000.0) { + val secs = ms / 1000.0 + String.format("%.3fs", secs) + } else { + String.format("%.3fms", ms) + } + } + private fun formatBenchReport(report: BenchReport): String = buildString { appendLine("=== Benchmark Results ===") appendLine() @@ -74,20 +88,19 @@ class MainActivity : AppCompatActivity() { appendLine() appendLine("Samples (${report.samples.size}):") report.samples.forEachIndexed { index, sample -> - val durationUs = sample.durationNs.toDouble() / 1_000.0 - appendLine(" ${index + 1}. ${String.format("%.3f", durationUs)} μs (${sample.durationNs} ns)") + appendLine(" ${index + 1}. ${formatDuration(sample.durationNs.toLong())}") } if (report.samples.isNotEmpty()) { - val durations = report.samples.map { it.durationNs.toDouble() / 1_000.0 } - val min = durations.minOrNull() ?: 0.0 - val max = durations.maxOrNull() ?: 0.0 - val avg = durations.average() + val durations = report.samples.map { it.durationNs.toLong() } + val min = durations.minOrNull() ?: 0L + val max = durations.maxOrNull() ?: 0L + val avg = durations.sum().toDouble() / durations.size.toDouble() appendLine() appendLine("Statistics:") - appendLine(" Min: ${String.format("%.3f", min)} μs") - appendLine(" Max: ${String.format("%.3f", max)} μs") - appendLine(" Avg: ${String.format("%.3f", avg)} μs") + appendLine(" Min: ${formatDuration(min)}") + appendLine(" Max: ${formatDuration(max)}") + appendLine(" Avg: ${formatDuration(avg.toLong())}") } } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index be78fae..e5d078c 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -134,6 +134,18 @@ enum {{PROJECT_NAME_PASCAL}}FFI { } } + /// Formats a duration in nanoseconds to a human-readable string. + /// Uses milliseconds (ms) by default, switches to seconds (s) if >= 1000ms. + private static func formatDuration(_ ns: UInt64) -> String { + let ms = Double(ns) / 1_000_000.0 + if ms >= 1000.0 { + let secs = ms / 1000.0 + return String(format: "%.3fs", secs) + } else { + return String(format: "%.3fms", ms) + } + } + private static func formatBenchReport(_ report: BenchReport) -> String { var output = "=== Benchmark Results ===\n\n" output += "Function: \(report.spec.name)\n" @@ -142,20 +154,19 @@ enum {{PROJECT_NAME_PASCAL}}FFI { output += "Samples (\(report.samples.count)):\n" for (index, sample) in report.samples.enumerated() { - let durationUs = Double(sample.durationNs) / 1_000.0 - output += " \(index + 1). \(String(format: "%.3f", durationUs)) μs (\(sample.durationNs) ns)\n" + output += " \(index + 1). \(formatDuration(sample.durationNs))\n" } if !report.samples.isEmpty { - let durations = report.samples.map { Double($0.durationNs) / 1_000.0 } - let min = durations.min() ?? 0.0 - let max = durations.max() ?? 0.0 - let avg = durations.reduce(0, +) / Double(durations.count) + let durations = report.samples.map { $0.durationNs } + let min = durations.min() ?? 0 + let max = durations.max() ?? 0 + let avg = durations.reduce(0, +) / UInt64(durations.count) output += "\nStatistics:\n" - output += " Min: \(String(format: "%.3f", min)) μs\n" - output += " Max: \(String(format: "%.3f", max)) μs\n" - output += " Avg: \(String(format: "%.3f", avg)) μs\n" + output += " Min: \(formatDuration(min))\n" + output += " Max: \(formatDuration(max))\n" + output += " Avg: \(formatDuration(avg))\n" } return output diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 10dfd50..9d67869 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -87,9 +87,12 @@ cargo mobench run \ --function fibonacci_30 \ --devices "Google Pixel 7-13.0" \ --iterations 100 \ - --warmup 10 + --warmup 10 \ + --release ``` +**Note**: Always use the `--release` flag for BrowserStack runs. Debug builds are significantly larger (~544MB vs ~133MB for release) and may cause upload timeouts. + ## Commands ### `init` - Initialize Project @@ -179,19 +182,21 @@ cargo mobench run --target --function [OPTIONS] # Run locally (no BrowserStack devices specified) cargo mobench run --target android --function fibonacci_30 -# Run on BrowserStack devices +# Run on BrowserStack devices (use --release for smaller APK) cargo mobench run \ --target android \ --function sha256_hash \ --devices "Google Pixel 7-13.0,Samsung Galaxy S23-13.0" \ --iterations 50 \ + --release \ --output results.json -# Run on iOS with auto-fetch +# Run on iOS with auto-fetch (use --release for smaller artifacts) cargo mobench run \ --target ios \ --function json_parse \ --devices "iPhone 14-16,iPhone 15-17" \ + --release \ --fetch ``` @@ -214,6 +219,26 @@ cargo mobench package-ipa --method adhoc **Output:** `target/mobench/ios/BenchRunner.ipa` +### `package-xcuitest` - Package XCUITest Runner + +Create the XCUITest runner package required for BrowserStack iOS testing: + +```bash +cargo mobench package-xcuitest [OPTIONS] +``` + +**Options:** +- `--scheme ` - Xcode scheme for UI tests (default: BenchRunnerUITests) + +**Example:** +```bash +cargo mobench package-xcuitest +``` + +**Output:** `target/mobench/ios/BenchRunnerUITests.zip` + +This command builds the XCUITest target and packages it into the zip format that BrowserStack expects for iOS test automation. + ### `plan` - Generate Device Matrix Create a template device matrix file: @@ -439,24 +464,26 @@ EOF # Build cargo mobench build --target android --release -# Run on multiple devices +# Run on multiple devices (use --release for BrowserStack) cargo mobench run \ --target android \ --function sha256_1kb \ --devices "Google Pixel 7-13.0,Samsung Galaxy S23-13.0,OnePlus 11-13.0" \ --iterations 200 \ + --release \ --output crypto-results.json ``` ### Compare iOS Performance ```bash -# Run same benchmark on different iOS versions +# Run same benchmark on different iOS versions (use --release for BrowserStack) cargo mobench run \ --target ios \ --function json_parse \ --devices "iPhone 13-15,iPhone 14-16,iPhone 15-17" \ --iterations 100 \ + --release \ --fetch \ --output ios-comparison.json ``` @@ -496,6 +523,7 @@ jobs: --function my_benchmark \ --devices "Google Pixel 7-13.0" \ --iterations 50 \ + --release \ --output results.json \ --fetch diff --git a/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift b/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift index 33b8aa9..8389310 100644 --- a/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift +++ b/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift @@ -78,6 +78,18 @@ enum BenchRunnerFFI { } } + /// Formats a duration in nanoseconds to a human-readable string. + /// Uses milliseconds (ms) by default, switches to seconds (s) if >= 1000ms. + private static func formatDuration(_ ns: UInt64) -> String { + let ms = Double(ns) / 1_000_000.0 + if ms >= 1000.0 { + let secs = ms / 1000.0 + return String(format: "%.3fs", secs) + } else { + return String(format: "%.3fms", ms) + } + } + private static func formatBenchReport(_ report: BenchReport) -> String { var output = "=== Benchmark Results ===\n\n" output += "Function: \(report.spec.name)\n" @@ -86,20 +98,19 @@ enum BenchRunnerFFI { output += "Samples (\(report.samples.count)):\n" for (index, sample) in report.samples.enumerated() { - let durationUs = Double(sample.durationNs) / 1_000.0 - output += " \(index + 1). \(String(format: "%.3f", durationUs)) μs (\(sample.durationNs) ns)\n" + output += " \(index + 1). \(formatDuration(sample.durationNs))\n" } if !report.samples.isEmpty { - let durations = report.samples.map { Double($0.durationNs) / 1_000.0 } - let min = durations.min() ?? 0.0 - let max = durations.max() ?? 0.0 - let avg = durations.reduce(0, +) / Double(durations.count) + let durations = report.samples.map { $0.durationNs } + let min = durations.min() ?? 0 + let max = durations.max() ?? 0 + let avg = durations.reduce(0, +) / UInt64(durations.count) output += "\nStatistics:\n" - output += " Min: \(String(format: "%.3f", min)) μs\n" - output += " Max: \(String(format: "%.3f", max)) μs\n" - output += " Avg: \(String(format: "%.3f", avg)) μs\n" + output += " Min: \(formatDuration(min))\n" + output += " Max: \(formatDuration(max))\n" + output += " Avg: \(formatDuration(avg))\n" } return output diff --git a/templates/android/app/src/main/java/MainActivity.kt.template b/templates/android/app/src/main/java/MainActivity.kt.template index 374da28..7b2e4c4 100644 --- a/templates/android/app/src/main/java/MainActivity.kt.template +++ b/templates/android/app/src/main/java/MainActivity.kt.template @@ -66,6 +66,20 @@ class MainActivity : AppCompatActivity() { findViewById(R.id.result_text)?.text = display } + /** + * Formats a duration in nanoseconds to a human-readable string. + * Uses milliseconds (ms) by default, switches to seconds (s) if >= 1000ms. + */ + private fun formatDuration(ns: Long): String { + val ms = ns.toDouble() / 1_000_000.0 + return if (ms >= 1000.0) { + val secs = ms / 1000.0 + String.format("%.3fs", secs) + } else { + String.format("%.3fms", ms) + } + } + private fun formatBenchReport(report: BenchReport): String = buildString { appendLine("=== Benchmark Results ===") appendLine() @@ -75,20 +89,19 @@ class MainActivity : AppCompatActivity() { appendLine() appendLine("Samples (${report.samples.size}):") report.samples.forEachIndexed { index, sample -> - val durationUs = sample.durationNs.toDouble() / 1_000.0 - appendLine(" ${index + 1}. ${String.format("%.3f", durationUs)} μs (${sample.durationNs} ns)") + appendLine(" ${index + 1}. ${formatDuration(sample.durationNs.toLong())}") } if (report.samples.isNotEmpty()) { - val durations = report.samples.map { it.durationNs.toDouble() / 1_000.0 } - val min = durations.minOrNull() ?: 0.0 - val max = durations.maxOrNull() ?: 0.0 - val avg = durations.average() + val durations = report.samples.map { it.durationNs.toLong() } + val min = durations.minOrNull() ?: 0L + val max = durations.maxOrNull() ?: 0L + val avg = durations.sum().toDouble() / durations.size.toDouble() appendLine() appendLine("Statistics:") - appendLine(" Min: ${String.format("%.3f", min)} μs") - appendLine(" Max: ${String.format("%.3f", max)} μs") - appendLine(" Avg: ${String.format("%.3f", avg)} μs") + appendLine(" Min: ${formatDuration(min)}") + appendLine(" Max: ${formatDuration(max)}") + appendLine(" Avg: ${formatDuration(avg.toLong())}") } } diff --git a/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index fcebaa5..3c2d9b6 100644 --- a/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -78,6 +78,18 @@ enum {{PROJECT_NAME_PASCAL}}FFI { } } + /// Formats a duration in nanoseconds to a human-readable string. + /// Uses milliseconds (ms) by default, switches to seconds (s) if >= 1000ms. + private static func formatDuration(_ ns: UInt64) -> String { + let ms = Double(ns) / 1_000_000.0 + if ms >= 1000.0 { + let secs = ms / 1000.0 + return String(format: "%.3fs", secs) + } else { + return String(format: "%.3fms", ms) + } + } + private static func formatBenchReport(_ report: BenchReport) -> String { var output = "=== Benchmark Results ===\n\n" output += "Function: \(report.spec.name)\n" @@ -86,20 +98,19 @@ enum {{PROJECT_NAME_PASCAL}}FFI { output += "Samples (\(report.samples.count)):\n" for (index, sample) in report.samples.enumerated() { - let durationUs = Double(sample.durationNs) / 1_000.0 - output += " \(index + 1). \(String(format: "%.3f", durationUs)) μs (\(sample.durationNs) ns)\n" + output += " \(index + 1). \(formatDuration(sample.durationNs))\n" } if !report.samples.isEmpty { - let durations = report.samples.map { Double($0.durationNs) / 1_000.0 } - let min = durations.min() ?? 0.0 - let max = durations.max() ?? 0.0 - let avg = durations.reduce(0, +) / Double(durations.count) + let durations = report.samples.map { $0.durationNs } + let min = durations.min() ?? 0 + let max = durations.max() ?? 0 + let avg = durations.reduce(0, +) / UInt64(durations.count) output += "\nStatistics:\n" - output += " Min: \(String(format: "%.3f", min)) μs\n" - output += " Max: \(String(format: "%.3f", max)) μs\n" - output += " Avg: \(String(format: "%.3f", avg)) μs\n" + output += " Min: \(formatDuration(min))\n" + output += " Max: \(formatDuration(max))\n" + output += " Avg: \(formatDuration(avg))\n" } return output From ac9679a39d8f072ab1b04435439385d1e43e1cf4 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 12:12:37 +0100 Subject: [PATCH 048/196] Fix iOS XCUITest benchmark report gap and add video capture delay iOS XCUITest was exiting in ~2 seconds without capturing benchmark data, while Android properly waited and extracted full timing reports. Changes: - XCUITest now waits up to 5 minutes for benchmark completion (not 2 sec) - Extract benchmark JSON via accessibility identifiers - Output JSON with BENCH_REPORT_JSON_START/END markers for log parsing - Add iOS-specific JSON extraction in fetch logic (handles NSLog prefixes) - Both Android and iOS now hold the report screen for 5 seconds so BrowserStack video recordings capture the benchmark results Files updated: - iOS XCUITest template and reference implementation - iOS ContentView with completion indicators and JSON exposure - iOS BenchRunnerFFI with JSON report generation - BrowserStack fetch logic for iOS log parsing - Android MainActivity with 5-second display hold --- .../main/java/dev/world/bench/MainActivity.kt | 5 + .../src/main/java/MainActivity.kt.template | 5 + .../BenchRunner/BenchRunnerFFI.swift.template | 109 ++++++- .../BenchRunner/ContentView.swift.template | 51 +++- .../BenchRunnerUITests.swift.template | 44 ++- crates/mobench/src/browserstack.rs | 267 +++++++++++++++++- crates/mobench/src/lib.rs | 134 +++++++++ .../BenchRunner/BenchRunnerFFI.swift | 109 ++++++- ios/BenchRunner/BenchRunner/ContentView.swift | 51 +++- .../BenchRunnerUITests.swift | 44 ++- mobench-ios-xcuitest-report-gap.md | 215 ++++++++++++++ .../src/main/java/MainActivity.kt.template | 5 + .../BenchRunner/ContentView.swift.template | 51 +++- 13 files changed, 1046 insertions(+), 44 deletions(-) create mode 100644 mobench-ios-xcuitest-report-gap.md diff --git a/android/app/src/main/java/dev/world/bench/MainActivity.kt b/android/app/src/main/java/dev/world/bench/MainActivity.kt index b65cc0e..bca6390 100644 --- a/android/app/src/main/java/dev/world/bench/MainActivity.kt +++ b/android/app/src/main/java/dev/world/bench/MainActivity.kt @@ -64,6 +64,11 @@ class MainActivity : AppCompatActivity() { } findViewById(R.id.result_text)?.text = display + + // Keep the report on screen for at least 5 seconds so BrowserStack video captures it + android.util.Log.i("BenchRunner", "Displaying results for 5 seconds for video capture...") + Thread.sleep(5000) + android.util.Log.i("BenchRunner", "Display hold complete") } /** diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index c42c0b2..a02cf34 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -63,6 +63,11 @@ class MainActivity : AppCompatActivity() { } findViewById(R.id.result_text)?.text = display + + // Keep the report on screen for at least 5 seconds so BrowserStack video captures it + android.util.Log.i("BenchRunner", "Displaying results for 5 seconds for video capture...") + Thread.sleep(5000) + android.util.Log.i("BenchRunner", "Display hold complete") } /** diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index e5d078c..8ab114d 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -109,13 +109,19 @@ struct BenchParams { } } +/// Result of running a benchmark, containing both display text and JSON report +struct BenchmarkResult { + let displayText: String + let jsonReport: String +} + enum {{PROJECT_NAME_PASCAL}}FFI { - static func runCurrentBenchmark() async -> String { + static func runCurrentBenchmark() async -> BenchmarkResult { let params = BenchParams.resolved() return run(params: params) } - static func run(params: BenchParams) -> String { + static func run(params: BenchParams) -> BenchmarkResult { let spec = BenchSpec( name: params.function, iterations: params.iterations, @@ -124,13 +130,106 @@ enum {{PROJECT_NAME_PASCAL}}FFI { do { let report = try runBenchmark(spec: spec) - return formatBenchReport(report) + let displayText = formatBenchReport(report) + let jsonReport = generateJSONReport(report) + return BenchmarkResult(displayText: displayText, jsonReport: jsonReport) } catch let error as BenchError { print("[BenchRunner] ERROR: Benchmark failed: \(error)") - return formatBenchError(error) + let errorText = formatBenchError(error) + let errorJSON = generateErrorJSON(error) + return BenchmarkResult(displayText: errorText, jsonReport: errorJSON) } catch { print("[BenchRunner] ERROR: Unexpected error during benchmark execution: \(error)") - return "Unexpected error: \(error.localizedDescription)" + let errorText = "Unexpected error: \(error.localizedDescription)" + let errorJSON = "{\"error\": \"Unexpected error: \(error.localizedDescription)\"}" + return BenchmarkResult(displayText: errorText, jsonReport: errorJSON) + } + } + + /// Generates a JSON report matching the Android BENCH_JSON format for consistency + private static func generateJSONReport(_ report: BenchReport) -> String { + var json: [String: Any] = [:] + + // Spec section + let specDict: [String: Any] = [ + "name": report.spec.name, + "iterations": report.spec.iterations, + "warmup": report.spec.warmup + ] + json["spec"] = specDict + + // Function name at top level (for compatibility with existing parsers) + json["function"] = report.spec.name + + // Samples as array of duration_ns values + let samplesNs = report.samples.map { $0.durationNs } + json["samples_ns"] = samplesNs + + // Also include samples in object format for compatibility + let samplesArray = report.samples.map { ["duration_ns": $0.durationNs] } + json["samples"] = samplesArray + + // Statistics + if !report.samples.isEmpty { + let durations = report.samples.map { $0.durationNs } + let minNs = durations.min() ?? 0 + let maxNs = durations.max() ?? 0 + let sumNs = durations.reduce(0, +) + let avgNs = Double(sumNs) / Double(durations.count) + + // Compute median + let sorted = durations.sorted() + let medianNs: UInt64 + if sorted.count % 2 == 0 { + medianNs = (sorted[sorted.count / 2 - 1] + sorted[sorted.count / 2]) / 2 + } else { + medianNs = sorted[sorted.count / 2] + } + + let stats: [String: Any] = [ + "min_ns": minNs, + "max_ns": maxNs, + "avg_ns": avgNs, + "mean_ns": UInt64(avgNs), + "median_ns": medianNs + ] + json["stats"] = stats + + // Also add top-level stats for compatibility with different parsers + json["mean_ns"] = UInt64(avgNs) + json["min_ns"] = minNs + json["max_ns"] = maxNs + } + + // Resource metrics (iOS-specific) + let resources: [String: Any] = [ + "platform": "ios", + "timestamp_ms": Int64(Date().timeIntervalSince1970 * 1000) + ] + json["resources"] = resources + + // Serialize to JSON string + do { + let data = try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{}" + } catch { + print("[BenchRunner] ERROR: Failed to serialize JSON report: \(error)") + return "{}" + } + } + + /// Generates a JSON error report + private static func generateErrorJSON(_ error: BenchError) -> String { + let errorDict: [String: Any] = [ + "error": true, + "message": error.localizedDescription + ] + + do { + let data = try JSONSerialization.data(withJSONObject: errorDict, options: [.sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{\"error\": true}" + } catch { + return "{\"error\": true, \"message\": \"Failed to serialize error\"}" } } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template index 1264f44..d288952 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template @@ -2,19 +2,54 @@ import SwiftUI struct ContentView: View { @State private var report: String = "Running benchmark..." + @State private var reportJSON: String = "" + @State private var isCompleted: Bool = false var body: some View { - ScrollView { - Text(report) - .font(.system(.body, design: .monospaced)) - .accessibilityIdentifier("benchmarkReport") - .frame(maxWidth: .infinity, alignment: .leading) - .padding() + ZStack { + ScrollView { + Text(report) + .font(.system(.body, design: .monospaced)) + .accessibilityIdentifier("benchmarkReport") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .background(Color(UIColor.systemBackground)) + + // Hidden elements for XCUITest to read benchmark data + // These are invisible but accessible for automation + VStack { + // Completion indicator - XCUITest waits for this to exist + if isCompleted { + Text("completed") + .accessibilityIdentifier("benchmarkCompleted") + .frame(width: 0, height: 0) + .opacity(0) + } + + // JSON report element - stores the full benchmark JSON in its label + Text(reportJSON) + .accessibilityIdentifier("benchmarkReportJSON") + .frame(width: 0, height: 0) + .opacity(0) + } } - .background(Color(UIColor.systemBackground)) .onAppear { Task { - report = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + let result = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + report = result.displayText + reportJSON = result.jsonReport + isCompleted = true + + // Log the JSON report with markers for BrowserStack device logs + NSLog("BENCH_REPORT_JSON_START") + NSLog("%@", result.jsonReport) + NSLog("BENCH_REPORT_JSON_END") + + // Keep the report on screen for at least 5 seconds so BrowserStack video captures it + NSLog("Displaying results for 5 seconds for video capture...") + try? await Task.sleep(nanoseconds: 5_000_000_000) + NSLog("Display hold complete") } } } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template index 595b47a..70f7cb7 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template @@ -1,12 +1,48 @@ import XCTest final class {{PROJECT_NAME_PASCAL}}UITests: XCTestCase { - func testLaunchShowsBenchmarkReport() { + + /// Maximum time to wait for benchmark completion (5 minutes for long benchmarks) + private let benchmarkTimeout: TimeInterval = 300.0 + + func testLaunchAndCaptureBenchmarkReport() { let app = XCUIApplication() app.launch() - let report = app.staticTexts["benchmarkReport"] - let exists = report.waitForExistence(timeout: 30.0) - XCTAssertTrue(exists, "Benchmark report text should appear after launch") + // Wait for the benchmark to actually COMPLETE, not just start + // The app sets a "benchmarkCompleted" element when done + let completedIndicator = app.staticTexts["benchmarkCompleted"] + let completed = completedIndicator.waitForExistence(timeout: benchmarkTimeout) + XCTAssertTrue(completed, "Benchmark should complete within \(benchmarkTimeout) seconds") + + // Give UI a moment to update with final results + Thread.sleep(forTimeInterval: 0.5) + + // Extract the benchmark report JSON from the hidden element + let reportElement = app.staticTexts["benchmarkReportJSON"] + XCTAssertTrue(reportElement.exists, "Benchmark report JSON element should exist after completion") + + // The JSON is stored in the element's label property + let jsonString = reportElement.label + + // Log with markers that mobench fetch can parse from instrumentation logs + // Using NSLog to ensure it goes to device logs that BrowserStack captures + NSLog("BENCH_REPORT_JSON_START") + NSLog("%@", jsonString) + NSLog("BENCH_REPORT_JSON_END") + + // Also print to stdout for local testing visibility + print("BENCH_REPORT_JSON_START") + print(jsonString) + print("BENCH_REPORT_JSON_END") + + // Verify we got valid JSON (not an error message) + XCTAssertFalse(jsonString.isEmpty, "Benchmark report JSON should not be empty") + XCTAssertTrue(jsonString.hasPrefix("{"), "Benchmark report should be valid JSON (starts with '{')") + } + + // Keep the old test name for backward compatibility + func testLaunchShowsBenchmarkReport() { + testLaunchAndCaptureBenchmarkReport() } } diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index 14931e0..ccce203 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -352,10 +352,32 @@ impl BrowserStackClient { /// Extract benchmark results from device logs /// Looks for JSON output matching BenchReport format + /// Supports both Android (BENCH_JSON) and iOS (BENCH_REPORT_JSON_START/END) formats pub fn extract_benchmark_results(&self, logs: &str) -> Result> { let mut results = Vec::new(); - // Look for JSON objects that contain benchmark-related fields + // First, try iOS-style markers: BENCH_REPORT_JSON_START ... BENCH_REPORT_JSON_END + if let Some(json) = Self::extract_ios_bench_json(logs) { + results.push(json); + } + + // Also look for Android-style BENCH_JSON marker + let bench_json_marker = "BENCH_JSON "; + for line in logs.lines() { + if let Some(idx) = line.find(bench_json_marker) { + let json_part = &line[idx + bench_json_marker.len()..]; + if let Ok(json) = serde_json::from_str::(json_part) { + if json.get("function").is_some() + || json.get("samples").is_some() + || json.get("spec").is_some() + { + results.push(json); + } + } + } + } + + // Look for JSON objects that contain benchmark-related fields (fallback) for line in logs.lines() { let trimmed = line.trim(); let looks_like_json = trimmed.starts_with('{') && trimmed.ends_with('}'); @@ -365,7 +387,13 @@ impl BrowserStackClient { && let Ok(json) = serde_json::from_str::(trimmed) && (json.get("function").is_some() || json.get("samples").is_some()) { - results.push(json); + // Avoid duplicates + if !results + .iter() + .any(|existing| existing.to_string() == json.to_string()) + { + results.push(json); + } } } @@ -376,6 +404,118 @@ impl BrowserStackClient { } } + /// Extract benchmark JSON from iOS logs using START/END markers. + /// iOS uses NSLog which may split the JSON across multiple log lines. + fn extract_ios_bench_json(logs: &str) -> Option { + let start_marker = "BENCH_REPORT_JSON_START"; + let end_marker = "BENCH_REPORT_JSON_END"; + + // Find the last occurrence of start marker (in case of multiple runs) + let start_pos = logs.rfind(start_marker)?; + let after_start = &logs[start_pos + start_marker.len()..]; + + // Find the end marker after the start + let end_pos = after_start.find(end_marker)?; + let json_section = &after_start[..end_pos]; + + // Try to extract valid JSON from the section + Self::extract_json_from_ios_log_section(json_section) + } + + /// Extract valid JSON from an iOS log section that may contain log prefixes/timestamps. + fn extract_json_from_ios_log_section(section: &str) -> Option { + // First, try the whole section as-is (trimmed) + let trimmed = section.trim(); + if trimmed.starts_with('{') && trimmed.ends_with('}') { + if let Ok(json) = serde_json::from_str::(trimmed) { + return Some(json); + } + } + + // Look for JSON on individual lines, stripping iOS log prefixes + for line in section.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Look for JSON starting with { + if let Some(json_start) = line.find('{') { + let potential_json = &line[json_start..]; + if let Some(json) = Self::extract_balanced_json(potential_json) { + if let Ok(parsed) = serde_json::from_str::(&json) { + return Some(parsed); + } + } + } + } + + // Try concatenating all lines (for multi-line JSON) + let all_content: String = section + .lines() + .map(|line| { + // Strip common iOS log prefixes (timestamps, process info) + // Format: "2026-01-20 12:34:56.789 AppName[pid:tid] content" + if let Some(bracket_end) = line.find("] ") { + &line[bracket_end + 2..] + } else { + line.trim() + } + }) + .collect::>() + .join(""); + + if let Some(json_start) = all_content.find('{') { + let potential_json = &all_content[json_start..]; + if let Some(json) = Self::extract_balanced_json(potential_json) { + if let Ok(parsed) = serde_json::from_str::(&json) { + return Some(parsed); + } + } + } + + None + } + + /// Extract a balanced JSON object from a string starting with '{'. + fn extract_balanced_json(s: &str) -> Option { + if !s.starts_with('{') { + return None; + } + + let mut depth = 0; + let mut in_string = false; + let mut escape_next = false; + + for (i, c) in s.char_indices() { + if escape_next { + escape_next = false; + continue; + } + + match c { + '\\' if in_string => { + escape_next = true; + } + '"' => { + in_string = !in_string; + } + '{' if !in_string => { + depth += 1; + } + '}' if !in_string => { + depth -= 1; + if depth == 0 { + return Some(s[..=i].to_string()); + } + } + _ => {} + } + } + + None + } + /// Extract performance metrics from device logs /// Looks for JSON objects with "type":"performance" or similar performance indicators pub fn extract_performance_metrics(&self, logs: &str) -> Result { @@ -1352,4 +1492,127 @@ Test completed assert_eq!(cpu.min_percent, 30.0); assert_eq!(cpu.average_percent, 40.0); // (30 + 50) / 2 } + + #[test] + fn extract_benchmark_results_handles_ios_markers() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + // Simulate iOS XCUITest logs with BENCH_REPORT_JSON_START/END markers + let logs = r#" +2026-01-20 12:34:56.789 BenchRunner[1234:5678] Starting benchmark... +2026-01-20 12:34:57.123 BenchRunner[1234:5678] BENCH_REPORT_JSON_START +2026-01-20 12:34:57.124 BenchRunner[1234:5678] {"function": "sample_fns::fibonacci", "samples": [{"duration_ns": 1000000}, {"duration_ns": 1200000}], "mean_ns": 1100000} +2026-01-20 12:34:57.125 BenchRunner[1234:5678] BENCH_REPORT_JSON_END +2026-01-20 12:34:57.200 BenchRunner[1234:5678] Test completed + "#; + + let results = client.extract_benchmark_results(logs).unwrap(); + assert!(!results.is_empty(), "Should find benchmark results"); + + let first = &results[0]; + assert_eq!( + first.get("function").unwrap().as_str().unwrap(), + "sample_fns::fibonacci" + ); + assert_eq!(first.get("mean_ns").unwrap().as_u64().unwrap(), 1100000); + } + + #[test] + fn extract_benchmark_results_handles_ios_raw_json() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + // Simulate iOS logs with raw JSON between markers (no log prefix on JSON line) + let logs = r#" +BENCH_REPORT_JSON_START +{"function": "test_fn", "samples": [{"duration_ns": 500000}], "mean_ns": 500000} +BENCH_REPORT_JSON_END + "#; + + let results = client.extract_benchmark_results(logs).unwrap(); + assert!(!results.is_empty()); + assert_eq!( + results[0].get("function").unwrap().as_str().unwrap(), + "test_fn" + ); + } + + #[test] + fn extract_benchmark_results_handles_android_bench_json_marker() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + // Simulate Android logs with BENCH_JSON marker + let logs = r#" +2026-01-20 12:34:56 I/BenchRunner: Starting benchmark... +2026-01-20 12:34:57 I/BenchRunner: BENCH_JSON {"spec": {"name": "sample_fns::checksum"}, "samples_ns": [1000, 2000], "function": "sample_fns::checksum"} +2026-01-20 12:34:58 I/BenchRunner: Test completed + "#; + + let results = client.extract_benchmark_results(logs).unwrap(); + assert!(!results.is_empty()); + assert!(results + .iter() + .any(|r| r.get("function").and_then(|f| f.as_str()) == Some("sample_fns::checksum"))); + } + + #[test] + fn extract_ios_bench_json_finds_last_occurrence() { + // Test that we find the last occurrence of markers (in case of multiple runs) + let logs = r#" +BENCH_REPORT_JSON_START +{"function": "first_run", "samples": []} +BENCH_REPORT_JSON_END +Some other logs +BENCH_REPORT_JSON_START +{"function": "second_run", "samples": []} +BENCH_REPORT_JSON_END + "#; + + let result = BrowserStackClient::extract_ios_bench_json(logs); + assert!(result.is_some()); + assert_eq!( + result.unwrap().get("function").unwrap().as_str().unwrap(), + "second_run" + ); + } + + #[test] + fn extract_balanced_json_handles_nested_objects() { + let input = r#"{"outer": {"inner": {"value": 42}}, "extra": "text"} more stuff"#; + let result = BrowserStackClient::extract_balanced_json(input); + assert!(result.is_some()); + let json = result.unwrap(); + assert!(json.contains("outer")); + assert!(json.contains("inner")); + assert!(!json.contains("more stuff")); + } + + #[test] + fn extract_balanced_json_handles_strings_with_braces() { + let input = r#"{"message": "Hello {world}"}"#; + let result = BrowserStackClient::extract_balanced_json(input); + assert!(result.is_some()); + let json = result.unwrap(); + assert_eq!(json, input); + } } diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index ce8bfe8..4998df8 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -1002,6 +1002,13 @@ fn filename_for_url(key: &str, url: &str) -> String { } fn extract_bench_json(contents: &str) -> Option { + // First, try iOS-style markers: BENCH_REPORT_JSON_START ... BENCH_REPORT_JSON_END + // This allows multi-line JSON and is more robust for iOS NSLog output + if let Some(json) = extract_bench_json_ios_markers(contents) { + return Some(json); + } + + // Fall back to Android-style single-line marker: BENCH_JSON {...} let marker = "BENCH_JSON "; for line in contents.lines().rev() { if let Some(idx) = line.find(marker) { @@ -1014,6 +1021,133 @@ fn extract_bench_json(contents: &str) -> Option { None } +/// Extract benchmark JSON from iOS logs using START/END markers. +/// iOS uses NSLog which may split the JSON across multiple log lines, +/// so we need to capture everything between the markers. +fn extract_bench_json_ios_markers(contents: &str) -> Option { + let start_marker = "BENCH_REPORT_JSON_START"; + let end_marker = "BENCH_REPORT_JSON_END"; + + // Find the last occurrence of start marker (in case of multiple runs) + let start_pos = contents.rfind(start_marker)?; + let after_start = &contents[start_pos + start_marker.len()..]; + + // Find the end marker after the start + let end_pos = after_start.find(end_marker)?; + let json_section = &after_start[..end_pos]; + + // The JSON might be on the next line or have log prefixes, so we need to clean it up + // iOS NSLog format often looks like: "2026-01-20 12:34:56.789 BenchRunner[1234:5678] {"key": "value"}" + // or just the raw JSON on its own line + + // Try to find valid JSON in the section + let json_str = extract_json_from_log_section(json_section)?; + + serde_json::from_str::(&json_str).ok() +} + +/// Extract valid JSON from a log section that may contain log prefixes/timestamps. +/// Handles both raw JSON and JSON embedded in log lines. +fn extract_json_from_log_section(section: &str) -> Option { + // First, try the whole section as-is (trimmed) + let trimmed = section.trim(); + if trimmed.starts_with('{') && trimmed.ends_with('}') { + if serde_json::from_str::(trimmed).is_ok() { + return Some(trimmed.to_string()); + } + } + + // If that didn't work, look for JSON on individual lines + // This handles cases where NSLog adds timestamps/prefixes + for line in section.lines() { + let line = line.trim(); + + // Skip empty lines + if line.is_empty() { + continue; + } + + // Look for JSON starting with { + if let Some(json_start) = line.find('{') { + let potential_json = &line[json_start..]; + + // Try to find the matching closing brace + // This handles cases like: "timestamp prefix {"key": "value"} suffix" + if let Some(json) = extract_balanced_json(potential_json) { + if serde_json::from_str::(&json).is_ok() { + return Some(json); + } + } + } + } + + // Try concatenating all lines and looking for JSON (for multi-line JSON) + let all_content: String = section + .lines() + .map(|line| { + // Try to strip common log prefixes (timestamps, process info) + // iOS format: "2026-01-20 12:34:56.789 AppName[pid:tid] content" + if let Some(bracket_end) = line.find("] ") { + &line[bracket_end + 2..] + } else { + line.trim() + } + }) + .collect::>() + .join(""); + + if let Some(json_start) = all_content.find('{') { + let potential_json = &all_content[json_start..]; + if let Some(json) = extract_balanced_json(potential_json) { + if serde_json::from_str::(&json).is_ok() { + return Some(json); + } + } + } + + None +} + +/// Extract a balanced JSON object from a string starting with '{'. +/// Returns the JSON substring if balanced braces are found. +fn extract_balanced_json(s: &str) -> Option { + if !s.starts_with('{') { + return None; + } + + let mut depth = 0; + let mut in_string = false; + let mut escape_next = false; + + for (i, c) in s.char_indices() { + if escape_next { + escape_next = false; + continue; + } + + match c { + '\\' if in_string => { + escape_next = true; + } + '"' => { + in_string = !in_string; + } + '{' if !in_string => { + depth += 1; + } + '}' if !in_string => { + depth -= 1; + if depth == 0 { + return Some(s[..=i].to_string()); + } + } + _ => {} + } + } + + None +} + fn write_json(path: PathBuf, value: &Value) -> Result<()> { let contents = serde_json::to_string_pretty(value)?; write_file(&path, contents.as_bytes()) diff --git a/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift b/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift index 8389310..654b6f7 100644 --- a/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift +++ b/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift @@ -55,13 +55,19 @@ struct BenchParams { } } +/// Result of running a benchmark, containing both display text and JSON report +struct BenchmarkResult { + let displayText: String + let jsonReport: String +} + enum BenchRunnerFFI { - static func runCurrentBenchmark() async -> String { + static func runCurrentBenchmark() async -> BenchmarkResult { let params = BenchParams.resolved() return run(params: params) } - static func run(params: BenchParams) -> String { + static func run(params: BenchParams) -> BenchmarkResult { let spec = BenchSpec( name: params.function, iterations: params.iterations, @@ -70,11 +76,104 @@ enum BenchRunnerFFI { do { let report = try runBenchmark(spec: spec) - return formatBenchReport(report) + let displayText = formatBenchReport(report) + let jsonReport = generateJSONReport(report) + return BenchmarkResult(displayText: displayText, jsonReport: jsonReport) } catch let error as BenchError { - return formatBenchError(error) + let errorText = formatBenchError(error) + let errorJSON = generateErrorJSON(error) + return BenchmarkResult(displayText: errorText, jsonReport: errorJSON) + } catch { + let errorText = "Unexpected error: \(error.localizedDescription)" + let errorJSON = "{\"error\": \"Unexpected error: \(error.localizedDescription)\"}" + return BenchmarkResult(displayText: errorText, jsonReport: errorJSON) + } + } + + /// Generates a JSON report matching the Android BENCH_JSON format for consistency + private static func generateJSONReport(_ report: BenchReport) -> String { + var json: [String: Any] = [:] + + // Spec section + let specDict: [String: Any] = [ + "name": report.spec.name, + "iterations": report.spec.iterations, + "warmup": report.spec.warmup + ] + json["spec"] = specDict + + // Function name at top level (for compatibility with existing parsers) + json["function"] = report.spec.name + + // Samples as array of duration_ns values + let samplesNs = report.samples.map { $0.durationNs } + json["samples_ns"] = samplesNs + + // Also include samples in object format for compatibility + let samplesArray = report.samples.map { ["duration_ns": $0.durationNs] } + json["samples"] = samplesArray + + // Statistics + if !report.samples.isEmpty { + let durations = report.samples.map { $0.durationNs } + let minNs = durations.min() ?? 0 + let maxNs = durations.max() ?? 0 + let sumNs = durations.reduce(0, +) + let avgNs = Double(sumNs) / Double(durations.count) + + // Compute median + let sorted = durations.sorted() + let medianNs: UInt64 + if sorted.count % 2 == 0 { + medianNs = (sorted[sorted.count / 2 - 1] + sorted[sorted.count / 2]) / 2 + } else { + medianNs = sorted[sorted.count / 2] + } + + let stats: [String: Any] = [ + "min_ns": minNs, + "max_ns": maxNs, + "avg_ns": avgNs, + "mean_ns": UInt64(avgNs), + "median_ns": medianNs + ] + json["stats"] = stats + + // Also add top-level stats for compatibility with different parsers + json["mean_ns"] = UInt64(avgNs) + json["min_ns"] = minNs + json["max_ns"] = maxNs + } + + // Resource metrics (iOS-specific) + let resources: [String: Any] = [ + "platform": "ios", + "timestamp_ms": Int64(Date().timeIntervalSince1970 * 1000) + ] + json["resources"] = resources + + // Serialize to JSON string + do { + let data = try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{}" + } catch { + print("[BenchRunner] ERROR: Failed to serialize JSON report: \(error)") + return "{}" + } + } + + /// Generates a JSON error report + private static func generateErrorJSON(_ error: BenchError) -> String { + let errorDict: [String: Any] = [ + "error": true, + "message": error.localizedDescription + ] + + do { + let data = try JSONSerialization.data(withJSONObject: errorDict, options: [.sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{\"error\": true}" } catch { - return "Unexpected error: \(error.localizedDescription)" + return "{\"error\": true, \"message\": \"Failed to serialize error\"}" } } diff --git a/ios/BenchRunner/BenchRunner/ContentView.swift b/ios/BenchRunner/BenchRunner/ContentView.swift index 4ce7837..4dc29b1 100644 --- a/ios/BenchRunner/BenchRunner/ContentView.swift +++ b/ios/BenchRunner/BenchRunner/ContentView.swift @@ -2,19 +2,54 @@ import SwiftUI struct ContentView: View { @State private var report: String = "Running benchmark..." + @State private var reportJSON: String = "" + @State private var isCompleted: Bool = false var body: some View { - ScrollView { - Text(report) - .font(.system(.body, design: .monospaced)) - .accessibilityIdentifier("benchmarkReport") - .frame(maxWidth: .infinity, alignment: .leading) - .padding() + ZStack { + ScrollView { + Text(report) + .font(.system(.body, design: .monospaced)) + .accessibilityIdentifier("benchmarkReport") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .background(Color(UIColor.systemBackground)) + + // Hidden elements for XCUITest to read benchmark data + // These are invisible but accessible for automation + VStack { + // Completion indicator - XCUITest waits for this to exist + if isCompleted { + Text("completed") + .accessibilityIdentifier("benchmarkCompleted") + .frame(width: 0, height: 0) + .opacity(0) + } + + // JSON report element - stores the full benchmark JSON in its label + Text(reportJSON) + .accessibilityIdentifier("benchmarkReportJSON") + .frame(width: 0, height: 0) + .opacity(0) + } } - .background(Color(UIColor.systemBackground)) .onAppear { Task { - report = await BenchRunnerFFI.runCurrentBenchmark() + let result = await BenchRunnerFFI.runCurrentBenchmark() + report = result.displayText + reportJSON = result.jsonReport + isCompleted = true + + // Log the JSON report with markers for BrowserStack device logs + NSLog("BENCH_REPORT_JSON_START") + NSLog("%@", result.jsonReport) + NSLog("BENCH_REPORT_JSON_END") + + // Keep the report on screen for at least 5 seconds so BrowserStack video captures it + NSLog("Displaying results for 5 seconds for video capture...") + try? await Task.sleep(nanoseconds: 5_000_000_000) + NSLog("Display hold complete") } } } diff --git a/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift b/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift index 2f32465..e14a8cb 100644 --- a/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift +++ b/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift @@ -1,12 +1,48 @@ import XCTest final class BenchRunnerUITests: XCTestCase { - func testLaunchShowsBenchmarkReport() { + + /// Maximum time to wait for benchmark completion (5 minutes for long benchmarks) + private let benchmarkTimeout: TimeInterval = 300.0 + + func testLaunchAndCaptureBenchmarkReport() { let app = XCUIApplication() app.launch() - let report = app.staticTexts["benchmarkReport"] - let exists = report.waitForExistence(timeout: 30.0) - XCTAssertTrue(exists, "Benchmark report text should appear after launch") + // Wait for the benchmark to actually COMPLETE, not just start + // The app sets a "benchmarkCompleted" element when done + let completedIndicator = app.staticTexts["benchmarkCompleted"] + let completed = completedIndicator.waitForExistence(timeout: benchmarkTimeout) + XCTAssertTrue(completed, "Benchmark should complete within \(benchmarkTimeout) seconds") + + // Give UI a moment to update with final results + Thread.sleep(forTimeInterval: 0.5) + + // Extract the benchmark report JSON from the hidden element + let reportElement = app.staticTexts["benchmarkReportJSON"] + XCTAssertTrue(reportElement.exists, "Benchmark report JSON element should exist after completion") + + // The JSON is stored in the element's label property + let jsonString = reportElement.label + + // Log with markers that mobench fetch can parse from instrumentation logs + // Using NSLog to ensure it goes to device logs that BrowserStack captures + NSLog("BENCH_REPORT_JSON_START") + NSLog("%@", jsonString) + NSLog("BENCH_REPORT_JSON_END") + + // Also print to stdout for local testing visibility + print("BENCH_REPORT_JSON_START") + print(jsonString) + print("BENCH_REPORT_JSON_END") + + // Verify we got valid JSON (not an error message) + XCTAssertFalse(jsonString.isEmpty, "Benchmark report JSON should not be empty") + XCTAssertTrue(jsonString.hasPrefix("{"), "Benchmark report should be valid JSON (starts with '{')") + } + + // Keep the old test name for backward compatibility + func testLaunchShowsBenchmarkReport() { + testLaunchAndCaptureBenchmarkReport() } } diff --git a/mobench-ios-xcuitest-report-gap.md b/mobench-ios-xcuitest-report-gap.md new file mode 100644 index 0000000..31618cd --- /dev/null +++ b/mobench-ios-xcuitest-report-gap.md @@ -0,0 +1,215 @@ +# mobench iOS XCUITest Benchmark Report Gap + +**Date:** 2026-01-20 +**Priority:** P1 - Feature gap preventing iOS benchmark data collection +**Affects:** `mobench fetch` for iOS builds + +--- + +## Summary + +The iOS XCUITest template does not capture benchmark results like the Android Espresso test does. After running benchmarks on BrowserStack, Android returns a complete `bench-report.json` with timing data, while iOS returns nothing. + +--- + +## Current Behavior + +### Android (Working) ✅ + +The Android Espresso test: +1. Launches the app +2. Waits for the benchmark to **complete** +3. Extracts the benchmark JSON from the app +4. Saves `bench-report.json` with full results + +**Result from `mobench fetch`:** +``` +session-xxx/ +├── bench-report.json ✅ Contains timing data +├── session.json +├── device_log.log +├── instrumentation_log.log +└── video.log +``` + +**bench-report.json contents:** +```json +{ + "spec": { + "name": "bench_mobile::bench_query_proof_generation", + "iterations": 20, + "warmup": 3 + }, + "samples_ns": [395761841, 404437540, ...], + "stats": { + "avg_ns": 415465220.25, + "min_ns": 389723715, + "max_ns": 459591756 + }, + "resources": { + "elapsed_cpu_ms": 52129, + "java_heap_kb": 2770, + "total_pss_kb": 71489 + } +} +``` + +### iOS (Not Working) ❌ + +The iOS XCUITest only checks that the UI exists: + +**Current template** (`BenchRunnerUITests.swift`): +```swift +final class BenchRunnerUITests: XCTestCase { + func testLaunchShowsBenchmarkReport() { + let app = XCUIApplication() + app.launch() + + let report = app.staticTexts["benchmarkReport"] + let exists = report.waitForExistence(timeout: 30.0) + XCTAssertTrue(exists, "Benchmark report text should appear after launch") + } +} +``` + +**Problems:** +1. Only waits for element to **exist** (2 seconds), not for benchmark to **complete** (could be 30+ seconds) +2. Does not extract the benchmark report text +3. Does not save any JSON output + +**Result from `mobench fetch`:** +``` +session-xxx/ +├── session.json +├── device_log.log (empty) +├── instrumentation_log.log +└── video.log + ❌ NO bench-report.json +``` + +--- + +## Evidence from BrowserStack Logs + +**iOS instrumentation log:** +``` +t = 1.19s Waiting 30.0s for "benchmarkReport" StaticText to exist +t = 2.20s Checking existence of `"benchmarkReport" StaticText` +t = 2.23s Tear Down +Test Case passed (2.429 seconds). +``` + +The test found the element in 2.2 seconds and exited immediately. The actual benchmark (which takes 400ms × 20 iterations = 8+ seconds minimum) never ran or was ignored. + +**Android instrumentation log (for comparison):** +- Session duration: **50 seconds** +- Captured 20 benchmark samples +- Full resource metrics + +--- + +## Required Changes + +### 1. Update iOS XCUITest Template + +The test needs to: + +```swift +final class BenchRunnerUITests: XCTestCase { + func testLaunchAndCaptureBenchmarkReport() { + let app = XCUIApplication() + app.launch() + + // 1. Wait for benchmark to COMPLETE (not just start) + // Look for a "completed" indicator or specific text pattern + let completedIndicator = app.staticTexts["benchmarkCompleted"] + let completed = completedIndicator.waitForExistence(timeout: 300.0) // 5 min timeout + XCTAssertTrue(completed, "Benchmark should complete") + + // 2. Extract the benchmark report JSON from the UI + let reportText = app.staticTexts["benchmarkReportJSON"] + XCTAssertTrue(reportText.exists, "Benchmark report JSON should exist") + + // 3. Log it in a format that mobench fetch can parse + let jsonString = reportText.label + print("BENCH_REPORT_JSON_START") + print(jsonString) + print("BENCH_REPORT_JSON_END") + + // Or write to a known file location that BrowserStack can retrieve + } +} +``` + +### 2. Update iOS App to Expose Report Data + +The `BenchRunner` iOS app needs to: +1. Show a "completed" indicator when benchmark finishes +2. Expose the full JSON report in an accessible UI element (or via `XCUIApplication.launchArguments` output) + +### 3. Update `mobench fetch` for iOS + +Parse the instrumentation log or device log for the benchmark JSON: +```rust +// In fetch command for iOS +fn extract_ios_bench_report(instrumentation_log: &str) -> Option { + // Look for BENCH_REPORT_JSON_START ... BENCH_REPORT_JSON_END + // Parse and save as bench-report.json +} +``` + +--- + +## Comparison: Android vs iOS Test Flow + +| Step | Android (Espresso) | iOS (XCUITest) Current | iOS (XCUITest) Required | +|------|-------------------|------------------------|------------------------| +| Launch app | ✅ | ✅ | ✅ | +| Wait for benchmark complete | ✅ Waits for completion | ❌ Only checks existence | ✅ Need to wait | +| Extract JSON report | ✅ Via Espresso | ❌ Not implemented | ✅ Via XCUITest | +| Save bench-report.json | ✅ Automatic | ❌ Missing | ✅ Need to implement | +| Duration | 50 seconds | 2 seconds | Should be ~50 seconds | + +--- + +## Files to Modify + +1. **`crates/mobench-sdk/templates/ios/BenchRunnerUITests/BenchRunnerUITests.swift`** + - Rewrite test to wait for completion and extract report + +2. **`crates/mobench-sdk/templates/ios/BenchRunner/ContentView.swift`** (or equivalent) + - Add accessibility identifiers for completed state and JSON report + +3. **`crates/mobench/src/fetch.rs`** (or equivalent) + - Add iOS-specific bench report extraction from logs + +--- + +## Test Plan + +After fix: +```bash +# Build and run iOS benchmark +mobench build --target ios --release +mobench package-ipa +mobench package-xcuitest +mobench run --target ios --devices "iPhone 15-17" --function bench_mobile::bench_query_proof_generation + +# Fetch should now include bench-report.json +mobench fetch --target ios --build-id --wait + +# Verify +ls target/browserstack//session-*/bench-report.json +cat target/browserstack//session-*/bench-report.json +# Should contain: samples_ns, stats.avg_ns, stats.min_ns, stats.max_ns, etc. +``` + +--- + +## Acceptance Criteria + +- [ ] iOS XCUITest waits for benchmark to complete (not just UI element existence) +- [ ] iOS XCUITest extracts full benchmark JSON +- [ ] `mobench fetch --target ios` produces `bench-report.json` with same structure as Android +- [ ] Benchmark timing data (samples_ns, stats) is captured +- [ ] Resource usage data is captured (if available on iOS) diff --git a/templates/android/app/src/main/java/MainActivity.kt.template b/templates/android/app/src/main/java/MainActivity.kt.template index 7b2e4c4..9893c82 100644 --- a/templates/android/app/src/main/java/MainActivity.kt.template +++ b/templates/android/app/src/main/java/MainActivity.kt.template @@ -64,6 +64,11 @@ class MainActivity : AppCompatActivity() { } findViewById(R.id.result_text)?.text = display + + // Keep the report on screen for at least 5 seconds so BrowserStack video captures it + android.util.Log.i("BenchRunner", "Displaying results for 5 seconds for video capture...") + Thread.sleep(5000) + android.util.Log.i("BenchRunner", "Display hold complete") } /** diff --git a/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template b/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template index 1264f44..d288952 100644 --- a/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template +++ b/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template @@ -2,19 +2,54 @@ import SwiftUI struct ContentView: View { @State private var report: String = "Running benchmark..." + @State private var reportJSON: String = "" + @State private var isCompleted: Bool = false var body: some View { - ScrollView { - Text(report) - .font(.system(.body, design: .monospaced)) - .accessibilityIdentifier("benchmarkReport") - .frame(maxWidth: .infinity, alignment: .leading) - .padding() + ZStack { + ScrollView { + Text(report) + .font(.system(.body, design: .monospaced)) + .accessibilityIdentifier("benchmarkReport") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .background(Color(UIColor.systemBackground)) + + // Hidden elements for XCUITest to read benchmark data + // These are invisible but accessible for automation + VStack { + // Completion indicator - XCUITest waits for this to exist + if isCompleted { + Text("completed") + .accessibilityIdentifier("benchmarkCompleted") + .frame(width: 0, height: 0) + .opacity(0) + } + + // JSON report element - stores the full benchmark JSON in its label + Text(reportJSON) + .accessibilityIdentifier("benchmarkReportJSON") + .frame(width: 0, height: 0) + .opacity(0) + } } - .background(Color(UIColor.systemBackground)) .onAppear { Task { - report = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + let result = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + report = result.displayText + reportJSON = result.jsonReport + isCompleted = true + + // Log the JSON report with markers for BrowserStack device logs + NSLog("BENCH_REPORT_JSON_START") + NSLog("%@", result.jsonReport) + NSLog("BENCH_REPORT_JSON_END") + + // Keep the report on screen for at least 5 seconds so BrowserStack video captures it + NSLog("Displaying results for 5 seconds for video capture...") + try? await Task.sleep(nanoseconds: 5_000_000_000) + NSLog("Display hold complete") } } } From 216221709fb01af7c72ae3d5ef88dbb0344cdb77 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 12:14:13 +0100 Subject: [PATCH 049/196] Sync top-level templates with SDK templates --- .../BenchRunner/BenchRunnerFFI.swift.template | 188 ++++++++++++++++-- .../BenchRunnerUITests.swift.template | 44 +++- 2 files changed, 209 insertions(+), 23 deletions(-) diff --git a/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index 3c2d9b6..8ab114d 100644 --- a/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -17,33 +17,87 @@ struct BenchParams { static func fromBundle() -> BenchParams? { guard let url = Bundle.main.url(forResource: "bench_spec", withExtension: "json") else { + print("[BenchRunner] No bench_spec.json found in bundle, will use process info or defaults") return nil } do { let data = try Data(contentsOf: url) let decoded = try JSONDecoder().decode(EncodedBenchSpec.self, from: data) + print("[BenchRunner] Loaded config from bench_spec.json: function=\(decoded.function), iterations=\(decoded.iterations), warmup=\(decoded.warmup)") return BenchParams(function: decoded.function, iterations: decoded.iterations, warmup: decoded.warmup) } catch { + print("[BenchRunner] ERROR: Failed to parse bench_spec.json: \(error)") + print("[BenchRunner] Will fall back to process info or defaults") return nil } } static func fromProcessInfo() -> BenchParams { let info = ProcessInfo.processInfo - var function = info.environment["BENCH_FUNCTION"] ?? defaultFunction - var iterations = UInt32(info.environment["BENCH_ITERATIONS"] ?? "") ?? defaultIterations - var warmup = UInt32(info.environment["BENCH_WARMUP"] ?? "") ?? defaultWarmup + var function = defaultFunction + var iterations = defaultIterations + var warmup = defaultWarmup + var sources: [String] = [] + // Check environment variables + if let envFunction = info.environment["BENCH_FUNCTION"], !envFunction.isEmpty { + function = envFunction + sources.append("function from BENCH_FUNCTION env") + } + + if let envIterations = info.environment["BENCH_ITERATIONS"], !envIterations.isEmpty { + if let parsed = UInt32(envIterations) { + iterations = parsed + sources.append("iterations from BENCH_ITERATIONS env") + } else { + print("[BenchRunner] WARNING: Failed to parse BENCH_ITERATIONS='\(envIterations)' as integer, using default: \(defaultIterations)") + } + } + + if let envWarmup = info.environment["BENCH_WARMUP"], !envWarmup.isEmpty { + if let parsed = UInt32(envWarmup) { + warmup = parsed + sources.append("warmup from BENCH_WARMUP env") + } else { + print("[BenchRunner] WARNING: Failed to parse BENCH_WARMUP='\(envWarmup)' as integer, using default: \(defaultWarmup)") + } + } + + // Check launch arguments (override environment variables) for arg in info.arguments { if arg.hasPrefix("--bench-function=") { - function = String(arg.split(separator: "=", maxSplits: 1).last ?? Substring(function)) + if let value = arg.split(separator: "=", maxSplits: 1).last { + function = String(value) + sources.append("function from --bench-function arg") + } } else if arg.hasPrefix("--bench-iterations=") { - iterations = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? iterations + if let value = arg.split(separator: "=", maxSplits: 1).last { + if let parsed = UInt32(value) { + iterations = parsed + sources.append("iterations from --bench-iterations arg") + } else { + print("[BenchRunner] WARNING: Failed to parse --bench-iterations='\(value)' as integer, using current value: \(iterations)") + } + } } else if arg.hasPrefix("--bench-warmup=") { - warmup = UInt32(arg.split(separator: "=", maxSplits: 1).last ?? "") ?? warmup + if let value = arg.split(separator: "=", maxSplits: 1).last { + if let parsed = UInt32(value) { + warmup = parsed + sources.append("warmup from --bench-warmup arg") + } else { + print("[BenchRunner] WARNING: Failed to parse --bench-warmup='\(value)' as integer, using current value: \(warmup)") + } + } } } + // Log resolution source + if sources.isEmpty { + print("[BenchRunner] Using hardcoded defaults: function=\(function), iterations=\(iterations), warmup=\(warmup)") + } else { + print("[BenchRunner] Resolved params from process info: function=\(function), iterations=\(iterations), warmup=\(warmup) (sources: \(sources.joined(separator: ", ")))") + } + return BenchParams(function: function, iterations: iterations, warmup: warmup) } @@ -55,13 +109,19 @@ struct BenchParams { } } +/// Result of running a benchmark, containing both display text and JSON report +struct BenchmarkResult { + let displayText: String + let jsonReport: String +} + enum {{PROJECT_NAME_PASCAL}}FFI { - static func runCurrentBenchmark() async -> String { + static func runCurrentBenchmark() async -> BenchmarkResult { let params = BenchParams.resolved() return run(params: params) } - static func run(params: BenchParams) -> String { + static func run(params: BenchParams) -> BenchmarkResult { let spec = BenchSpec( name: params.function, iterations: params.iterations, @@ -70,11 +130,106 @@ enum {{PROJECT_NAME_PASCAL}}FFI { do { let report = try runBenchmark(spec: spec) - return formatBenchReport(report) + let displayText = formatBenchReport(report) + let jsonReport = generateJSONReport(report) + return BenchmarkResult(displayText: displayText, jsonReport: jsonReport) } catch let error as BenchError { - return formatBenchError(error) + print("[BenchRunner] ERROR: Benchmark failed: \(error)") + let errorText = formatBenchError(error) + let errorJSON = generateErrorJSON(error) + return BenchmarkResult(displayText: errorText, jsonReport: errorJSON) } catch { - return "Unexpected error: \(error.localizedDescription)" + print("[BenchRunner] ERROR: Unexpected error during benchmark execution: \(error)") + let errorText = "Unexpected error: \(error.localizedDescription)" + let errorJSON = "{\"error\": \"Unexpected error: \(error.localizedDescription)\"}" + return BenchmarkResult(displayText: errorText, jsonReport: errorJSON) + } + } + + /// Generates a JSON report matching the Android BENCH_JSON format for consistency + private static func generateJSONReport(_ report: BenchReport) -> String { + var json: [String: Any] = [:] + + // Spec section + let specDict: [String: Any] = [ + "name": report.spec.name, + "iterations": report.spec.iterations, + "warmup": report.spec.warmup + ] + json["spec"] = specDict + + // Function name at top level (for compatibility with existing parsers) + json["function"] = report.spec.name + + // Samples as array of duration_ns values + let samplesNs = report.samples.map { $0.durationNs } + json["samples_ns"] = samplesNs + + // Also include samples in object format for compatibility + let samplesArray = report.samples.map { ["duration_ns": $0.durationNs] } + json["samples"] = samplesArray + + // Statistics + if !report.samples.isEmpty { + let durations = report.samples.map { $0.durationNs } + let minNs = durations.min() ?? 0 + let maxNs = durations.max() ?? 0 + let sumNs = durations.reduce(0, +) + let avgNs = Double(sumNs) / Double(durations.count) + + // Compute median + let sorted = durations.sorted() + let medianNs: UInt64 + if sorted.count % 2 == 0 { + medianNs = (sorted[sorted.count / 2 - 1] + sorted[sorted.count / 2]) / 2 + } else { + medianNs = sorted[sorted.count / 2] + } + + let stats: [String: Any] = [ + "min_ns": minNs, + "max_ns": maxNs, + "avg_ns": avgNs, + "mean_ns": UInt64(avgNs), + "median_ns": medianNs + ] + json["stats"] = stats + + // Also add top-level stats for compatibility with different parsers + json["mean_ns"] = UInt64(avgNs) + json["min_ns"] = minNs + json["max_ns"] = maxNs + } + + // Resource metrics (iOS-specific) + let resources: [String: Any] = [ + "platform": "ios", + "timestamp_ms": Int64(Date().timeIntervalSince1970 * 1000) + ] + json["resources"] = resources + + // Serialize to JSON string + do { + let data = try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{}" + } catch { + print("[BenchRunner] ERROR: Failed to serialize JSON report: \(error)") + return "{}" + } + } + + /// Generates a JSON error report + private static func generateErrorJSON(_ error: BenchError) -> String { + let errorDict: [String: Any] = [ + "error": true, + "message": error.localizedDescription + ] + + do { + let data = try JSONSerialization.data(withJSONObject: errorDict, options: [.sortedKeys]) + return String(data: data, encoding: .utf8) ?? "{\"error\": true}" + } catch { + return "{\"error\": true, \"message\": \"Failed to serialize error\"}" } } @@ -117,13 +272,8 @@ enum {{PROJECT_NAME_PASCAL}}FFI { } private static func formatBenchError(_ error: BenchError) -> String { - switch error { - case .InvalidIterations(let message): - return "Error (InvalidIterations): \(message)" - case .UnknownFunction(let message): - return "Error (UnknownFunction): \(message)" - case .ExecutionFailed(let message): - return "Error (ExecutionFailed): \(message)" - } + // Generic error formatting - works with any BenchError variant + // This avoids hardcoding specific error cases that may not exist in all schemas + return "Benchmark error: \(error.localizedDescription)" } } diff --git a/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template b/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template index 595b47a..70f7cb7 100644 --- a/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template +++ b/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template @@ -1,12 +1,48 @@ import XCTest final class {{PROJECT_NAME_PASCAL}}UITests: XCTestCase { - func testLaunchShowsBenchmarkReport() { + + /// Maximum time to wait for benchmark completion (5 minutes for long benchmarks) + private let benchmarkTimeout: TimeInterval = 300.0 + + func testLaunchAndCaptureBenchmarkReport() { let app = XCUIApplication() app.launch() - let report = app.staticTexts["benchmarkReport"] - let exists = report.waitForExistence(timeout: 30.0) - XCTAssertTrue(exists, "Benchmark report text should appear after launch") + // Wait for the benchmark to actually COMPLETE, not just start + // The app sets a "benchmarkCompleted" element when done + let completedIndicator = app.staticTexts["benchmarkCompleted"] + let completed = completedIndicator.waitForExistence(timeout: benchmarkTimeout) + XCTAssertTrue(completed, "Benchmark should complete within \(benchmarkTimeout) seconds") + + // Give UI a moment to update with final results + Thread.sleep(forTimeInterval: 0.5) + + // Extract the benchmark report JSON from the hidden element + let reportElement = app.staticTexts["benchmarkReportJSON"] + XCTAssertTrue(reportElement.exists, "Benchmark report JSON element should exist after completion") + + // The JSON is stored in the element's label property + let jsonString = reportElement.label + + // Log with markers that mobench fetch can parse from instrumentation logs + // Using NSLog to ensure it goes to device logs that BrowserStack captures + NSLog("BENCH_REPORT_JSON_START") + NSLog("%@", jsonString) + NSLog("BENCH_REPORT_JSON_END") + + // Also print to stdout for local testing visibility + print("BENCH_REPORT_JSON_START") + print(jsonString) + print("BENCH_REPORT_JSON_END") + + // Verify we got valid JSON (not an error message) + XCTAssertFalse(jsonString.isEmpty, "Benchmark report JSON should not be empty") + XCTAssertTrue(jsonString.hasPrefix("{"), "Benchmark report should be valid JSON (starts with '{')") + } + + // Keep the old test name for backward compatibility + func testLaunchShowsBenchmarkReport() { + testLaunchAndCaptureBenchmarkReport() } } From 8051be86afc380e2250397d6d401ddbe38aa0260 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 12:25:30 +0100 Subject: [PATCH 050/196] Update documentation for --release flag and iOS XCUITest features - Add --release flag to BrowserStack examples in README.md - Update mobench-sdk README and rustdocs with --release recommendation - Update mobench CLI rustdocs with --release flag - Expand template READMEs with BrowserStack workflow and video capture info - Document benchmark report capture mechanism (5-second delay, JSON markers) --- README.md | 6 ++- crates/mobench-sdk/README.md | 4 +- crates/mobench-sdk/src/lib.rs | 4 +- .../mobench-sdk/templates/android/README.md | 19 ++++++- .../templates/ios/BenchRunner/README.md | 23 +++++++++ crates/mobench/src/lib.rs | 4 +- templates/android/README.md | 41 +++++++++++++++ templates/ios/BenchRunner/README.md | 51 +++++++++++++++++++ 8 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 templates/android/README.md create mode 100644 templates/ios/BenchRunner/README.md diff --git a/README.md b/README.md index 8ea4808..f64abff 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,12 @@ cargo add mobench-sdk inventory cargo mobench build --target android cargo mobench build --target ios -# Run a benchmark +# Run a benchmark locally cargo mobench run --target android --function sample_fns::fibonacci + +# Run on BrowserStack (use --release for smaller APK uploads) +cargo mobench run --target android --function sample_fns::fibonacci \ + --devices "Google Pixel 7-13.0" --release ``` ## Configuration diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index 026ed8b..a25acef 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -123,7 +123,9 @@ BrowserStack: export BROWSERSTACK_USERNAME=your_username export BROWSERSTACK_ACCESS_KEY=your_key -cargo mobench run --target android --function my_benchmark --devices "Google Pixel 7-13.0" +# Use --release for BrowserStack (smaller APK: ~133MB vs ~544MB debug) +cargo mobench run --target android --function my_benchmark \ + --devices "Google Pixel 7-13.0" --release ``` ## Examples (Repository) diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 2dabc3b..bd38a33 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -58,9 +58,9 @@ //! # Build for iOS //! cargo mobench build --target ios //! -//! # Run on BrowserStack +//! # Run on BrowserStack (use --release for smaller APK uploads) //! cargo mobench run --target android --function my_expensive_operation \ -//! --iterations 100 --warmup 10 --devices "Google Pixel 7-13.0" +//! --iterations 100 --warmup 10 --devices "Google Pixel 7-13.0" --release //! ``` //! //! ## Architecture diff --git a/crates/mobench-sdk/templates/android/README.md b/crates/mobench-sdk/templates/android/README.md index d88d1d9..fbdf4d4 100644 --- a/crates/mobench-sdk/templates/android/README.md +++ b/crates/mobench-sdk/templates/android/README.md @@ -8,10 +8,21 @@ This is an auto-generated Android app for running Rust benchmarks on real device # Build debug APK ./gradlew assembleDebug -# Build release APK +# Build release APK (recommended for BrowserStack - ~133MB vs ~544MB debug) ./gradlew assembleRelease ``` +## BrowserStack Testing + +```bash +# Build with mobench (use --release for smaller APK uploads) +cargo mobench build --target android --release + +# Run on BrowserStack +cargo mobench run --target android --function my_benchmark \ + --devices "Google Pixel 7-13.0" --release +``` + ## Running Benchmarks The app reads benchmark configuration from: @@ -19,6 +30,12 @@ The app reads benchmark configuration from: 2. `assets/bench_spec.json` 3. Default values in code +## Benchmark Report Capture + +The app captures benchmark results for BrowserStack: +- Displays results on screen for 5 seconds (video capture) +- Outputs JSON with `BENCH_JSON` marker to logcat + ## Generated by [mobench](https://crates.io/crates/mobench) - Mobile benchmarking SDK for Rust diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/README.md b/crates/mobench-sdk/templates/ios/BenchRunner/README.md index b739b03..b0cefc1 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/README.md +++ b/crates/mobench-sdk/templates/ios/BenchRunner/README.md @@ -15,6 +15,22 @@ xcodebuild -scheme BenchRunner -destination 'platform=iOS Simulator,name=iPhone xcodebuild -scheme BenchRunner -destination 'generic/platform=iOS' build ``` +## BrowserStack Testing + +For BrowserStack testing, you need both the app IPA and the XCUITest runner: + +```bash +# Package the app as IPA +cargo mobench package-ipa --method adhoc + +# Package the XCUITest runner +cargo mobench package-xcuitest + +# Run on BrowserStack (use --release for smaller builds) +cargo mobench run --target ios --function my_benchmark \ + --devices "iPhone 14-16" --release +``` + ## Running Benchmarks The app reads benchmark configuration from: @@ -23,6 +39,13 @@ The app reads benchmark configuration from: 3. `bench_spec.json` in bundle 4. Default values in code +## Benchmark Report Capture + +The app captures benchmark results for BrowserStack: +- Displays results on screen for 5 seconds (video capture) +- Outputs JSON with `BENCH_REPORT_JSON_START/END` markers +- XCUITest extracts results via accessibility identifiers + ## Generated by [mobench](https://crates.io/crates/mobench) - Mobile benchmarking SDK for Rust diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 4998df8..64af30f 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -35,9 +35,9 @@ //! # Run locally (no device required) //! cargo mobench run --target android --function my_benchmark --local-only //! -//! # Run on BrowserStack +//! # Run on BrowserStack (use --release for smaller APK uploads) //! cargo mobench run --target android --function my_benchmark \ -//! --iterations 100 --warmup 10 --devices "Google Pixel 7-13.0" +//! --iterations 100 --warmup 10 --devices "Google Pixel 7-13.0" --release //! ``` //! //! ## Commands diff --git a/templates/android/README.md b/templates/android/README.md new file mode 100644 index 0000000..fbdf4d4 --- /dev/null +++ b/templates/android/README.md @@ -0,0 +1,41 @@ +# {{PROJECT_NAME}} Android Benchmark App + +This is an auto-generated Android app for running Rust benchmarks on real devices. + +## Building + +```bash +# Build debug APK +./gradlew assembleDebug + +# Build release APK (recommended for BrowserStack - ~133MB vs ~544MB debug) +./gradlew assembleRelease +``` + +## BrowserStack Testing + +```bash +# Build with mobench (use --release for smaller APK uploads) +cargo mobench build --target android --release + +# Run on BrowserStack +cargo mobench run --target android --function my_benchmark \ + --devices "Google Pixel 7-13.0" --release +``` + +## Running Benchmarks + +The app reads benchmark configuration from: +1. Intent extras (`bench_function`, `bench_iterations`, `bench_warmup`) +2. `assets/bench_spec.json` +3. Default values in code + +## Benchmark Report Capture + +The app captures benchmark results for BrowserStack: +- Displays results on screen for 5 seconds (video capture) +- Outputs JSON with `BENCH_JSON` marker to logcat + +## Generated by + +[mobench](https://crates.io/crates/mobench) - Mobile benchmarking SDK for Rust diff --git a/templates/ios/BenchRunner/README.md b/templates/ios/BenchRunner/README.md new file mode 100644 index 0000000..b0cefc1 --- /dev/null +++ b/templates/ios/BenchRunner/README.md @@ -0,0 +1,51 @@ +# {{PROJECT_NAME_PASCAL}} iOS Benchmark App + +This is an auto-generated iOS app for running Rust benchmarks on real devices. + +## Building + +```bash +# Generate Xcode project (if using xcodegen) +xcodegen generate + +# Build for simulator +xcodebuild -scheme BenchRunner -destination 'platform=iOS Simulator,name=iPhone 15' build + +# Build for device +xcodebuild -scheme BenchRunner -destination 'generic/platform=iOS' build +``` + +## BrowserStack Testing + +For BrowserStack testing, you need both the app IPA and the XCUITest runner: + +```bash +# Package the app as IPA +cargo mobench package-ipa --method adhoc + +# Package the XCUITest runner +cargo mobench package-xcuitest + +# Run on BrowserStack (use --release for smaller builds) +cargo mobench run --target ios --function my_benchmark \ + --devices "iPhone 14-16" --release +``` + +## Running Benchmarks + +The app reads benchmark configuration from: +1. Environment variables +2. Launch arguments +3. `bench_spec.json` in bundle +4. Default values in code + +## Benchmark Report Capture + +The app captures benchmark results for BrowserStack: +- Displays results on screen for 5 seconds (video capture) +- Outputs JSON with `BENCH_REPORT_JSON_START/END` markers +- XCUITest extracts results via accessibility identifiers + +## Generated by + +[mobench](https://crates.io/crates/mobench) - Mobile benchmarking SDK for Rust From e4c0f225863b02ee8e594178eb6483b35b3eb119 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 13:39:14 +0100 Subject: [PATCH 051/196] Fix iOS XCUITest BrowserStack detection and video capture - Add Info.plist to XCUITest bundle to fix BrowserStack test detection BrowserStack requires Info.plist with XCTContainsUITests=true to identify and run tests; without it, builds are skipped with "no tests detected" - Add info: section to UITests target in project.yml templates with proper bundle identifier (*.uitests suffix) - Increase XCUITest delay from 0.5s to 5s after benchmark completion to ensure BrowserStack video captures the results - Update initial display text from "Running benchmark..." to "Running benchmarks..." for better UX during video recording --- .../BenchRunner/ContentView.swift.template | 2 +- .../BenchRunnerUITests.swift.template | 5 +++-- .../BenchRunnerUITests/Info.plist.template | 22 +++++++++++++++++++ .../ios/BenchRunner/project.yml.template | 5 +++++ ios/BenchRunner/BenchRunner/ContentView.swift | 2 +- ios/BenchRunner/BenchRunner/Info.plist | 2 +- .../BenchRunnerUITests.swift | 5 +++-- ios/BenchRunner/BenchRunnerUITests/Info.plist | 22 +++++++++++++++++++ ios/BenchRunner/project.yml | 5 +++++ 9 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/Info.plist.template create mode 100644 ios/BenchRunner/BenchRunnerUITests/Info.plist diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template index d288952..0ea80de 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template @@ -1,7 +1,7 @@ import SwiftUI struct ContentView: View { - @State private var report: String = "Running benchmark..." + @State private var report: String = "Running benchmarks..." @State private var reportJSON: String = "" @State private var isCompleted: Bool = false diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template index 70f7cb7..fc0e8df 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template @@ -15,8 +15,9 @@ final class {{PROJECT_NAME_PASCAL}}UITests: XCTestCase { let completed = completedIndicator.waitForExistence(timeout: benchmarkTimeout) XCTAssertTrue(completed, "Benchmark should complete within \(benchmarkTimeout) seconds") - // Give UI a moment to update with final results - Thread.sleep(forTimeInterval: 0.5) + // Wait 5 seconds so BrowserStack video captures the results + // This delay is critical for video evidence of benchmark completion + Thread.sleep(forTimeInterval: 5.0) // Extract the benchmark report JSON from the hidden element let reportElement = app.staticTexts["benchmarkReportJSON"] diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/Info.plist.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/Info.plist.template new file mode 100644 index 0000000..fb402aa --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/Info.plist.template @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template index 75c9ae3..cd8278b 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template @@ -36,5 +36,10 @@ targets: deploymentTarget: "15.0" sources: - path: {{PROJECT_NAME_PASCAL}}UITests + info: + path: {{PROJECT_NAME_PASCAL}}UITests/Info.plist + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID}}.uitests dependencies: - target: {{PROJECT_NAME_PASCAL}} diff --git a/ios/BenchRunner/BenchRunner/ContentView.swift b/ios/BenchRunner/BenchRunner/ContentView.swift index 4dc29b1..d2a2d26 100644 --- a/ios/BenchRunner/BenchRunner/ContentView.swift +++ b/ios/BenchRunner/BenchRunner/ContentView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ContentView: View { - @State private var report: String = "Running benchmark..." + @State private var report: String = "Running benchmarks..." @State private var reportJSON: String = "" @State private var isCompleted: Bool = false diff --git a/ios/BenchRunner/BenchRunner/Info.plist b/ios/BenchRunner/BenchRunner/Info.plist index 657d601..47ef031 100644 --- a/ios/BenchRunner/BenchRunner/Info.plist +++ b/ios/BenchRunner/BenchRunner/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.0 + 1.0 CFBundleVersion 1 diff --git a/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift b/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift index e14a8cb..0a46691 100644 --- a/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift +++ b/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift @@ -15,8 +15,9 @@ final class BenchRunnerUITests: XCTestCase { let completed = completedIndicator.waitForExistence(timeout: benchmarkTimeout) XCTAssertTrue(completed, "Benchmark should complete within \(benchmarkTimeout) seconds") - // Give UI a moment to update with final results - Thread.sleep(forTimeInterval: 0.5) + // Wait 5 seconds so BrowserStack video captures the results + // This delay is critical for video evidence of benchmark completion + Thread.sleep(forTimeInterval: 5.0) // Extract the benchmark report JSON from the hidden element let reportElement = app.staticTexts["benchmarkReportJSON"] diff --git a/ios/BenchRunner/BenchRunnerUITests/Info.plist b/ios/BenchRunner/BenchRunnerUITests/Info.plist new file mode 100644 index 0000000..6c40a6c --- /dev/null +++ b/ios/BenchRunner/BenchRunnerUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/ios/BenchRunner/project.yml b/ios/BenchRunner/project.yml index 5b5d269..b006c0a 100644 --- a/ios/BenchRunner/project.yml +++ b/ios/BenchRunner/project.yml @@ -46,5 +46,10 @@ targets: deploymentTarget: "15.0" sources: - path: BenchRunnerUITests + info: + path: BenchRunnerUITests/Info.plist + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.world.bench.uitests dependencies: - target: BenchRunner From 1b4f9ba9b46cd681173cafe32119300733083803 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 16:00:02 +0100 Subject: [PATCH 052/196] Remove resolved issue tracking docs --- mobench-bugs-summary.md | 208 ---------------------------- mobench-ios-xcuitest-report-gap.md | 215 ----------------------------- 2 files changed, 423 deletions(-) delete mode 100644 mobench-bugs-summary.md delete mode 100644 mobench-ios-xcuitest-report-gap.md diff --git a/mobench-bugs-summary.md b/mobench-bugs-summary.md deleted file mode 100644 index e3ffc7f..0000000 --- a/mobench-bugs-summary.md +++ /dev/null @@ -1,208 +0,0 @@ -# mobench Bug Summary for Development - -**Last Updated:** 2026-01-19 -**Tested Versions:** 0.1.9, 0.1.10, 0.1.11, Local Build -**Test Crate:** bench-mobile (World ID ZK Proof Benchmarks) - ---- - -## Quick Status - -| Version | Critical Bugs | Builds Without Fixes | Recommendation | -|---------|---------------|---------------------|----------------| -| 0.1.9 | 12 | ❌ No | Do not use | -| 0.1.10 | 2 | ✅ Yes (false failure) | Usable | -| 0.1.11 | 1 | ⚠️ Partial | Usable with workaround | -| Local | 2 | ⚠️ Requires --crate-path | Needs crate detection fix | - ---- - -## Version Evolution - -### 0.1.9 → 0.1.10 (12 bugs fixed) -All template placeholder bugs fixed, Gradle wrapper and properties added. - -### 0.1.10 → 0.1.11 (3 bugs fixed, 1 new bug) - -**Fixed:** -- ✅ APK filename detection (now parses output-metadata.json) -- ✅ iOS bundle identifier (removed invalid hyphens/underscores) -- ✅ proguard-rules.pro now generated - -**New Bug:** -- ❌ `assembleReleaseAndroidTest` task not found - -### 0.1.11 → Local Build (0 bugs fixed, 1 new bug) - -**New Bug:** -- ❌ Crate detection fails - requires `--crate-path .` flag - -**Still Present:** -- ❌ `assembleReleaseAndroidTest` task not found -- ❌ Hardcoded local.properties -- ❌ Source files not in package directory -- ❌ iOS bundle ID duplication - ---- - -## Current Bugs in Local Build - -### CRITICAL - -#### Bug 0: Crate Detection Fails (NEW in Local Build) -**Location:** mobench-sdk crate detection logic -**Symptom:** -``` -build error: Benchmark crate 'bench-mobile' not found. - -Searched locations: -- /path/bench-mobile/bench-mobile/Cargo.toml -- /path/bench-mobile/crates/bench-mobile/Cargo.toml -``` - -**Cause:** mobench looks for nested directories instead of checking if the current directory is a valid crate. - -**Impact:** -- Build fails to start without workaround -- Must use `--crate-path .` flag - -**Workaround:** -```bash -mobench build --target android --release --crate-path . -``` - ---- - -#### Bug 1: Test APK Task Not Found (from 0.1.11) -**Location:** mobench-sdk android.rs -**Symptom:** -``` -Task 'assembleReleaseAndroidTest' not found in root project -``` - -**Cause:** Android Gradle only creates test tasks for debug by default. Release test task requires `testBuildType "release"`. - -**Impact:** -- Main APK builds ✅ -- Test APK fails ❌ -- mobench reports overall failure - -**Workaround:** -```gradle -// Add to app/build.gradle -android { - defaultConfig { - testBuildType "release" - } -} -``` - -**Or:** Use debug build type for tests (recommended) - ---- - -### HIGH - -#### Bug 2: Hardcoded local.properties (Still present) -**File:** `target/mobench/android/local.properties` -```properties -sdk.dir=/Users/dcbuilder/Library/Android/sdk -``` - -**Impact:** Breaks builds on other machines - -**Fix:** Don't generate this file - ---- - -#### Bug 3: Test File Wrong Directory -**Current:** `app/src/androidTest/java/MainActivityTest.kt` -**Expected:** `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` - -**Impact:** Test compilation may fail - ---- - -### MEDIUM - -#### Bug 4: iOS Bundle ID Duplication -**Current:** `dev.world.benchmobile.benchmobile` -**Expected:** `dev.world.benchmobile.BenchRunner` - -**Impact:** Cosmetic, works but non-standard - ---- - -#### Bug 5: Silent Error Fallbacks -Both platforms catch exceptions broadly and lose error context. - ---- - -## Fixed Bugs (Historical) - -### Fixed in 0.1.11 -| Bug | Description | Status | -|-----|-------------|--------| -| APK filename | Expected wrong name | ✅ Fixed | -| iOS bundle ID | Invalid chars | ✅ Fixed | -| proguard-rules.pro | Missing file | ✅ Fixed | - -### Fixed in 0.1.10 -| Bug | Description | Status | -|-----|-------------|--------| -| `{{PACKAGE_NAME}}` | Not replaced | ✅ Fixed | -| `{{LIBRARY_NAME}}` | Not replaced | ✅ Fixed | -| `{{PROJECT_NAME}}` | Not replaced | ✅ Fixed | -| `{{APP_NAME}}` | Not replaced | ✅ Fixed | -| gradle.properties | Missing | ✅ Fixed | -| Gradle wrapper | Missing | ✅ Fixed | -| AGP version | Invalid 8.13.2 | ✅ Fixed | -| x86_64 iOS sim | Missing | ✅ Fixed | - ---- - -## Verification Commands - -```bash -# Build Android (APK succeeds, test APK fails) -mobench build --target android --release --verbose -ls -la target/mobench/android/app/build/outputs/apk/release/ - -# Build iOS (succeeds fully) -mobench build --target ios --release --verbose -ls -la target/mobench/ios/bench_mobile.xcframework/ - -# Workaround for test APK -cd target/mobench/android -# Add testBuildType to build.gradle, then: -./gradlew assembleReleaseAndroidTest -``` - ---- - -## Recommended Priority Fixes for Next Release - -### P0 (Blocker) -1. **Fix crate detection** - Check current directory Cargo.toml, not just nested paths -2. **Fix test APK task** - Use debug or add testBuildType config - -### P1 (Should Fix) -3. Don't generate local.properties with hardcoded paths -4. Fix source file directory structure (place in package path) -5. Log UniFFI cleanup errors instead of swallowing - -### P2 (Nice to Have) -6. Fix iOS bundle ID duplication -7. Standardize package naming across platforms -8. Improve error handling to preserve types - ---- - -## Files Changed - -- `bench-mobile/Cargo.toml` - Using local mobench-sdk path -- `docs/mobench-0.1.9-dx-report.md` - Full 0.1.9 report -- `docs/mobench-0.1.10-dx-report.md` - Full 0.1.10 report -- `docs/mobench-0.1.11-dx-report.md` - Full 0.1.11 report -- `docs/mobench-local-build-dx-report.md` - Full local build report -- `docs/mobench-bugs-summary.md` - This file diff --git a/mobench-ios-xcuitest-report-gap.md b/mobench-ios-xcuitest-report-gap.md deleted file mode 100644 index 31618cd..0000000 --- a/mobench-ios-xcuitest-report-gap.md +++ /dev/null @@ -1,215 +0,0 @@ -# mobench iOS XCUITest Benchmark Report Gap - -**Date:** 2026-01-20 -**Priority:** P1 - Feature gap preventing iOS benchmark data collection -**Affects:** `mobench fetch` for iOS builds - ---- - -## Summary - -The iOS XCUITest template does not capture benchmark results like the Android Espresso test does. After running benchmarks on BrowserStack, Android returns a complete `bench-report.json` with timing data, while iOS returns nothing. - ---- - -## Current Behavior - -### Android (Working) ✅ - -The Android Espresso test: -1. Launches the app -2. Waits for the benchmark to **complete** -3. Extracts the benchmark JSON from the app -4. Saves `bench-report.json` with full results - -**Result from `mobench fetch`:** -``` -session-xxx/ -├── bench-report.json ✅ Contains timing data -├── session.json -├── device_log.log -├── instrumentation_log.log -└── video.log -``` - -**bench-report.json contents:** -```json -{ - "spec": { - "name": "bench_mobile::bench_query_proof_generation", - "iterations": 20, - "warmup": 3 - }, - "samples_ns": [395761841, 404437540, ...], - "stats": { - "avg_ns": 415465220.25, - "min_ns": 389723715, - "max_ns": 459591756 - }, - "resources": { - "elapsed_cpu_ms": 52129, - "java_heap_kb": 2770, - "total_pss_kb": 71489 - } -} -``` - -### iOS (Not Working) ❌ - -The iOS XCUITest only checks that the UI exists: - -**Current template** (`BenchRunnerUITests.swift`): -```swift -final class BenchRunnerUITests: XCTestCase { - func testLaunchShowsBenchmarkReport() { - let app = XCUIApplication() - app.launch() - - let report = app.staticTexts["benchmarkReport"] - let exists = report.waitForExistence(timeout: 30.0) - XCTAssertTrue(exists, "Benchmark report text should appear after launch") - } -} -``` - -**Problems:** -1. Only waits for element to **exist** (2 seconds), not for benchmark to **complete** (could be 30+ seconds) -2. Does not extract the benchmark report text -3. Does not save any JSON output - -**Result from `mobench fetch`:** -``` -session-xxx/ -├── session.json -├── device_log.log (empty) -├── instrumentation_log.log -└── video.log - ❌ NO bench-report.json -``` - ---- - -## Evidence from BrowserStack Logs - -**iOS instrumentation log:** -``` -t = 1.19s Waiting 30.0s for "benchmarkReport" StaticText to exist -t = 2.20s Checking existence of `"benchmarkReport" StaticText` -t = 2.23s Tear Down -Test Case passed (2.429 seconds). -``` - -The test found the element in 2.2 seconds and exited immediately. The actual benchmark (which takes 400ms × 20 iterations = 8+ seconds minimum) never ran or was ignored. - -**Android instrumentation log (for comparison):** -- Session duration: **50 seconds** -- Captured 20 benchmark samples -- Full resource metrics - ---- - -## Required Changes - -### 1. Update iOS XCUITest Template - -The test needs to: - -```swift -final class BenchRunnerUITests: XCTestCase { - func testLaunchAndCaptureBenchmarkReport() { - let app = XCUIApplication() - app.launch() - - // 1. Wait for benchmark to COMPLETE (not just start) - // Look for a "completed" indicator or specific text pattern - let completedIndicator = app.staticTexts["benchmarkCompleted"] - let completed = completedIndicator.waitForExistence(timeout: 300.0) // 5 min timeout - XCTAssertTrue(completed, "Benchmark should complete") - - // 2. Extract the benchmark report JSON from the UI - let reportText = app.staticTexts["benchmarkReportJSON"] - XCTAssertTrue(reportText.exists, "Benchmark report JSON should exist") - - // 3. Log it in a format that mobench fetch can parse - let jsonString = reportText.label - print("BENCH_REPORT_JSON_START") - print(jsonString) - print("BENCH_REPORT_JSON_END") - - // Or write to a known file location that BrowserStack can retrieve - } -} -``` - -### 2. Update iOS App to Expose Report Data - -The `BenchRunner` iOS app needs to: -1. Show a "completed" indicator when benchmark finishes -2. Expose the full JSON report in an accessible UI element (or via `XCUIApplication.launchArguments` output) - -### 3. Update `mobench fetch` for iOS - -Parse the instrumentation log or device log for the benchmark JSON: -```rust -// In fetch command for iOS -fn extract_ios_bench_report(instrumentation_log: &str) -> Option { - // Look for BENCH_REPORT_JSON_START ... BENCH_REPORT_JSON_END - // Parse and save as bench-report.json -} -``` - ---- - -## Comparison: Android vs iOS Test Flow - -| Step | Android (Espresso) | iOS (XCUITest) Current | iOS (XCUITest) Required | -|------|-------------------|------------------------|------------------------| -| Launch app | ✅ | ✅ | ✅ | -| Wait for benchmark complete | ✅ Waits for completion | ❌ Only checks existence | ✅ Need to wait | -| Extract JSON report | ✅ Via Espresso | ❌ Not implemented | ✅ Via XCUITest | -| Save bench-report.json | ✅ Automatic | ❌ Missing | ✅ Need to implement | -| Duration | 50 seconds | 2 seconds | Should be ~50 seconds | - ---- - -## Files to Modify - -1. **`crates/mobench-sdk/templates/ios/BenchRunnerUITests/BenchRunnerUITests.swift`** - - Rewrite test to wait for completion and extract report - -2. **`crates/mobench-sdk/templates/ios/BenchRunner/ContentView.swift`** (or equivalent) - - Add accessibility identifiers for completed state and JSON report - -3. **`crates/mobench/src/fetch.rs`** (or equivalent) - - Add iOS-specific bench report extraction from logs - ---- - -## Test Plan - -After fix: -```bash -# Build and run iOS benchmark -mobench build --target ios --release -mobench package-ipa -mobench package-xcuitest -mobench run --target ios --devices "iPhone 15-17" --function bench_mobile::bench_query_proof_generation - -# Fetch should now include bench-report.json -mobench fetch --target ios --build-id --wait - -# Verify -ls target/browserstack//session-*/bench-report.json -cat target/browserstack//session-*/bench-report.json -# Should contain: samples_ns, stats.avg_ns, stats.min_ns, stats.max_ns, etc. -``` - ---- - -## Acceptance Criteria - -- [ ] iOS XCUITest waits for benchmark to complete (not just UI element existence) -- [ ] iOS XCUITest extracts full benchmark JSON -- [ ] `mobench fetch --target ios` produces `bench-report.json` with same structure as Android -- [ ] Benchmark timing data (samples_ns, stats) is captured -- [ ] Resource usage data is captured (if available on iOS) From 6ed04f054743dd4fbdfdb55be1ac6ab6d98dfcfd Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 16:23:11 +0100 Subject: [PATCH 053/196] Remove stale DX report doc --- mobench-local-build-dx-report.md | 244 ------------------------------- 1 file changed, 244 deletions(-) delete mode 100644 mobench-local-build-dx-report.md diff --git a/mobench-local-build-dx-report.md b/mobench-local-build-dx-report.md deleted file mode 100644 index d46d95f..0000000 --- a/mobench-local-build-dx-report.md +++ /dev/null @@ -1,244 +0,0 @@ -# mobench Local Build DX Report - -**Date:** 2026-01-19 -**Tested Version:** Local build from `../mobile-bench-rs` (based on 0.1.11) -**Platform:** macOS Darwin 25.1.0 (arm64) - -## Executive Summary - -Testing the local mobench build revealed **1 new bug** (crate detection) while confirming that previous bugs from 0.1.11 remain unfixed. Both platforms build successfully, but the test APK step still fails. - -**Build Status:** -- Android APK: ✅ Built successfully (133MB) -- Android Test APK: ❌ Failed (`assembleReleaseAndroidTest` task not found) -- iOS: ✅ Built successfully - ---- - -## New Bug Found - -### BUG: Crate Detection Fails Without `--crate-path` - -**Severity:** CRITICAL (build won't start) - -**Symptom:** -``` -build error: Benchmark crate 'bench-mobile' not found. - -Searched locations: -- /Users/.../bench-mobile/bench-mobile/Cargo.toml -- /Users/.../bench-mobile/crates/bench-mobile/Cargo.toml -``` - -**Root Cause:** mobench looks for nested directories (`bench-mobile/bench-mobile/` or `bench-mobile/crates/bench-mobile/`) instead of checking if the current directory contains a valid crate with a matching name. - -**Workaround:** Use `--crate-path .` flag: -```bash -mobench build --target android --release --crate-path . -``` - -**Fix Required:** mobench should check if the current directory's `Cargo.toml` has a matching `[package] name`. - ---- - -## Confirmed Bugs (Still Present from 0.1.11) - -### 1. Test APK Task Not Found - -**Status:** STILL PRESENT -**Impact:** Android build reports failure even though main APK builds successfully - -``` -Task 'assembleReleaseAndroidTest' not found in root project 'bench_mobile-android' -``` - -**Workaround:** Add to `app/build.gradle`: -```gradle -android { - defaultConfig { - testBuildType "release" - } -} -``` - ---- - -### 2. Hardcoded local.properties - -**Status:** STILL PRESENT -**File:** `target/mobench/android/local.properties` -```properties -sdk.dir=/Users/dcbuilder/Library/Android/sdk -``` - -**Impact:** Breaks builds on other machines. - ---- - -### 3. Source Files Not in Package Directory - -**Status:** STILL PRESENT - -**Current:** -- `app/src/main/java/MainActivity.kt` -- `app/src/androidTest/java/MainActivityTest.kt` - -**Expected:** -- `app/src/main/java/dev/world/bench_mobile/MainActivity.kt` -- `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` - -**Impact:** Kotlin files with `package dev.world.bench_mobile` must be in matching directory structure. - ---- - -### 4. iOS Bundle ID Duplication - -**Status:** STILL PRESENT -**File:** `target/mobench/ios/BenchRunner/project.yml` - -**Current:** `dev.world.benchmobile.benchmobile` (duplicated) -**Expected:** `dev.world.benchmobile` - ---- - -### 5. Cross-Platform Naming Inconsistency - -**Android:** `dev.world.bench_mobile` (snake_case) -**iOS:** `dev.world.benchmobile` (camelCase) - ---- - -### 6. Version String Mismatch - -**Android:** `0.1` -**iOS:** `1.0` - ---- - -## Silent Failure Issues - -### CRITICAL: UniFFI Cleanup Errors Swallowed - -**File:** `app/src/main/java/uniffi/bench_mobile/bench_mobile.kt:895-905` -```kotlin -} catch (e: Throwable) { - // swallow -} -``` - -All exceptions during `destroy()` are silently discarded, hiding memory leaks and native crashes. - ---- - -### CRITICAL: Broad Exception Catch Without Logging - -**File:** `app/src/main/java/MainActivity.kt:59-61` -```kotlin -} catch (e: Exception) { - "Unexpected error: ${e.message}" -} -``` - -Catches all exceptions but doesn't log them, making production debugging impossible. - ---- - -### HIGH: Silent Config Fallbacks - -Both platforms silently fall back to defaults when config parsing fails: -- Android: `MainActivity.kt:191-197` - logs but user isn't notified -- iOS: `BenchRunnerFFI.swift:35-52` - no logging at all for invalid numeric values - ---- - -## What's Working - -✅ APK filename detection (parses `output-metadata.json`) -✅ `proguard-rules.pro` generated with correct JNA/UniFFI rules -✅ iOS bundle identifier format (no invalid characters) -✅ Gradle wrapper generated -✅ `gradle.properties` generated -✅ All template placeholders replaced -✅ x86_64 iOS simulator support (universal binary) -✅ iOS xcframework code-signed - ---- - -## Build Outputs - -### Android -``` -Location: target/mobench/android/ -APK: app/build/outputs/apk/release/app-release-unsigned.apk -Size: 133,561,304 bytes (127 MB) -Status: ✅ Built successfully -``` - -### iOS -``` -Location: target/mobench/ios/ -Framework: bench_mobile.xcframework/ -Architectures: ios-arm64, ios-arm64_x86_64-simulator -Status: ✅ Built successfully -``` - ---- - -## Priority Fixes for Next Release - -### P0 (Blocker) -1. **Fix crate detection** - Check current directory Cargo.toml, not just nested paths -2. **Fix test APK task** - Use `assembleDebugAndroidTest` or add `testBuildType` config - -### P1 (Should Fix) -3. Don't generate `local.properties` with hardcoded paths -4. Fix source file directory structure (place in package path) -5. Log UniFFI cleanup errors instead of swallowing - -### P2 (Nice to Have) -6. Fix iOS bundle ID duplication -7. Standardize package naming across platforms -8. Align version strings -9. Add user-visible config fallback warnings - ---- - -## Test Commands - -```bash -# Build with local mobench (requires --crate-path flag) -cd bench-mobile -mobench build --target android --release --verbose --crate-path . -mobench build --target ios --release --verbose --crate-path . - -# Verify APK exists despite error -ls -la target/mobench/android/app/build/outputs/apk/release/ - -# Verify iOS xcframework -ls -la target/mobench/ios/bench_mobile.xcframework/ -``` - ---- - -## Agent Analysis - -Three debugging agents were deployed in parallel: - -1. **Code Reviewer** - Confirmed test task issue, found source file directory structure bug -2. **Silent Failure Hunter** - Found 9 error handling issues (2 critical, 4 high, 3 medium) -3. **Explorer** - Found bundle ID duplication, naming inconsistencies, version mismatch - ---- - -## Comparison: 0.1.11 vs Local Build - -| Issue | 0.1.11 | Local Build | -|-------|--------|-------------| -| Crate detection | ✅ | ❌ NEW BUG | -| Test APK task | ❌ | ❌ | -| local.properties | ❌ | ❌ | -| Source file paths | ❌ | ❌ | -| iOS bundle ID dupe | ❌ | ❌ | -| proguard-rules.pro | ✅ | ✅ | -| APK detection | ✅ | ✅ | -| iOS bundle ID chars | ✅ | ✅ | From 97feccac854682b334c1770bf983aff4816b8e4d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 18:07:40 +0100 Subject: [PATCH 054/196] Bump version to 0.1.12 for release Changes in this release: - Fix iOS XCUITest BrowserStack detection (Info.plist added to UITests target) - Improve video capture for BrowserStack (5s delay for video evidence) - Show "Running benchmarks..." text before results appear - Sync top-level templates with SDK templates - Various DX fixes --- CLAUDE.md | 2 +- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- README.md | 15 +++++++++++++++ crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb9c6c5..6a6bce3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.11):** +**Published on crates.io as the mobench ecosystem (v0.1.12):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation diff --git a/Cargo.lock b/Cargo.lock index dadd2d7..dec6036 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.11" +version = "0.1.12" dependencies = [ "anyhow", "clap", @@ -820,7 +820,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.11" +version = "0.1.12" dependencies = [ "proc-macro2", "quote", @@ -829,7 +829,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.11" +version = "0.1.12" dependencies = [ "anyhow", "include_dir", @@ -1154,7 +1154,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.11" +version = "0.1.12" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index a3c6cb3..54b09ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.11" +version = "0.1.12" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index f64abff..85e6a2f 100644 --- a/README.md +++ b/README.md @@ -80,4 +80,19 @@ CLI flags override config file values when provided. - `PROJECT_PLAN.md`: goals and backlog - `CLAUDE.md`: developer guide +## Release Notes + +### v0.1.12 + +- **Fix iOS XCUITest BrowserStack detection**: Added Info.plist to the UITests target template, resolving issues where BrowserStack could not properly detect and run XCUITest bundles +- **Improved video capture for BrowserStack**: Increased post-benchmark delay from 0.5s to 5.0s to ensure benchmark results are captured in BrowserStack video recordings +- **Better UX during benchmark runs**: iOS app now shows "Running benchmarks..." text before results appear, providing visual feedback during execution +- **Template sync**: Synchronized top-level iOS/Android templates with SDK-embedded templates for consistency + +### v0.1.11 + +- Initial public release with `--release` flag support +- `package-xcuitest` command for iOS BrowserStack testing +- Updated mobile timing display and documentation + MIT licensed — World Foundation 2026. diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 80ff7a2..6850227 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.11", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.12", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 37e276c..432d4ef 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.11", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.12", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true From 01f42705b46d8de3481c73e54cb2c817dfb38fa6 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 18:32:03 +0100 Subject: [PATCH 055/196] Fix error message hints --- crates/mobench-sdk/src/types.rs | 2 +- crates/mobench/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs index 7854d03..f036605 100644 --- a/crates/mobench-sdk/src/types.rs +++ b/crates/mobench-sdk/src/types.rs @@ -83,7 +83,7 @@ pub enum BenchError { /// /// This can occur when reading/writing benchmark specifications /// or configuration files. - #[error("serialization error: {0}. Ensure the input is valid JSON")] + #[error("serialization error: {0}. Check JSON validity or output serializability")] Serialization(#[from] serde_json::Error), /// A configuration error occurred. diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 64af30f..0d75a3d 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -2287,7 +2287,7 @@ fn detect_bench_mobile_crate_name(root: &Path) -> Result { } bail!( - "No benchmark crate found. Expected bench-mobile/Cargo.toml or crates/sample-fns/Cargo.toml under the project root. Run from the project root or set crate_name in mobench.toml." + "No benchmark crate found. Expected bench-mobile/Cargo.toml or crates/sample-fns/Cargo.toml under the project root. Run from the project root or set [project].crate in mobench.toml." ) } From b5c905accfcbd13e7f05fe61b4f834132870442e Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 19:18:17 +0100 Subject: [PATCH 056/196] Fix iOS XCUITest test name mismatch with BrowserStack Changed only-testing filter from testLaunchShowsBenchmarkReport to testLaunchAndCaptureBenchmarkReport to match what BrowserStack parses from the xctest bundle. --- crates/mobench/src/browserstack.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index ccce203..670f8fb 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -185,7 +185,7 @@ impl BrowserStackClient { build_name: self.project.clone(), // Specify the test method to run (required by BrowserStack for XCUITest) only_testing: Some(vec![ - "BenchRunnerUITests/BenchRunnerUITests/testLaunchShowsBenchmarkReport".to_string(), + "BenchRunnerUITests/BenchRunnerUITests/testLaunchAndCaptureBenchmarkReport".to_string(), ]), }; From ade279e0fe47a4a64d1b5015aa3cf374439a39a9 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 20 Jan 2026 20:04:04 +0100 Subject: [PATCH 057/196] Bump version to 0.1.13 for release Fix iOS XCUITest test name mismatch with BrowserStack - changed only-testing filter to use testLaunchAndCaptureBenchmarkReport. --- CLAUDE.md | 2 +- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- README.md | 4 ++++ crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6a6bce3..08e5a7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.12):** +**Published on crates.io as the mobench ecosystem (v0.1.13):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation diff --git a/Cargo.lock b/Cargo.lock index dec6036..8318d66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.12" +version = "0.1.13" dependencies = [ "anyhow", "clap", @@ -820,7 +820,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.12" +version = "0.1.13" dependencies = [ "proc-macro2", "quote", @@ -829,7 +829,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.12" +version = "0.1.13" dependencies = [ "anyhow", "include_dir", @@ -1154,7 +1154,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.12" +version = "0.1.13" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 54b09ec..ec173f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.12" +version = "0.1.13" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 85e6a2f..e26b167 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,10 @@ CLI flags override config file values when provided. ## Release Notes +### v0.1.13 + +- **Fix iOS XCUITest test name mismatch**: Changed BrowserStack `only-testing` filter to use `testLaunchAndCaptureBenchmarkReport` which matches what BrowserStack parses from the xctest bundle + ### v0.1.12 - **Fix iOS XCUITest BrowserStack detection**: Added Info.plist to the UITests target template, resolving issues where BrowserStack could not properly detect and run XCUITest bundles diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 6850227..0399d16 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.12", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.13", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 432d4ef..df47c36 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.12", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.13", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true From 3d85d84ad20a813a34314a48e3a4be604da8994b Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 21 Jan 2026 09:53:25 -0300 Subject: [PATCH 058/196] Modernize build flow and examples (#6) * Refactor mobench layout and refresh README - split CLI entrypoint into library and cargo-mobench bin - update workspace manifests and lockfile for crate changes - rewrite README with current workflow and crate list * Remove legacy bench-cli crates and update docs Drop the unused bench-cli and bench-runner crates, update docs to reflect the current mobench-runner workflow, and adjust legacy scripts/comments to reference cargo mobench. * Split examples into minimal and FFI variants Simplify basic-benchmark to only show #[benchmark] usage and add a new ffi-benchmark example that contains the UniFFI types and entrypoint. Update workspace metadata and docs to reflect the new example layout. * Remove legacy sample_fns UDL Drop the unused UniFFI UDL file for sample-fns and update build script notes to reflect the proc-macro binding generation flow. * Remove legacy scripts and update docs Delete deprecated build scripts, switch CI and docs to cargo mobench commands, and refresh integration guidance for the new flow. * Refresh docs for mobench build flow Update CLAUDE, BUILD, and mobench-sdk README to remove script references, clarify the builder flow, and document the new examples. --------- Co-authored-by: dcbuilder.eth --- .github/workflows/mobile-bench.yml | 14 +- BENCH_SDK_INTEGRATION.md | 9 +- BUILD.md | 50 +- CLAUDE.md | 126 +- Cargo.lock | 26 +- Cargo.toml | 3 +- PROJECT_PLAN.md | 6 +- README.md | 548 +------ TESTING.md | 41 +- android/README.md | 8 +- crates/bench-cli/Cargo.toml | 20 - crates/bench-cli/src/browserstack.rs | 509 ------ crates/bench-cli/src/main.rs | 1052 ------------ crates/bench-runner/Cargo.toml | 9 - crates/bench-runner/src/lib.rs | 92 -- crates/mobench-sdk/Cargo.toml | 4 +- crates/mobench-sdk/README.md | 15 +- crates/mobench-sdk/src/runner.rs | 2 +- crates/mobench/Cargo.toml | 6 +- crates/mobench/src/bin/cargo-mobench.rs | 6 + crates/mobench/src/lib.rs | 1961 ++++++++++++++++++++++ crates/mobench/src/main.rs | 1963 +---------------------- crates/sample-fns/src/lib.rs | 2 +- crates/sample-fns/src/sample_fns.udl | 26 - examples/basic-benchmark/Cargo.toml | 7 - examples/basic-benchmark/build.rs | 3 +- examples/basic-benchmark/src/lib.rs | 154 +- examples/ffi-benchmark/Cargo.toml | 20 + examples/ffi-benchmark/src/lib.rs | 231 +++ scripts/bindgen.rs | 56 - scripts/build-android-app.sh | 60 - scripts/build-android.sh | 43 - scripts/build-ios.sh | 202 --- scripts/generate-bindings.sh | 26 - scripts/sync-android-libs.sh | 50 - 35 files changed, 2426 insertions(+), 4924 deletions(-) delete mode 100644 crates/bench-cli/Cargo.toml delete mode 100644 crates/bench-cli/src/browserstack.rs delete mode 100644 crates/bench-cli/src/main.rs delete mode 100644 crates/bench-runner/Cargo.toml delete mode 100644 crates/bench-runner/src/lib.rs create mode 100644 crates/mobench/src/bin/cargo-mobench.rs create mode 100644 crates/mobench/src/lib.rs delete mode 100644 crates/sample-fns/src/sample_fns.udl create mode 100644 examples/ffi-benchmark/Cargo.toml create mode 100644 examples/ffi-benchmark/src/lib.rs delete mode 100644 scripts/bindgen.rs delete mode 100755 scripts/build-android-app.sh delete mode 100755 scripts/build-android.sh delete mode 100755 scripts/build-ios.sh delete mode 100755 scripts/generate-bindings.sh delete mode 100755 scripts/sync-android-libs.sh diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index e6e27c2..2d55817 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -77,11 +77,8 @@ jobs: build-tools;34.0.0 ndk;26.1.10909125 - - name: Build Rust shared libs for Android - run: scripts/build-android.sh - - - name: Sync .so into Android app - run: scripts/sync-android-libs.sh + - name: Build Android artifacts + run: cargo mobench build --target android - name: Setup Gradle uses: gradle/gradle-build-action@v3 @@ -112,13 +109,8 @@ jobs: with: targets: aarch64-apple-ios,aarch64-apple-ios-sim - - name: Install cargo-apple and cbindgen - run: | - cargo install cargo-apple - cargo install cbindgen - - name: Build iOS xcframework + header - run: scripts/build-ios.sh + run: cargo mobench build --target ios - name: Upload iOS artifacts uses: actions/upload-artifact@v4 diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index 55ab67c..c831c42 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -4,7 +4,6 @@ This guide shows how to integrate `mobench-sdk` into an existing Rust project, r mobile benchmarks, and then run them on BrowserStack. > **Important**: This guide is for integrators importing `mobench-sdk` as a library. -> You do **NOT** need the `scripts/` directory from this repository. > All build functionality is available via `cargo mobench` commands. ## 1) Prerequisites @@ -50,6 +49,7 @@ fn checksum_bench() { ``` Benchmarks are identified by name at runtime. You can call them by: + - Fully-qualified path (e.g., `my_crate::checksum_bench`) - Or suffix match (e.g., `checksum_bench`) @@ -62,6 +62,7 @@ cargo mobench init-sdk --target both --project-name my-bench --output-dir . ``` This generates: + - `bench-mobile/` (FFI bridge that links your crate) - `android/` and `ios/` app templates - `bench-config.toml` configuration @@ -75,6 +76,7 @@ cargo mobench build --target android ``` This automatically: + - Builds Rust libraries for all Android ABIs (arm64-v8a, armeabi-v7a, x86_64) - Generates UniFFI Kotlin bindings - Copies .so files to jniLibs @@ -105,6 +107,7 @@ cargo mobench build --target ios ``` This automatically: + - Builds Rust libraries for iOS device + simulator - Generates UniFFI Swift bindings and C headers - Creates properly structured xcframework @@ -142,6 +145,7 @@ cargo mobench run \ ``` The CLI will automatically: + - Upload APK and test APK to BrowserStack - Schedule the test run - Wait for completion @@ -176,6 +180,7 @@ cargo mobench run \ ``` **IPA Signing Methods:** + - `adhoc`: No Apple ID required, works for BrowserStack device testing - `development`: Requires Apple Developer account, for physical device testing @@ -185,4 +190,4 @@ cargo mobench run \ - If you change FFI types, the build process automatically regenerates bindings - Android emulator ABI is typically `x86_64` in Android Studio - BrowserStack credentials must be set via `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` -- For developing this repo (not integrating the SDK), legacy `scripts/` are available but deprecated +- For repository development, use the same `cargo mobench` workflow diff --git a/BUILD.md b/BUILD.md index 1ad9925..096089b 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,11 +2,10 @@ Complete build instructions for Android and iOS targets. -> **For SDK Integrators**: If you're importing `mobench-sdk` into your project, use the CLI commands: -> - `cargo mobench build --target android` for Android -> - `cargo mobench build --target ios` for iOS +> **For SDK Integrators**: Use the CLI commands: +> - `cargo mobench build --target android` +> - `cargo mobench build --target ios` > -> The scripts shown below are legacy tooling for developing this repository. > See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide. ## Table of Contents @@ -77,8 +76,7 @@ xcodebuild -version ### Quick Start (Recommended) ```bash # Build everything and create APK in one command -# For Android Studio emulators, use UNIFFI_ANDROID_ABI=x86_64 -UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh +cargo mobench build --target android # Install on connected device or emulator adb install -r android/app/build/outputs/apk/debug/app-debug.apk @@ -91,8 +89,8 @@ adb shell am start -n dev.world.bench/.MainActivity #### Step 1: Build Rust Libraries + Bindings ```bash -# ABI-aware binding generation to avoid UniFFI checksum mismatches. -UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh +# Build Rust libraries, generate bindings, and sync JNI libs. +cargo mobench build --target android ``` This compiles Rust code for three Android ABIs: @@ -131,8 +129,7 @@ adb shell am start -n dev.world.bench/.MainActivity \ ### Using Android Studio 1. Build Rust libraries first: ```bash - ./scripts/build-android.sh - ./scripts/sync-android-libs.sh + cargo mobench build --target android ``` 2. Open the `android/` directory in Android Studio @@ -146,15 +143,14 @@ adb shell am start -n dev.world.bench/.MainActivity \ ### Rebuild After Code Changes ```bash # If Rust code changed -./scripts/build-android.sh -./scripts/sync-android-libs.sh +cargo mobench build --target android # If only Kotlin/Java changed cd android && ./gradlew :app:assembleDebug # Full clean rebuild cargo clean -./scripts/build-android-app.sh +cargo mobench build --target android ``` ## iOS Build @@ -162,7 +158,7 @@ cargo clean ### Quick Start (Recommended) ```bash # Build Rust xcframework (includes automatic code signing) -./scripts/build-ios.sh +cargo mobench build --target ios # Generate Xcode project cd ios/BenchRunner @@ -180,10 +176,10 @@ Then in Xcode: #### Step 1: Build Rust XCFramework ```bash -./scripts/build-ios.sh +cargo mobench build --target ios ``` -This script: +This build step: 1. Compiles Rust for iOS targets: - `aarch64-apple-ios` (physical devices) - `aarch64-apple-ios-sim` (M1+ Mac simulators) @@ -216,7 +212,7 @@ This script: Output: `target/ios/sample_fns.xcframework` (signed) -**Note**: The build script now includes automatic code signing. If signing fails for any reason, you can sign manually: +**Note**: The build step includes automatic code signing. If signing fails for any reason, you can sign manually: ```bash codesign --force --deep --sign - target/ios/sample_fns.xcframework ``` @@ -276,7 +272,7 @@ xcrun simctl launch booted dev.world.bench \ ### Rebuild After Code Changes ```bash # If Rust code changed (includes automatic signing) -./scripts/build-ios.sh +cargo mobench build --target ios # If Swift code changed, just rebuild in Xcode (⌘+B) @@ -287,7 +283,7 @@ open BenchRunner.xcodeproj # Full clean rebuild cargo clean -./scripts/build-ios.sh +cargo mobench build --target ios cd ios/BenchRunner xcodegen generate # Clean in Xcode (⌘+Shift+K) then build (⌘+B) @@ -308,7 +304,7 @@ xcodegen generate This makes C types (`RustBuffer`, `RustCallStatus`, etc.) available to Swift without explicit imports. -**Code Signing**: The build script (`build-ios.sh`) automatically signs the xcframework. If you build manually or signing fails, sign with: +**Code Signing**: The build step automatically signs the xcframework. If signing fails, sign with: ```bash codesign --force --deep --sign - target/ios/sample_fns.xcframework ``` @@ -334,7 +330,7 @@ cargo install cargo-ndk **Issue**: App crashes with `UnsatisfiedLinkError` ```bash # Ensure .so files are in the APK -./scripts/sync-android-libs.sh +cargo mobench build --target android cd android && ./gradlew clean assembleDebug # Verify .so files are in APK @@ -361,7 +357,7 @@ codesign --force --deep --sign - target/ios/sample_fns.xcframework ```bash # Rebuild with correct structure rm -rf target/ios/sample_fns.xcframework -./scripts/build-ios.sh +cargo mobench build --target ios codesign --force --deep --sign - target/ios/sample_fns.xcframework # Clean Xcode build @@ -388,12 +384,12 @@ xcodegen generate **Issue**: "framework 'ios-simulator-arm64' not found" - The framework binary or directory structure is incorrect -- Rebuild: `./scripts/build-ios.sh` +- Rebuild: `cargo mobench build --target ios` - Verify structure: Each framework should be named `sample_fns.framework`, not the platform identifier **Issue**: "Framework had an invalid CFBundleIdentifier" - Framework bundle ID conflicts with app bundle ID -- Check `scripts/build-ios.sh` uses `dev.world.sample-fns` for framework +- Check the iOS builder uses `dev.world.sample-fns` for the framework - App uses `dev.world.bench` ## UniFFI Bindings (Proc Macros) @@ -407,7 +403,7 @@ If you modify FFI types in Rust (`crates/sample-fns/src/lib.rs`): cargo build -p sample-fns # Regenerate bindings from proc macros -./scripts/generate-bindings.sh +cargo mobench build --target android # This updates: # - android/app/src/main/java/uniffi/sample_fns/sample_fns.kt (Kotlin) @@ -415,8 +411,8 @@ cargo build -p sample-fns # - ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h (C header) # Then rebuild mobile apps -UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh # Android -./scripts/build-ios.sh # iOS (includes automatic code signing) +cargo mobench build --target android +cargo mobench build --target ios ``` **Example**: Adding a new FFI type: diff --git a/CLAUDE.md b/CLAUDE.md index 1c08a2f..da6d267 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. **Published on crates.io as the mobench ecosystem (v0.1.5):** + - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with build automation - **[mobench-macros](https://crates.io/crates/mobench-macros)** - `#[benchmark]` attribute proc macro @@ -42,7 +43,8 @@ The repository is organized as a Cargo workspace: - **`crates/mobench-sdk`**: Core SDK library with registry system, builders (AndroidBuilder, IosBuilder), template generation, and BrowserStack integration. - **`crates/mobench-macros`**: Proc macro crate providing the `#[benchmark]` attribute for marking functions. - **`crates/mobench-runner`**: Lightweight timing harness library that gets embedded in mobile binaries. Provides timing infrastructure for benchmarks. -- **`examples/basic-benchmark`**: Example benchmark functions with UniFFI bindings for mobile platforms. Demonstrates the SDK usage pattern. +- **`examples/basic-benchmark`**: Minimal SDK usage example with `#[benchmark]`. +- **`examples/ffi-benchmark`**: Full UniFFI surface example (types + `run_benchmark`). ### Mobile Integration Flow @@ -65,10 +67,12 @@ The CLI supports both Espresso (Android) and XCUITest (iOS) test automation fram ## Build and Testing Documentation **Primary Documentation:** + - **`BUILD.md`**: Complete build reference with prerequisites, step-by-step instructions, and troubleshooting for both Android and iOS - **`TESTING.md`**: Comprehensive testing guide with advanced scenarios and detailed troubleshooting For comprehensive testing instructions, see **`TESTING.md`** which includes: + - Prerequisites and setup - Host testing (cargo test) - Android testing (emulator, device, Android Studio; use `UNIFFI_ANDROID_ABI=x86_64` for default emulators) @@ -77,6 +81,7 @@ For comprehensive testing instructions, see **`TESTING.md`** which includes: - Advanced testing scenarios Quick test commands: + ```bash # Run all Rust tests cargo test --all @@ -91,9 +96,6 @@ cargo mobench build --target ios # List discovered benchmarks cargo mobench list -# Legacy: Direct script usage (for repository development only) -scripts/build-android-app.sh -scripts/build-ios.sh ``` ## Common Commands @@ -124,57 +126,22 @@ cargo mobench package-ipa --method adhoc ``` **What the CLI does:** + - Automatically builds Rust libraries with correct targets - Generates or updates mobile app projects from embedded templates - Syncs native libraries into platform-specific directories - Builds APK (Android) or xcframework (iOS) - No manual script execution needed -### Legacy Script-Based Building (Repository Development) - -**Note:** The `scripts/` directory contains legacy tooling used for developing this repository. SDK users should use `cargo mobench build` instead. - -#### Android (Legacy) -```bash -# Build Rust shared libraries for Android (requires Android NDK) -scripts/build-android.sh - -# Sync .so files into Android project structure -scripts/sync-android-libs.sh - -# Build complete APK with Gradle -cd android && gradle :app:assembleDebug - -# Or use the all-in-one script -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh -``` - -Requirements: -- `ANDROID_NDK_HOME` environment variable set -- `cargo-ndk` installed: `cargo install cargo-ndk` -- Android SDK/NDK available (API level 24+) -- Set `UNIFFI_ANDROID_ABI=x86_64` for default Android Studio emulators - -#### iOS (Legacy) -```bash -# Build Rust xcframework for iOS (includes UniFFI headers and automatic signing) -scripts/build-ios.sh - -# Generate Xcode project from project.yml (if using repository's iOS app) -cd ios/BenchRunner && xcodegen generate - -# Open in Xcode -open BenchRunner.xcodeproj -``` +### Repository Development Builds -Requirements: -- Xcode command-line tools -- Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim` -- `xcodegen` installed: `brew install xcodegen` (only for repository development) +Use `cargo mobench build --target ` for local or CI builds. The CLI handles +library builds, binding generation, and app packaging without extra scripts. **Important iOS Build Details:** -The `build-ios.sh` script creates an xcframework with the following structure: +The mobench iOS builder creates an xcframework with the following structure: + ``` target/ios/sample_fns.xcframework/ ├── Info.plist # XCFramework manifest @@ -195,6 +162,7 @@ target/ios/sample_fns.xcframework/ ``` **Key Configuration Details:** + - Framework binary must be named `sample_fns` (the module name), not the platform identifier - Each framework slice must be in `{LibraryIdentifier}/sample_fns.framework/` directory structure - Module map defines the C module as `sample_fnsFFI` (matches what UniFFI-generated Swift code imports) @@ -203,7 +171,8 @@ target/ios/sample_fns.xcframework/ - The Xcode project uses a bridging header (`BenchRunner-Bridging-Header.h`) to expose C FFI types to Swift - UniFFI-generated Swift bindings are compiled directly into the app (no `import sample_fns` needed) -**Automatic Code Signing**: The build script automatically signs the xcframework with: +**Automatic Code Signing**: The build step automatically signs the xcframework with: + ```bash codesign --force --deep --sign - target/ios/sample_fns.xcframework ``` @@ -215,6 +184,7 @@ Note: UniFFI C headers are generated automatically during the build process and ### Running Benchmarks #### Local Testing (No BrowserStack) + ```bash # Build artifacts and write bench_spec.json (launch the app manually) cargo mobench run \ @@ -226,6 +196,7 @@ cargo mobench run \ ``` #### BrowserStack Run (Android) + ```bash # Set credentials export BROWSERSTACK_USERNAME="your_username" @@ -242,6 +213,7 @@ cargo mobench run \ ``` #### BrowserStack Run (iOS) + ```bash cargo mobench run \ --target ios \ @@ -255,6 +227,7 @@ cargo mobench run \ ``` #### Using Config Files + ```bash # Generate starter config cargo mobench init --output bench-config.toml --target android @@ -267,6 +240,7 @@ cargo mobench run --config bench-config.toml ``` #### Fetch BrowserStack Results + ```bash # Download results from previous run cargo mobench fetch \ @@ -293,7 +267,7 @@ fn my_expensive_operation() { The macro automatically registers functions at compile time via the `inventory` crate. -### FFI Boundary (`examples/basic-benchmark`) +### FFI Boundary (`examples/ffi-benchmark`) The example crate uses **UniFFI proc macros** to generate type-safe bindings for Kotlin and Swift. The API is defined directly in Rust code with attributes: @@ -321,15 +295,17 @@ uniffi::setup_scaffolding!(); // Auto-uses crate name as namespace ``` Regenerate bindings after modifying FFI types (for repository development): + ```bash # Build library to generate metadata -cargo build -p basic-benchmark +cargo build -p ffi-benchmark # Generate Kotlin + Swift bindings -./scripts/generate-bindings.sh +cargo mobench build --target android ``` Generated files (committed to git for the example app): + - Kotlin: `android/app/src/main/java/uniffi/sample_fns/sample_fns.kt` - Swift: `ios/BenchRunner/BenchRunner/Generated/sample_fns.swift` - C header: `ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h` @@ -337,6 +313,7 @@ Generated files (committed to git for the example app): ### Template System The SDK embeds Android and iOS app templates using the `include_dir!` macro: + - Templates located in `crates/mobench-sdk/templates/` - Embedded at compile time (no runtime file access needed) - Generated projects are created in user's workspace via `cargo mobench init` @@ -346,6 +323,7 @@ The SDK embeds Android and iOS app templates using the `include_dir!` macro: The CLI writes benchmark parameters to `target/mobile-spec/{android,ios}/bench_spec.json` during build. Mobile apps read this at runtime to know which function to benchmark. When using SDK-generated projects: + - Templates include spec reading logic - Apps automatically parse `bench_spec.json` from assets/bundle - Supports runtime parameter override via Intent extras (Android) or environment variables (iOS) @@ -353,6 +331,7 @@ When using SDK-generated projects: ### BrowserStack Credentials Credentials are resolved in this order: + 1. Config file (supports `${ENV_VAR}` expansion) 2. Environment variables: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `BROWSERSTACK_PROJECT` 3. `.env.local` file (loaded automatically via `dotenvy`) @@ -360,6 +339,7 @@ Credentials are resolved in this order: ### CI/CD (`.github/workflows/mobile-bench.yml`) The workflow supports manual dispatch with platform selection: + - Runs host tests first - Builds Android APK and/or iOS xcframework - Uploads artifacts @@ -370,6 +350,7 @@ The workflow supports manual dispatch with platform selection: ### Using mobench-sdk in Your Project 1. Add dependencies to your `Cargo.toml`: + ```toml [dependencies] mobench-sdk = "0.1" @@ -377,6 +358,7 @@ inventory = "0.3" ``` 1. Mark functions with `#[benchmark]`: + ```rust use mobench_sdk::benchmark; @@ -388,23 +370,25 @@ fn my_function() { ``` 1. Build for mobile: + ```bash cargo mobench build --target android cargo mobench build --target ios ``` 1. Run benchmarks: + ```bash cargo mobench run --target android --function my_function ``` ### Adding New Benchmark Functions to Repository Example -1. Add function to `examples/basic-benchmark/src/lib.rs` +1. Add function to `crates/sample-fns/src/lib.rs` 2. Add function dispatch to `run_benchmark()` match statement (e.g., `"my_func" => run_closure(spec, || my_func())`) 3. If adding new FFI types, add proc macro attributes (`#[derive(uniffi::Record)]`, `#[uniffi::export]`, etc.) -4. Regenerate bindings: `./scripts/generate-bindings.sh` -5. Rebuild native libraries: `cargo mobench build --target ` or use legacy scripts +4. Regenerate bindings: `cargo mobench build --target android` +5. Rebuild native libraries: `cargo mobench build --target ` 6. Mobile apps will automatically use the updated bindings **Note**: No UDL file needed! Proc macros automatically detect FFI types from Rust code. @@ -416,9 +400,10 @@ cargo mobench run --target android --function my_function ### XCFramework Structure -`scripts/build-ios.sh` manually constructs an xcframework (not using `xcodebuild -create-xcframework`) by creating framework slices for each target with proper Info.plist and module.modulemap files. +The mobench iOS builder manually constructs an xcframework (not using `xcodebuild -create-xcframework`) by creating framework slices for each target with proper Info.plist and module.modulemap files. **Critical Implementation Details:** + 1. **Directory Structure**: Each framework must be in `{LibraryIdentifier}/{FrameworkName}.framework/`, not directly at the root. For example: `ios-simulator-arm64/sample_fns.framework/`, not `ios-simulator-arm64.framework/`. 2. **Framework Binary Naming**: The binary inside each framework slice must be named after the module (`sample_fns`), not the platform identifier (`ios-simulator-arm64`). This is what Xcode's linker expects. @@ -426,6 +411,7 @@ cargo mobench run --target android --function my_function 3. **Module Map**: The C module in `module.modulemap` must be named `sample_fnsFFI` to match what UniFFI-generated Swift code tries to import via `#if canImport(sample_fnsFFI)`. 4. **Platform Identifiers**: The framework Info.plist uses Apple's official platform names: + - Device: `CFBundleSupportedPlatforms = ["iPhoneOS"]` - Simulator: `CFBundleSupportedPlatforms = ["iPhoneSimulator"]` @@ -439,11 +425,12 @@ cargo mobench run --target android --function my_function ### Gradle Integration (Android) -The Android app expects `.so` files under `android/app/src/main/jniLibs/{abi}/libsample_fns.so`. The `sync-android-libs.sh` script copies them from `target/android/{abi}/release/` to the correct locations. +The Android app expects `.so` files under `android/app/src/main/jniLibs/{abi}/libsample_fns.so`. The mobench Android builder copies them from `target/android/{abi}/release/` to the correct locations. ## Configuration Files ### `bench-config.toml` (generated by `init` command) + ```toml target = "android" function = "sample_fns::fibonacci" @@ -463,6 +450,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" ``` ### `device-matrix.yaml` (generated by `plan` command) + ```yaml devices: - name: Google Pixel 7-13.0 @@ -478,51 +466,63 @@ devices: ## Common iOS Build Issues and Solutions ### Issue: "The Framework 'sample_fns.xcframework' is unsigned" + **Solution**: Code-sign the xcframework after building: + ```bash codesign --force --deep --sign - target/ios/sample_fns.xcframework ``` ### Issue: "While building for iOS Simulator, no library for this platform was found" + **Root Cause**: Incorrect xcframework structure (frameworks at wrong path or incorrectly named). -**Solution**: Ensure `build-ios.sh` creates the correct structure with frameworks in subdirectories: +**Solution**: Ensure the iOS builder creates the correct structure with frameworks in subdirectories: + ``` ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) ``` ### Issue: "framework 'ios-simulator-arm64' not found" (linker error) + **Root Cause**: Framework LibraryPath in xcframework Info.plist points to wrong name. **Solution**: Verify xcframework Info.plist has: + ```xml LibraryPath sample_fns.framework ``` ### Issue: "Unable to find module dependency: 'sample_fns'" in Swift + **Root Cause**: Trying to import the module when it should be compiled directly into the app. **Solution**: Remove `import sample_fns` from Swift files. The UniFFI-generated Swift bindings are compiled into the app target, and C types are exposed via the bridging header. ### Issue: "Cannot find type 'RustBuffer' in scope" + **Root Cause**: Bridging header missing or not configured. **Solution**: + 1. Ensure `BenchRunner-Bridging-Header.h` exists with `#import "sample_fnsFFI.h"` 2. Verify `project.yml` has `SWIFT_OBJC_BRIDGING_HEADER` set 3. Regenerate Xcode project: `xcodegen generate` ### Issue: "Framework had an invalid CFBundleIdentifier" + **Root Cause**: Framework bundle ID conflicts with app bundle ID. **Solution**: Use different bundle IDs: + - Framework: `dev.world.sample-fns` - App: `dev.world.bench` ## Important Files ### Core SDK Crates + - **`crates/mobench/`**: CLI tool (published to crates.io) - `src/main.rs`: CLI entry point with commands (init, build, run, fetch, etc.) - `src/browserstack.rs`: BrowserStack REST API client @@ -540,9 +540,9 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) - `src/lib.rs`: Core timing and reporting logic ### Example & Testing -- **`examples/basic-benchmark/`**: Example benchmark crate demonstrating SDK usage - - `src/lib.rs`: Sample benchmark functions with UniFFI bindings - - `src/bin/generate-bindings.rs`: Binding generation for Kotlin/Swift + +- **`examples/basic-benchmark/`**: Minimal SDK usage example +- **`examples/ffi-benchmark/`**: Full UniFFI surface example - **`android/`**: Android test app (for repository development) - `app/src/main/java/dev/world/bench/MainActivity.kt`: Android app entry point - `app/src/main/java/uniffi/sample_fns/sample_fns.kt`: Generated Kotlin bindings @@ -553,16 +553,14 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) - `project.yml`: XcodeGen project specification ### Documentation + - **`BUILD.md`**: Complete build reference with prerequisites and troubleshooting - **`TESTING.md`**: Comprehensive testing guide with detailed troubleshooting - **`BENCH_SDK_INTEGRATION.md`**: Integration guide for SDK users - **`PROJECT_PLAN.md`**: Goals, architecture, task backlog - **`CLAUDE.md`**: This file - developer guide for the codebase -### Legacy Build Scripts (Repository Development Only) -- **`scripts/build-android.sh`**: Builds Rust libs with cargo-ndk for Android targets -- **`scripts/build-ios.sh`**: Builds iOS xcframework with correct structure and code signing -- **`scripts/sync-android-libs.sh`**: Copies .so files into Android jniLibs structure -- **`scripts/generate-bindings.sh`**: Regenerates UniFFI bindings for Kotlin/Swift +### Build Tooling -**Note**: SDK users should use `cargo mobench build` instead of calling scripts directly. +Use `cargo mobench build --target ` for repository development and CI. The CLI +handles native builds, binding generation, and packaging. diff --git a/Cargo.lock b/Cargo.lock index 07fa5e4..f6d1662 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,10 +123,6 @@ version = "0.1.0" dependencies = [ "inventory", "mobench-sdk", - "serde", - "serde_json", - "thiserror 1.0.69", - "uniffi", ] [[package]] @@ -313,6 +309,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ffi-benchmark" +version = "0.1.0" +dependencies = [ + "inventory", + "mobench-sdk", + "serde", + "serde_json", + "thiserror 1.0.69", + "uniffi", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -793,7 +801,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "clap", @@ -813,7 +821,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.5" +version = "0.1.6" dependencies = [ "proc-macro2", "quote", @@ -822,7 +830,7 @@ dependencies = [ [[package]] name = "mobench-runner" -version = "0.1.5" +version = "0.1.6" dependencies = [ "serde", "thiserror 1.0.69", @@ -830,7 +838,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "include_dir", @@ -1156,7 +1164,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.5" +version = "0.1.6" dependencies = [ "camino", "mobench-runner", diff --git a/Cargo.toml b/Cargo.toml index 66d36c7..c125948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,13 +6,14 @@ members = [ "crates/mobench-sdk", "crates/sample-fns", "examples/basic-benchmark", + "examples/ffi-benchmark", ] resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.5" +version = "0.1.6" [workspace.dependencies] anyhow = "1" diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 0f56510..2463f71 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -16,7 +16,7 @@ ## Architecture Outline - `mobench`: Orchestrates builds, packaging, upload, AppAutomate sessions, and result collation. -- `bench-runner`: Minimal Rust harness compiled into mobile libs; exposes FFI entrypoints for target functions and collects timings. +- `mobench-runner`: Minimal Rust harness compiled into mobile libs; exposes FFI entrypoints for target functions and collects timings. - Mobile bindings: - Android: Kotlin wrapper + APK test harness embedding Rust lib (cargo-ndk); uses Espresso/Appium-style entrypoints for AppAutomate. - iOS: Swift wrapper + test host app/xcframework; invokes Rust via C-ABI bindings. @@ -31,7 +31,7 @@ ## Task Backlog -- [x] Repo bootstrap: Cargo workspace, `mobench` binary crate, `bench-runner` library crate, example `sample-fns` crate. +- [x] Repo bootstrap: Cargo workspace, `mobench` binary crate, `mobench-runner` library crate, example `sample-fns` crate. - [x] Define FFI boundary: macro/attribute to mark benchmarkable Rust functions; export through C ABI; basic timing harness. - [x] Android packaging: cargo-ndk config, Kotlin wrapper module, minimal test/activity to trigger Rust bench entrypoint. - [x] iOS packaging: xcframework build script (cargo lipo or cargo-apple), C header generation (cbindgen), Swift wrapper, test host. @@ -52,6 +52,6 @@ ## In-Repo Placeholders (current) -- Scripts: `scripts/build-android.sh`, `scripts/build-ios.sh` for manual/CI builds (require Android NDK / cargo-apple). +- CLI: `cargo mobench build --target ` for manual/CI builds (requires Android NDK/Xcode as appropriate). - Android demo app: `android/` Gradle project that loads the Rust demo cdylib (`sample-fns`) and displays results. - Workflow: `.github/workflows/mobile-bench.yml` manual build for Android; extend with BrowserStack upload/run and iOS job. diff --git a/README.md b/README.md index 5f6abb4..f354fa4 100644 --- a/README.md +++ b/README.md @@ -1,527 +1,55 @@ -# mobile-bench-rs → mobench-sdk +# mobench -**Mobile benchmarking SDK for Rust** - Run Rust benchmarks on real Android and iOS devices. +Mobile benchmarking SDK for Rust. Build and run Rust benchmarks on Android and iOS, locally or on BrowserStack, with a library-first workflow. -> **Phase 1 MVP Complete!** This project has been transformed into an importable library crate (`mobench-sdk`) that can be published to crates.io. +## What it is -## 🎯 For SDK Integrators +mobench provides a Rust API and a CLI for running benchmarks on real mobile devices. You define benchmarks in Rust, generate mobile bindings automatically, and drive execution from the CLI with consistent output formats (JSON, Markdown, CSV). -**Importing mobench-sdk into your project?** You do **NOT** need the `scripts/` directory! +## How mobench works -- ✅ Use `cargo mobench build --target ` for all builds -- ✅ All build logic is in pure Rust (no shell scripts required) -- ✅ Templates are embedded in the binary -- ✅ See **[BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md)** for the integration guide +- `#[benchmark]` marks functions and registers them via `inventory` +- `mobench-sdk` builds mobile artifacts and generates app templates from embedded assets +- UniFFI proc macros generate Kotlin and Swift bindings directly from Rust types +- The CLI writes a benchmark spec (function, iterations, warmup) and packages it into the app +- Mobile apps call `run_benchmark` via the generated bindings and return timing samples +- The CLI collects results locally or from BrowserStack and writes summaries -**The `scripts/` directory** is legacy tooling for developing this repository. SDK users should ignore it. +## Workspace crates ---- +- `crates/mobench` ([mobench](https://crates.io/crates/mobench)): CLI tool that builds, runs, and fetches benchmarks +- `crates/mobench-sdk` ([mobench-sdk](https://crates.io/crates/mobench-sdk)): core SDK (builders, registry, codegen) +- `crates/mobench-macros` ([mobench-macros](https://crates.io/crates/mobench-macros)): `#[benchmark]` proc macro +- `crates/mobench-runner` ([mobench-runner](https://crates.io/crates/mobench-runner)): lightweight timing harness +- `crates/sample-fns`: sample benchmarks and UniFFI bindings +- `examples/basic-benchmark`: minimal SDK integration example +- `examples/ffi-benchmark`: full UniFFI/FFI surface example -## 🚀 What's New in Phase 1 - -### Library-First Design - -Use `mobench-sdk` in any Rust project: - -```toml -[dependencies] -mobench-sdk = "0.1" -inventory = "0.3" # Required for registry -``` - -### #[benchmark] Macro - -Mark functions for benchmarking: - -```rust -use mobench_sdk::benchmark; - -#[benchmark] -fn my_expensive_operation() { - let result = compute_something(); - std::hint::black_box(result); -} -``` - -### New CLI Commands - -```bash -# Initialize SDK project -cargo mobench init-sdk --target android --project-name my-bench - -# Build mobile artifacts -cargo mobench build --target android - -# Package iOS app as IPA (for BrowserStack or physical devices) -cargo mobench package-ipa --method adhoc - -# List discovered benchmarks -cargo mobench list -``` - -### Architecture - -- **mobench-sdk**: Core library (registry, runner, builders, codegen) -- **mobench-macros**: `#[benchmark]` proc macro -- **mobench**: CLI tool for building, testing, and running benchmarks -- **examples/basic-benchmark**: Example using the new SDK - ---- - -## Original README (Legacy Information) - -## Layout - -- `crates/mobench`: CLI orchestrator for building/packaging benchmarks and driving BrowserStack runs. -- `crates/bench-runner`: Shared harness that will be embedded in Android/iOS binaries; currently host-side only. -- `crates/sample-fns`: Small Rust functions used as demo benchmarks with UniFFI bindings for mobile platforms. -- `PROJECT_PLAN.md`: Goals, architecture outline, and initial task backlog. -- `android/`: Minimal Android app that loads the Rust demo library; Gradle project for BrowserStack/AppAutomate runs. - -## Quick Start - -### Host Testing (No Mobile Build Required) - -Run the host-side Rust tests: - -```bash -cargo test --all -``` - -### Mobile Testing - -For complete end-to-end testing on Android/iOS, see the **[BrowserStack Workflow](#browserstack-workflow)** section below. - -**Quick commands:** - -- **Android**: `scripts/build-android-app.sh` then install APK -- **iOS**: `scripts/build-ios.sh` then open in Xcode - -### Generate Config Files - -```bash -cargo mobench init --output bench-config.toml -cargo mobench plan --output device-matrix.yaml -``` - -### Run Outputs - -`cargo mobench run` writes a JSON summary to `run-summary.json` by default and -produces a Markdown summary alongside it (`run-summary.md`). Use `--output` to -change the base filename and `--summary-csv` to emit a CSV summary. - -## UniFFI Bindings (Proc Macro Mode) - -This project uses [UniFFI](https://mozilla.github.io/uniffi-rs/) with **proc macros** to generate type-safe Kotlin and Swift bindings from Rust code. - -### Adding New FFI Types - -No UDL file needed! Just add proc macro attributes to your Rust types: - -```rust -#[derive(uniffi::Record)] -pub struct MyBenchmark { - pub name: String, - pub iterations: u32, -} - -#[uniffi::export] -pub fn run_my_benchmark(spec: MyBenchmark) -> Result { - // Your implementation -} - -uniffi::setup_scaffolding!(); // Auto-uses crate name as namespace -``` - -### Regenerating Bindings - -After modifying FFI types in `crates/sample-fns/src/lib.rs`: - -```bash -# Build the library first -cargo build -p sample-fns - -# Generate Kotlin + Swift bindings -./scripts/generate-bindings.sh -``` - -Generated files (committed to git for reproducibility): - -- **Kotlin**: `android/app/src/main/java/uniffi/sample_fns/sample_fns.kt` -- **Swift**: `ios/BenchRunner/BenchRunner/Generated/sample_fns.swift` -- **C header**: `ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h` - -The UniFFI API exposes: - -- `runBenchmark(spec: BenchSpec) -> BenchReport`: Run a benchmark by name -- `BenchSpec(name, iterations, warmup)`: Benchmark configuration -- `BenchReport`: Contains timing samples and statistics -- `BenchError`: Type-safe error handling (InvalidIterations, UnknownFunction, ExecutionFailed) - -## Testing Workflows - -mobile-bench-rs supports two testing workflows: - -1. **[Local Development](#local-development-workflow)**: Test on emulators/simulators or connected devices using Android Studio/Xcode -2. **[BrowserStack Testing](#browserstack-workflow)**: Test on real devices in the cloud using BrowserStack App Automate - ---- - -## Deferred QoL (Post-Feedback) - -These improvements are intentionally deferred until we have real usage feedback: - -- Parallel device runs and retry policies -- Additional percentile/statistics enhancements -- Energy or thermal readings where supported - -## Local Development Workflow - -Test your benchmarks locally using Android Studio or Xcode. This is the fastest way to iterate during development. - -### Android (Local) - -#### Quick Start (All-in-One) - -```bash -# Build everything and create APK -# Set UNIFFI_ANDROID_ABI for emulator ABI (x86_64 for default Android Studio emulators). -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh - -# Install and launch on emulator/device -adb install -r android/app/build/outputs/apk/debug/app-debug.apk -adb shell am start -n dev.world.bench/.MainActivity -``` - -#### Step-by-Step - -```bash -# 1. Build Rust libraries + regenerate bindings (ABI-aware) + sync jniLibs -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh - -# 2. Build the APK with Gradle -cd android && ./gradlew :app:assembleDebug - -# 3. Install and launch -adb install -r app/build/outputs/apk/debug/app-debug.apk -adb shell am start -n dev.world.bench/.MainActivity -``` - -#### Testing with Custom Parameters - -```bash -# Launch with custom benchmark function and parameters -adb shell am start -n dev.world.bench/.MainActivity \ - --es bench_function sample_fns::checksum \ - --ei bench_iterations 30 \ - --ei bench_warmup 5 -``` - -#### Using Android Studio - -1. Open the `android/` directory in Android Studio -2. Ensure Rust libraries are built: `scripts/build-android.sh` -3. Sync libs: `scripts/sync-android-libs.sh` -4. Click Run (the app module should auto-sync) -5. Select emulator/device and run - -**Expected Output**: The app displays formatted benchmark results with individual sample timings and statistics (min/max/avg). - -### iOS (Local) - -#### Prerequisites - -```bash -# Install xcodegen if not already installed -brew install xcodegen - -# Install Rust iOS targets -rustup target add aarch64-apple-ios aarch64-apple-ios-sim -``` - -#### Step-by-Step - -```bash -# 1. Build Rust xcframework for iOS (includes UniFFI headers and automatic code signing) -scripts/build-ios.sh - -# The script creates a properly structured xcframework with: -# - Static libraries for device (aarch64-apple-ios) and simulator (aarch64-apple-ios-sim) -# - UniFFI-generated C headers in each framework slice -# - Module maps for Swift interop -# - Correct bundle identifiers and platform identifiers -# - Automatic code signing for Xcode compatibility - -# 2. Generate Xcode project from project.yml -cd ios/BenchRunner -xcodegen generate - -# 3. Open in Xcode -open BenchRunner.xcodeproj -``` - -Then in Xcode: - -1. Select a simulator (e.g., iPhone 15) or connected device -2. Click Run (⌘R) or Product → Run -3. The app will display benchmark results - -**Note**: The project uses a bridging header to expose C FFI types from Rust to Swift. The UniFFI-generated Swift bindings are compiled directly into the app (no module import needed). - -#### Testing with Custom Parameters - -**Method 1: Edit Scheme (Xcode)** - -1. Product → Scheme → Edit Scheme... -2. Run → Arguments → Environment Variables -3. Add variables: - - `BENCH_FUNCTION` = `sample_fns::checksum` - - `BENCH_ITERATIONS` = `30` - - `BENCH_WARMUP` = `5` -4. Run the app - -**Method 2: Command Line (simulator only)** +## Quick start ```bash -# Build and run with xcrun -xcrun simctl launch booted dev.world.bench.BenchRunner \ - --bench-function=sample_fns::checksum \ - --bench-iterations=30 \ - --bench-warmup=5 -``` - -**Expected Output**: The app displays formatted benchmark results with individual sample timings and statistics (min/max/avg). +# Install the CLI +cargo install mobench -### Key Features +# Add the SDK to your project +cargo add mobench-sdk inventory -The build process is **streamlined** with UniFFI proc macros: - -- ✅ No UDL file needed - proc macros define the FFI from Rust code -- ✅ No need to run `cbindgen` manually -- ✅ UniFFI headers (`sample_fnsFFI.h`) are automatically generated during `build-ios.sh` -- ✅ Kotlin/Swift bindings are already committed to git -- ✅ Only regenerate bindings if you change FFI types in Rust (via `./scripts/generate-bindings.sh`) -- ✅ Apps show formatted output with statistics (min/max/avg in microseconds) -- ✅ Type-safe error handling (no more string parsing) - ---- - -## BrowserStack Workflow - -Test your benchmarks on real devices in the cloud using BrowserStack App Automate. This workflow uploads your app to BrowserStack, runs tests remotely, and downloads results. - -### Prerequisites - -1. **BrowserStack Account**: Sign up at [browserstack.com](https://www.browserstack.com/) -2. **Credentials**: Set environment variables: - ```bash - export BROWSERSTACK_USERNAME="your_username" - export BROWSERSTACK_ACCESS_KEY="your_access_key" - ``` -3. **Built Artifacts**: Build your app and test suite first (see below) - -### Android + BrowserStack (Espresso) - -#### Step 1: Build Artifacts - -```bash -# Build Android app APK and test suite -UNIFFI_ANDROID_ABI=x86_64 ./scripts/build-android-app.sh - -# Build test APK (if needed) -cd android -./gradlew :app:assembleDebugAndroidTest -cd .. -``` - -Artifacts created: -- **App APK**: `android/app/build/outputs/apk/debug/app-debug.apk` -- **Test APK**: `android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk` - -#### Step 2: Run on BrowserStack - -```bash -# Run benchmark on specific device -cargo mobench run \ - --target android \ - --function sample_fns::fibonacci \ - --iterations 100 \ - --warmup 10 \ - --devices "Google Pixel 7-13.0" \ - --output run-summary.json -``` - -**What happens:** -1. CLI uploads APKs to BrowserStack -2. Schedules Espresso test run on specified device -3. Waits for completion -4. Downloads logs and results -5. Saves summary to `run-summary.json` - -#### Step 3: View Results - -```bash -# Results are in run-summary.json -cat run-summary.json - -# BrowserStack artifacts downloaded to: -# target/browserstack/{build_id}/session-{session_id}/ -``` - -**BrowserStack Dashboard**: View live test execution at https://app-automate.browserstack.com/dashboard - -### iOS + BrowserStack (XCUITest) - -#### Step 1: Build Artifacts - -```bash -# Build iOS app and xcframework -./scripts/build-ios.sh - -# Generate Xcode project -cd ios/BenchRunner -xcodegen generate - -# Build app for device (requires signing) -xcodebuild -project BenchRunner.xcodeproj \ - -scheme BenchRunner \ - -sdk iphoneos \ - -configuration Release \ - -derivedDataPath build \ - CODE_SIGN_IDENTITY="-" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO - -# Create IPA -mkdir -p Payload -cp -r build/Build/Products/Release-iphoneos/BenchRunner.app Payload/ -zip -r BenchRunner.ipa Payload/ -mv BenchRunner.ipa ../../target/ios/ - -# Build XCUITest runner -xcodebuild build-for-testing \ - -project BenchRunner.xcodeproj \ - -scheme BenchRunner \ - -sdk iphoneos \ - -derivedDataPath build - -# Package test runner -cd build/Build/Products/Release-iphoneos -zip -r BenchRunnerUITests-Runner.zip BenchRunnerUITests-Runner.app -mv BenchRunnerUITests-Runner.zip ../../../../target/ios/ -cd ../../../.. -``` - -Artifacts created: -- **App IPA**: `target/ios/BenchRunner.ipa` -- **Test Suite**: `target/ios/BenchRunnerUITests-Runner.zip` - -#### Step 2: Run on BrowserStack - -```bash -# Run benchmark on specific device -cargo mobench run \ - --target ios \ - --function sample_fns::fibonacci \ - --iterations 100 \ - --warmup 10 \ - --devices "iPhone 14-16" \ - --ios-app target/ios/BenchRunner.ipa \ - --ios-test-suite target/ios/BenchRunnerUITests-Runner.zip \ - --output run-summary.json -``` - -**What happens:** -1. CLI uploads IPA and test suite to BrowserStack -2. Schedules XCUITest run on specified device -3. Waits for completion -4. Downloads logs and results -5. Saves summary to `run-summary.json` - -### Using Config Files (Recommended) - -For repeated runs, use config files: - -```bash -# Generate templates -cargo mobench init --output bench-config.toml --target android -cargo mobench plan --output device-matrix.yaml -``` - -**bench-config.toml:** -```toml -target = "android" -function = "sample_fns::fibonacci" -iterations = 100 -warmup = 10 -device_matrix = "device-matrix.yaml" - -[browserstack] -app_automate_username = "${BROWSERSTACK_USERNAME}" -app_automate_access_key = "${BROWSERSTACK_ACCESS_KEY}" -project = "mobile-bench-rs" -``` +# Build artifacts +cargo mobench build --target android +cargo mobench build --target ios -**device-matrix.yaml:** -```yaml -devices: - - name: Google Pixel 7-13.0 - os: android - os_version: "13.0" - tags: [default, pixel] - - name: Samsung Galaxy S23-13.0 - os: android - os_version: "13.0" - tags: [samsung] +# Run a benchmark +cargo mobench run --target android --function sample_fns::fibonacci ``` -**Run with config:** -```bash -cargo mobench run --config bench-config.toml -``` - -### BrowserStack Features - -- **Device Logs**: Automatically downloaded to `target/browserstack/{build_id}/session-{session_id}/` -- **Screenshots/Video**: Available in BrowserStack dashboard -- **Parallel Testing**: Specify multiple devices to run in parallel -- **Network Conditions**: Configure via BrowserStack dashboard -- **Real Devices**: Tests run on actual hardware, not emulators - ---- - -## Requirements - -### Android - -- Android Studio (SDK + NDK manager): https://developer.android.com/studio -- Android NDK (API level 24+): https://developer.android.com/ndk/downloads -- `ANDROID_NDK_HOME` environment variable set -- `cargo-ndk` installed: `cargo install cargo-ndk` (https://github.com/bbqsrc/cargo-ndk) -- JDK 17+ (for Gradle; any distribution): https://openjdk.org/install/ - - Note: Android Gradle Plugin (AGP) officially supports Java 17. -- For local testing: Android emulator or physical device -- For BrowserStack: BrowserStack account and credentials - -### iOS - -- macOS with Xcode command-line tools: https://developer.apple.com/xcode/ -- Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim` (https://doc.rust-lang.org/rustup/targets.html) -- `xcodegen` installed (optional): https://github.com/yonaskolb/XcodeGen -- For local testing: iOS Simulator or physical device (requires code signing) -- For BrowserStack: BrowserStack account and credentials - ---- - -## Additional Documentation - -- **`BUILD.md`**: Complete build reference guide for Android and iOS (prerequisites, step-by-step instructions, troubleshooting) -- **`TESTING.md`**: Comprehensive testing guide with troubleshooting and advanced scenarios -- **`PROJECT_PLAN.md`**: Project goals, architecture, and task backlog -- **`CLAUDE.md`**: Developer guide for this codebase - ---- +## Project docs -## License +- `BENCH_SDK_INTEGRATION.md`: SDK integration guide +- `BUILD.md`: build prerequisites and troubleshooting +- `TESTING.md`: testing guide and device workflows +- `BROWSERSTACK_CI_INTEGRATION.md`: BrowserStack CI setup +- `FETCH_RESULTS_GUIDE.md`: fetching and summarizing results +- `PROJECT_PLAN.md`: goals and backlog +- `CLAUDE.md`: developer guide -MIT OR Apache-2.0 +MIT licensed — World Foundation 2026. diff --git a/TESTING.md b/TESTING.md index 6f823f2..9e8a87b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -85,8 +85,7 @@ The `Mobile Bench (manual)` workflow uploads summary artifacts: ```bash # Build everything and create APK -# For Android Studio emulators, use UNIFFI_ANDROID_ABI=x86_64 -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh +cargo mobench build --target android # Install on connected device/emulator adb install -r android/app/build/outputs/apk/debug/app-debug.apk @@ -99,7 +98,7 @@ adb shell am start -n dev.world.bench/.MainActivity ```bash # Step 1: Build Rust libraries + bindings (ABI-aware) -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh +cargo mobench build --target android # Step 2: Build APK cd android @@ -115,8 +114,7 @@ adb shell am start -n dev.world.bench/.MainActivity 1. Build Rust libraries first: ```bash - scripts/build-android.sh - scripts/sync-android-libs.sh + cargo mobench build --target android ``` 2. Open `android/` directory in Android Studio @@ -182,7 +180,7 @@ Statistics: ```bash # Step 1: Build Rust xcframework (includes automatic code signing) -scripts/build-ios.sh +cargo mobench build --target ios # This script: # - Compiles Rust for aarch64-apple-ios (device) and aarch64-apple-ios-sim (simulator) @@ -315,13 +313,13 @@ cargo install cargo-ndk ```bash # Solution: Clean and rebuild cargo clean -scripts/build-android.sh +cargo mobench build --target android ``` **Problem**: App crashes on launch with "UnsatisfiedLinkError" ```bash # Solution: Ensure .so files are in the APK -scripts/sync-android-libs.sh +cargo mobench build --target android cd android && ./gradlew clean assembleDebug ``` @@ -342,8 +340,8 @@ brew install xcodegen # Solution: Code-sign the xcframework codesign --force --deep --sign - target/ios/sample_fns.xcframework -# The build script now includes signing, but if you built manually: -scripts/build-ios.sh +# The build step includes signing, but if you built manually: +cargo mobench build --target ios cd ios/BenchRunner xcodegen generate # Clean build in Xcode (⌘+Shift+K) then build (⌘+B) @@ -383,7 +381,7 @@ xcodegen generate ```bash # Solution: Ensure xcframework was built correctly with proper structure rm -rf target/ios/sample_fns.xcframework -scripts/build-ios.sh +cargo mobench build --target ios codesign --force --deep --sign - target/ios/sample_fns.xcframework # Verify structure: @@ -398,7 +396,7 @@ ls -la target/ios/sample_fns.xcframework/ ```bash # Solution: Rebuild the xcframework - the structure may be incorrect rm -rf target/ios/sample_fns.xcframework -scripts/build-ios.sh +cargo mobench build --target ios codesign --force --deep --sign - target/ios/sample_fns.xcframework # Clean Xcode build folder @@ -410,9 +408,9 @@ xcodebuild clean -project BenchRunner.xcodeproj -scheme BenchRunner **Problem**: "Framework had an invalid CFBundleIdentifier in its Info.plist" ```bash # Solution: The framework bundle ID should not conflict with the app -# Check scripts/build-ios.sh has correct bundle ID (dev.world.sample-fns) +# Check the iOS builder uses `dev.world.sample-fns` for the framework # Rebuild: -scripts/build-ios.sh +cargo mobench build --target ios codesign --force --deep --sign - target/ios/sample_fns.xcframework ``` @@ -420,7 +418,7 @@ codesign --force --deep --sign - target/ios/sample_fns.xcframework ```bash # Solution: Clean and rebuild for simulator architecture cargo clean -scripts/build-ios.sh +cargo mobench build --target ios codesign --force --deep --sign - target/ios/sample_fns.xcframework # In Xcode, clean (⌘+Shift+K) then build (⌘+B) @@ -437,12 +435,11 @@ codesign --force --deep --sign - target/ios/sample_fns.xcframework **Problem**: Changes to FFI types in `crates/sample-fns/src/lib.rs` not reflected in mobile apps ```bash # Solution: Rebuild library and regenerate bindings -cargo build -p sample-fns -./scripts/generate-bindings.sh +cargo mobench build --target android # Then rebuild mobile apps -UNIFFI_ANDROID_ABI=x86_64 scripts/build-android-app.sh # For Android -scripts/build-ios.sh # For iOS (includes signing) +cargo mobench build --target android +cargo mobench build --target ios ``` **Problem**: "error: cannot find type `BenchSpec` in the crate root" @@ -465,12 +462,6 @@ cargo test --all # - Test assertions need updating ``` -**Problem**: "permission denied" when running scripts -```bash -# Solution: Make scripts executable -chmod +x scripts/*.sh -``` - ## Advanced Testing ### BrowserStack Integration Testing diff --git a/android/README.md b/android/README.md index 061db9a..81346c1 100644 --- a/android/README.md +++ b/android/README.md @@ -5,13 +5,9 @@ Minimal Android app that loads the Rust `sample-fns` cdylib and calls exported f ## Build steps 1. Build Rust libs for Android: ```bash - scripts/build-android.sh + cargo mobench build --target android ``` -2. Copy `.so` outputs into the app: - ```bash - scripts/sync-android-libs.sh - ``` -3. Assemble the APK (requires Java + Gradle + Android SDK/NDK on PATH): +2. Assemble the APK (requires Java + Gradle + Android SDK/NDK on PATH): ```bash cd android gradle :app:assembleDebug diff --git a/crates/bench-cli/Cargo.toml b/crates/bench-cli/Cargo.toml deleted file mode 100644 index 9d50b04..0000000 --- a/crates/bench-cli/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "bench-cli" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -anyhow.workspace = true -bench-runner = { path = "../bench-runner" } -sample-fns = { path = "../sample-fns" } -clap.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_yaml.workspace = true -toml.workspace = true -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking", "json", "multipart"] } -dotenvy = "0.15" - -[dev-dependencies] -tempfile = "3" diff --git a/crates/bench-cli/src/browserstack.rs b/crates/bench-cli/src/browserstack.rs deleted file mode 100644 index 7e78c1a..0000000 --- a/crates/bench-cli/src/browserstack.rs +++ /dev/null @@ -1,509 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use reqwest::blocking::multipart::Form; -use reqwest::blocking::{Client, Response}; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use serde_json::Value; -use std::path::Path; - -const DEFAULT_BASE_URL: &str = "https://api-cloud.browserstack.com"; -const USER_AGENT: &str = "mobile-bench-rs/0.1"; - -#[derive(Debug, Clone)] -pub struct BrowserStackAuth { - pub username: String, - pub access_key: String, -} - -/// BrowserStack App Automate (Espresso) client. -#[derive(Debug, Clone)] -pub struct BrowserStackClient { - http: Client, - auth: BrowserStackAuth, - base_url: String, - project: Option, -} - -impl BrowserStackClient { - pub fn new(auth: BrowserStackAuth, project: Option) -> Result { - let http = Client::builder() - .user_agent(USER_AGENT) - .build() - .context("building HTTP client")?; - - Ok(Self { - http, - auth, - base_url: DEFAULT_BASE_URL.to_string(), - project, - }) - } - - #[cfg(test)] - #[allow(dead_code)] // Used in tests to verify URL construction - pub fn with_base_url(mut self, base_url: impl Into) -> Self { - self.base_url = base_url.into(); - self - } - - /// Upload an Espresso app-under-test APK to BrowserStack. - pub fn upload_espresso_app(&self, artifact: &Path) -> Result { - if !artifact.exists() { - return Err(anyhow!("app artifact not found at {:?}", artifact)); - } - - let form = Form::new().file("file", artifact)?; - let resp = self - .http - .post(self.api("app-automate/espresso/v2/app")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .multipart(form) - .send() - .context("uploading app to BrowserStack")?; - - parse_response(resp, "app upload") - } - - /// Upload an Espresso test-suite APK to BrowserStack. - pub fn upload_espresso_test_suite(&self, artifact: &Path) -> Result { - if !artifact.exists() { - return Err(anyhow!("test suite artifact not found at {:?}", artifact)); - } - - let form = Form::new().file("file", artifact)?; - let resp = self - .http - .post(self.api("app-automate/espresso/v2/test-suite")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .multipart(form) - .send() - .context("uploading test suite to BrowserStack")?; - - parse_response(resp, "test suite upload") - } - - pub fn upload_xcuitest_app(&self, artifact: &Path) -> Result { - if !artifact.exists() { - return Err(anyhow!("iOS app artifact not found at {:?}", artifact)); - } - - let form = Form::new().file("file", artifact)?; - let resp = self - .http - .post(self.api("app-automate/xcuitest/v2/app")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .multipart(form) - .send() - .context("uploading iOS app to BrowserStack")?; - - parse_response(resp, "iOS app upload") - } - - pub fn upload_xcuitest_test_suite(&self, artifact: &Path) -> Result { - if !artifact.exists() { - return Err(anyhow!( - "iOS XCUITest suite artifact not found at {:?}", - artifact - )); - } - - let form = Form::new().file("file", artifact)?; - let resp = self - .http - .post(self.api("app-automate/xcuitest/v2/test-suite")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .multipart(form) - .send() - .context("uploading iOS XCUITest suite to BrowserStack")?; - - parse_response(resp, "iOS XCUITest suite upload") - } - - pub fn schedule_espresso_run( - &self, - devices: &[String], - app_url: &str, - test_suite_url: &str, - ) -> Result { - if devices.is_empty() { - return Err(anyhow!("device list is empty; provide at least one target")); - } - if app_url.is_empty() { - return Err(anyhow!("app_url is empty")); - } - if test_suite_url.is_empty() { - return Err(anyhow!("test_suite_url is empty")); - } - - let body = BuildRequest { - app: app_url.to_string(), - test_suite: test_suite_url.to_string(), - devices: devices.to_vec(), - device_logs: true, - disable_animations: true, - build_name: self.project.clone(), - }; - - let resp = self - .http - .post(self.api("app-automate/espresso/v2/build")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .json(&body) - .send() - .context("scheduling BrowserStack Espresso run")?; - - let build: BuildResponse = parse_response(resp, "schedule run")?; - Ok(ScheduledRun { - build_id: build.build_id, - }) - } - - pub fn schedule_xcuitest_run( - &self, - devices: &[String], - app_url: &str, - test_suite_url: &str, - ) -> Result { - if devices.is_empty() { - return Err(anyhow!("device list is empty; provide at least one target")); - } - if app_url.is_empty() { - return Err(anyhow!("app_url is empty")); - } - if test_suite_url.is_empty() { - return Err(anyhow!("test_suite_url is empty")); - } - - let body = XcuitestBuildRequest { - app: app_url.to_string(), - test_suite: test_suite_url.to_string(), - devices: devices.to_vec(), - device_logs: true, - build_name: self.project.clone(), - }; - - let resp = self - .http - .post(self.api("app-automate/xcuitest/v2/build")) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .json(&body) - .send() - .context("scheduling BrowserStack XCUITest run")?; - - let build: BuildResponse = parse_response(resp, "schedule run")?; - Ok(ScheduledRun { - build_id: build.build_id, - }) - } - - fn api(&self, path: &str) -> String { - format!( - "{}/{}", - self.base_url.trim_end_matches('/'), - path.trim_start_matches('/') - ) - } - - pub fn get_json(&self, path: &str) -> Result { - let resp = self - .http - .get(self.api(path)) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .send() - .with_context(|| format!("requesting BrowserStack API {}", path))?; - - parse_response(resp, path) - } - - pub fn download_url(&self, url: &str, dest: &Path) -> Result<()> { - let resp = self - .http - .get(url) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) - .send() - .with_context(|| format!("downloading BrowserStack asset {}", url))?; - let status = resp.status(); - let bytes = resp - .bytes() - .with_context(|| format!("reading BrowserStack asset body {}", url))?; - if !status.is_success() { - return Err(anyhow!( - "BrowserStack asset download failed (status {}): {}", - status, - String::from_utf8_lossy(&bytes) - )); - } - std::fs::write(dest, bytes) - .with_context(|| format!("writing BrowserStack asset to {:?}", dest))?; - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AppUpload { - #[serde(alias = "appUrl")] - pub app_url: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TestSuiteUpload { - #[serde(alias = "test_suite_url", alias = "testSuiteUrl")] - pub test_suite_url: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ScheduledRun { - pub build_id: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct BuildRequest { - app: String, - test_suite: String, - devices: Vec, - device_logs: bool, - disable_animations: bool, - #[serde(skip_serializing_if = "Option::is_none")] - build_name: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct XcuitestBuildRequest { - app: String, - test_suite: String, - devices: Vec, - device_logs: bool, - #[serde(skip_serializing_if = "Option::is_none")] - build_name: Option, -} - -#[derive(Debug, Deserialize)] -struct BuildResponse { - #[serde(alias = "build_id", alias = "buildId")] - build_id: String, -} - -fn parse_response(resp: Response, context: &str) -> Result { - let status = resp.status(); - let text = resp - .text() - .with_context(|| format!("reading BrowserStack API response body for {}", context))?; - - if !status.is_success() { - return Err(anyhow!( - "BrowserStack API {} failed (status {}): {}", - context, - status, - text - )); - } - - serde_json::from_str(&text) - .with_context(|| format!("parsing BrowserStack API response for {}", context)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_missing_artifact() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - let missing = Path::new("/tmp/definitely-missing-file"); - assert!(client.upload_espresso_app(missing).is_err()); - } - - #[test] - fn suppresses_dead_code_warning_for_test_helper() { - // This test uses with_base_url to verify it works and suppress the warning - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap() - .with_base_url("https://test.example.com"); - - assert_eq!(client.base_url, "https://test.example.com"); - } - - #[test] - fn new_client_uses_default_base_url() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "testuser".into(), - access_key: "testkey".into(), - }, - Some("test-project".into()), - ) - .unwrap(); - - assert_eq!(client.base_url, DEFAULT_BASE_URL); - assert_eq!(client.project, Some("test-project".to_string())); - } - - #[test] - fn api_constructs_url_correctly() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let url = client.api("app-automate/espresso/v2/app"); - assert_eq!( - url, - "https://api-cloud.browserstack.com/app-automate/espresso/v2/app" - ); - } - - #[test] - fn api_handles_leading_slash() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let url = client.api("/app-automate/builds"); - assert_eq!( - url, - "https://api-cloud.browserstack.com/app-automate/builds" - ); - } - - #[test] - fn api_handles_trailing_slash_in_base_url() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap() - .with_base_url("https://test.example.com/"); - - let url = client.api("endpoint"); - assert_eq!(url, "https://test.example.com/endpoint"); - } - - #[test] - fn schedule_espresso_run_rejects_empty_devices() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let result = client.schedule_espresso_run(&[], "bs://app123", "bs://test456"); - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("empty")); - } - - #[test] - fn schedule_espresso_run_rejects_empty_app_url() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let result = - client.schedule_espresso_run(&["Google Pixel 7-13.0".to_string()], "", "bs://test456"); - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("app_url")); - } - - #[test] - fn schedule_espresso_run_rejects_empty_test_suite_url() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let result = client.schedule_espresso_run( - &["Google Pixel 7-13.0".to_string()], - "bs://app123", - "", - ); - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("test_suite_url")); - } - - #[test] - fn schedule_xcuitest_run_rejects_empty_devices() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let result = client.schedule_xcuitest_run(&[], "bs://app123", "bs://test456"); - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("empty")); - } - - #[test] - fn upload_xcuitest_app_rejects_missing_artifact() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let missing = Path::new("/tmp/nonexistent-ios-app.ipa"); - assert!(client.upload_xcuitest_app(missing).is_err()); - } - - #[test] - fn upload_xcuitest_test_suite_rejects_missing_artifact() { - let client = BrowserStackClient::new( - BrowserStackAuth { - username: "user".into(), - access_key: "key".into(), - }, - None, - ) - .unwrap(); - - let missing = Path::new("/tmp/nonexistent-test-suite.zip"); - assert!(client.upload_xcuitest_test_suite(missing).is_err()); - } -} diff --git a/crates/bench-cli/src/main.rs b/crates/bench-cli/src/main.rs deleted file mode 100644 index 43310c9..0000000 --- a/crates/bench-cli/src/main.rs +++ /dev/null @@ -1,1052 +0,0 @@ -use anyhow::{Context, Result, anyhow, bail}; -use bench_runner::{BenchSpec, run_closure}; -use clap::{Parser, Subcommand, ValueEnum}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command as ProcessCommand; -use std::time::{Duration, Instant}; - -use browserstack::{BrowserStackAuth, BrowserStackClient}; - -mod browserstack; - -/// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. -#[derive(Parser, Debug)] -#[command(name = "bench-cli", author, version, about = "Mobile Rust benchmarking orchestrator", long_about = None)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// Run a benchmark against a target platform (mobile integration stub for now). - Run { - #[arg(long, value_enum)] - target: MobileTarget, - #[arg(long, help = "Fully-qualified Rust function to benchmark")] - function: String, - #[arg(long, default_value_t = 100)] - iterations: u32, - #[arg(long, default_value_t = 10)] - warmup: u32, - #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] - devices: Vec, - #[arg(long, help = "Optional path to config file")] - config: Option, - #[arg(long, help = "Optional output path for JSON report")] - output: Option, - #[arg(long, help = "Skip mobile builds and only run the host harness")] - local_only: bool, - #[arg( - long, - help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" - )] - ios_app: Option, - #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] - ios_test_suite: Option, - #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] - fetch: bool, - #[arg(long, default_value = "target/browserstack")] - fetch_output_dir: PathBuf, - #[arg(long, default_value_t = 10)] - fetch_poll_interval_secs: u64, - #[arg(long, default_value_t = 1800)] - fetch_timeout_secs: u64, - }, - /// Run a local demo against bundled sample functions to validate the harness. - Demo { - #[arg(long, default_value_t = 50)] - iterations: u32, - #[arg(long, default_value_t = 5)] - warmup: u32, - }, - /// Scaffold a base config file for the CLI. - Init { - #[arg(long, default_value = "bench-config.toml")] - output: PathBuf, - #[arg(long, value_enum, default_value_t = MobileTarget::Android)] - target: MobileTarget, - }, - /// Generate a sample device matrix file. - Plan { - #[arg(long, default_value = "device-matrix.yaml")] - output: PathBuf, - }, - /// Fetch BrowserStack build artifacts (logs, session JSON) for CI. - Fetch { - #[arg(long, value_enum)] - target: MobileTarget, - #[arg(long)] - build_id: String, - #[arg(long, default_value = "target/browserstack")] - output_dir: PathBuf, - #[arg(long, default_value_t = true)] - wait: bool, - #[arg(long, default_value_t = 10)] - poll_interval_secs: u64, - #[arg(long, default_value_t = 1800)] - timeout_secs: u64, - }, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -enum MobileTarget { - Android, - Ios, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct BrowserStackConfig { - app_automate_username: String, - app_automate_access_key: String, - project: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct IosXcuitestArtifacts { - app: PathBuf, - test_suite: PathBuf, -} - -#[derive(Debug, Serialize, Deserialize)] -struct BenchConfig { - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, - device_matrix: PathBuf, - browserstack: BrowserStackConfig, - #[serde(skip_serializing_if = "Option::is_none", default)] - ios_xcuitest: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct DeviceEntry { - name: String, - os: String, - os_version: String, - tags: Option>, -} - -#[derive(Debug, Serialize, Deserialize)] -struct DeviceMatrix { - devices: Vec, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct RunSpec { - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, - devices: Vec, - #[serde(skip_serializing, skip_deserializing, default)] - browserstack: Option, - #[serde(skip_serializing_if = "Option::is_none", default)] - ios_xcuitest: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "platform", rename_all = "lowercase")] -enum MobileArtifacts { - Android { - apk: PathBuf, - }, - Ios { - xcframework: PathBuf, - header: PathBuf, - #[serde(skip_serializing_if = "Option::is_none")] - app: Option, - #[serde(skip_serializing_if = "Option::is_none")] - test_suite: Option, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -struct RunSummary { - spec: RunSpec, - artifacts: Option, - local_report: Value, - remote_run: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "platform", rename_all = "lowercase")] -enum RemoteRun { - Android { - app_url: String, - build_id: String, - }, - Ios { - app_url: String, - test_suite_url: String, - build_id: String, - }, -} - -fn main() -> Result<()> { - load_dotenv(); - let cli = Cli::parse(); - match cli.command { - Command::Run { - target, - function, - iterations, - warmup, - devices, - config, - output, - local_only, - ios_app, - ios_test_suite, - fetch, - fetch_output_dir, - fetch_poll_interval_secs, - fetch_timeout_secs, - } => { - let spec = resolve_run_spec( - target, - function, - iterations, - warmup, - devices, - config.as_deref(), - ios_app, - ios_test_suite, - )?; - println!( - "Preparing benchmark run for {:?}: {} (iterations={}, warmup={})", - spec.target, spec.function, spec.iterations, spec.warmup - ); - persist_mobile_spec(&spec)?; - if !spec.devices.is_empty() { - println!("Devices: {}", spec.devices.join(", ")); - } - if let Some(path) = &output { - println!("JSON summary will be written to {:?}", path); - } - - let local_report = run_local_smoke(&spec)?; - let mut remote_run = None; - let artifacts = if local_only { - println!("Skipping mobile build: --local-only set"); - None - } else { - match spec.target { - MobileTarget::Android => { - let ndk = std::env::var("ANDROID_NDK_HOME") - .context("ANDROID_NDK_HOME must be set for Android builds")?; - let apk = run_android_build(&ndk)?; - println!("Built Android APK at {:?}", apk); - if spec.devices.is_empty() { - println!("Skipping BrowserStack upload/run: no devices provided"); - Some(MobileArtifacts::Android { apk }) - } else { - let run = trigger_browserstack_espresso(&spec, &apk)?; - remote_run = Some(run); - Some(MobileArtifacts::Android { apk }) - } - } - MobileTarget::Ios => { - let (xcframework, header) = run_ios_build()?; - println!("Built iOS xcframework at {:?}", xcframework); - let ios_xcuitest = spec.ios_xcuitest.clone(); - - if spec.devices.is_empty() { - println!("Skipping BrowserStack upload/run: no devices provided"); - } else { - let xcui = spec.ios_xcuitest.as_ref().context( - "iOS XCUITest artifacts required when targeting BrowserStack devices; provide --ios-app and --ios-test-suite or set ios_xcuitest in the config", - )?; - let run = trigger_browserstack_xcuitest(&spec, xcui)?; - remote_run = Some(run); - } - - Some(MobileArtifacts::Ios { - xcframework, - header, - app: ios_xcuitest.as_ref().map(|a| a.app.clone()), - test_suite: ios_xcuitest.map(|a| a.test_suite), - }) - } - } - }; - - let summary = RunSummary { - spec, - artifacts, - local_report, - remote_run, - }; - write_summary(&summary, output.as_deref())?; - - if fetch { - if let Some(remote) = &summary.remote_run { - let build_id = match remote { - RemoteRun::Android { build_id, .. } => build_id, - RemoteRun::Ios { build_id, .. } => build_id, - }; - let creds = resolve_browserstack_credentials(summary.spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username, - access_key: creds.access_key, - }, - creds.project, - )?; - let output_root = fetch_output_dir.join(build_id); - fetch_browserstack_artifacts( - &client, - summary.spec.target, - build_id, - &output_root, - true, - fetch_poll_interval_secs, - fetch_timeout_secs, - )?; - } else { - println!("No BrowserStack run to fetch (devices not provided?)"); - } - } - } - Command::Demo { iterations, warmup } => { - let spec = BenchSpec::new("sample_fns::fibonacci", iterations, warmup)?; - let report = run_closure(spec, || { - // This is the shape of the closure that will be invoked on-device; - // for now we reuse it locally. - let _ = sample_fns::fibonacci(24); - Ok(()) - })?; - - let json = serde_json::to_string_pretty(&report)?; - println!("{json}"); - } - Command::Init { output, target } => { - write_config_template(&output, target)?; - println!("Wrote starter config to {:?}", output); - } - Command::Plan { output } => { - write_device_matrix_template(&output)?; - println!("Wrote sample device matrix to {:?}", output); - } - Command::Fetch { - target, - build_id, - output_dir, - wait, - poll_interval_secs, - timeout_secs, - } => { - let creds = resolve_browserstack_credentials(None)?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username, - access_key: creds.access_key, - }, - creds.project, - )?; - let output_root = output_dir.join(&build_id); - fetch_browserstack_artifacts( - &client, - target, - &build_id, - &output_root, - wait, - poll_interval_secs, - timeout_secs, - )?; - } - } - - Ok(()) -} - -fn write_config_template(path: &Path, target: MobileTarget) -> Result<()> { - ensure_can_write(path)?; - - let ios_xcuitest = if target == MobileTarget::Ios { - Some(IosXcuitestArtifacts { - app: PathBuf::from("target/ios/BenchRunner.ipa"), - test_suite: PathBuf::from("target/ios/BenchRunnerUITests.zip"), - }) - } else { - None - }; - - let cfg = BenchConfig { - target, - function: "sample_fns::fibonacci".into(), - iterations: 100, - warmup: 10, - device_matrix: PathBuf::from("device-matrix.yaml"), - browserstack: BrowserStackConfig { - app_automate_username: "${BROWSERSTACK_USERNAME}".into(), - app_automate_access_key: "${BROWSERSTACK_ACCESS_KEY}".into(), - project: Some("mobile-bench-rs".into()), - }, - ios_xcuitest, - }; - - let contents = toml::to_string_pretty(&cfg)?; - write_file(path, contents.as_bytes()) -} - -fn write_device_matrix_template(path: &Path) -> Result<()> { - ensure_can_write(path)?; - - let matrix = DeviceMatrix { - devices: vec![ - DeviceEntry { - name: "Pixel 7".into(), - os: "android".into(), - os_version: "13.0".into(), - tags: Some(vec!["default".into(), "pixel".into()]), - }, - DeviceEntry { - name: "iPhone 14".into(), - os: "ios".into(), - os_version: "16".into(), - tags: Some(vec!["default".into(), "iphone".into()]), - }, - ], - }; - - let contents = serde_yaml::to_string(&matrix)?; - write_file(path, contents.as_bytes()) -} - -fn fetch_browserstack_artifacts( - client: &BrowserStackClient, - target: MobileTarget, - build_id: &str, - output_root: &Path, - wait: bool, - poll_interval_secs: u64, - timeout_secs: u64, -) -> Result<()> { - fs::create_dir_all(output_root) - .with_context(|| format!("creating output dir {:?}", output_root))?; - - let base = browserstack_base_path(target); - let build_path = format!("{base}/builds/{build_id}"); - let sessions_path = format!("{base}/builds/{build_id}/sessions"); - - if wait { - wait_for_build(client, &build_path, poll_interval_secs, timeout_secs)?; - } - - let build_json = client.get_json(&build_path)?; - write_json(output_root.join("build.json"), &build_json)?; - - let sessions_json = match client.get_json(&sessions_path) { - Ok(value) => { - write_json(output_root.join("sessions.json"), &value)?; - Some(value) - } - Err(err) => { - let msg = shorten_html_error(&err.to_string()); - println!("Sessions endpoint unavailable; falling back to build.json: {msg}"); - None - } - }; - - let session_ids = extract_session_ids(sessions_json.as_ref().unwrap_or(&build_json)); - if session_ids.is_empty() { - println!("No sessions found for build {}", build_id); - return Ok(()); - } - - for session_id in session_ids { - let session_path = format!("{base}/builds/{build_id}/sessions/{session_id}"); - let session_json = client.get_json(&session_path)?; - let session_dir = output_root.join(format!("session-{}", session_id)); - fs::create_dir_all(&session_dir) - .with_context(|| format!("creating session dir {:?}", session_dir))?; - write_json(session_dir.join("session.json"), &session_json)?; - - let mut bench_report: Option = None; - for (key, url) in extract_url_fields(&session_json) { - let file_name = filename_for_url(&key, &url); - let dest = session_dir.join(file_name); - if let Err(err) = client.download_url(&url, &dest) { - println!("Skipping download for {key}: {err}"); - continue; - } - if key.contains("device_log") || key.contains("instrumentation_log") || key.contains("app_log") { - if let Ok(contents) = fs::read_to_string(&dest) { - if let Some(parsed) = extract_bench_json(&contents) { - bench_report = Some(parsed); - } - } - } - } - - if let Some(report) = bench_report { - write_json(session_dir.join("bench-report.json"), &report)?; - } - } - - println!("Fetched BrowserStack artifacts to {:?}", output_root); - Ok(()) -} - -fn browserstack_base_path(target: MobileTarget) -> &'static str { - match target { - MobileTarget::Android => "app-automate/espresso/v2", - MobileTarget::Ios => "app-automate/xcuitest/v2", - } -} - -fn wait_for_build( - client: &BrowserStackClient, - build_path: &str, - poll_interval_secs: u64, - timeout_secs: u64, -) -> Result<()> { - let deadline = Instant::now() + Duration::from_secs(timeout_secs); - loop { - let build_json = client.get_json(build_path)?; - if let Some(status) = build_json - .get("status") - .and_then(|val| val.as_str()) - .map(|val| val.to_lowercase()) - { - if status == "failed" || status == "error" { - println!("Build status: {status}"); - return Ok(()); - } - if status == "done" || status == "passed" || status == "completed" { - println!("Build status: {status}"); - return Ok(()); - } - println!("Build status: {status} (waiting)"); - } else { - println!("Build status missing; continuing without wait"); - return Ok(()); - } - - if Instant::now() >= deadline { - println!("Timed out waiting for build status"); - return Ok(()); - } - std::thread::sleep(Duration::from_secs(poll_interval_secs)); - } -} - -fn extract_session_ids(value: &Value) -> Vec { - let sessions = value - .get("sessions") - .and_then(|val| val.as_array()) - .or_else(|| value.as_array()); - let mut ids = Vec::new(); - if let Some(entries) = sessions { - for entry in entries { - let id = entry - .get("id") - .or_else(|| entry.get("session_id")) - .or_else(|| entry.get("sessionId")) - .and_then(|val| val.as_str()); - if let Some(id) = id { - ids.push(id.to_string()); - } - } - } - if ids.is_empty() { - if let Some(devices) = value.get("devices").and_then(|val| val.as_array()) { - for device in devices { - if let Some(sessions) = device.get("sessions").and_then(|val| val.as_array()) { - for entry in sessions { - if let Some(id) = entry.get("id").and_then(|val| val.as_str()) { - ids.push(id.to_string()); - } - } - } - } - } - } - ids -} - -fn extract_url_fields(value: &Value) -> Vec<(String, String)> { - let mut urls = Vec::new(); - extract_url_fields_recursive(value, "", &mut urls); - urls -} - -fn extract_url_fields_recursive(value: &Value, prefix: &str, out: &mut Vec<(String, String)>) { - match value { - Value::Object(map) => { - for (key, val) in map { - let next = if prefix.is_empty() { - key.clone() - } else { - format!("{}.{}", prefix, key) - }; - if let Value::String(url) = val { - if url.starts_with("http") || url.starts_with("bs://") { - out.push((next.clone(), url.clone())); - } - } - extract_url_fields_recursive(val, &next, out); - } - } - Value::Array(items) => { - for (idx, val) in items.iter().enumerate() { - let next = format!("{}[{}]", prefix, idx); - extract_url_fields_recursive(val, &next, out); - } - } - _ => {} - } -} - -fn filename_for_url(key: &str, url: &str) -> String { - let stripped = url.split('?').next().unwrap_or(url); - let ext = Path::new(stripped) - .extension() - .and_then(|val| val.to_str()) - .unwrap_or("log"); - let mut safe = String::with_capacity(key.len()); - for ch in key.chars() { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - safe.push(ch); - } else { - safe.push('_'); - } - } - format!("{}.{}", safe, ext) -} - -fn extract_bench_json(contents: &str) -> Option { - let marker = "BENCH_JSON "; - for line in contents.lines().rev() { - if let Some(idx) = line.find(marker) { - let json_part = &line[idx + marker.len()..]; - if let Ok(value) = serde_json::from_str::(json_part) { - return Some(value); - } - } - } - None -} - -fn write_json(path: PathBuf, value: &Value) -> Result<()> { - let contents = serde_json::to_string_pretty(value)?; - write_file(&path, contents.as_bytes()) -} - -fn shorten_html_error(message: &str) -> String { - if message.contains("") || message.contains(", - config: Option<&Path>, - ios_app: Option, - ios_test_suite: Option, -) -> Result { - if let Some(cfg_path) = config { - let cfg = load_config(cfg_path)?; - let matrix = load_device_matrix(&cfg.device_matrix)?; - let device_names = matrix.devices.into_iter().map(|d| d.name).collect(); - return Ok(RunSpec { - target: cfg.target, - function: cfg.function, - iterations: cfg.iterations, - warmup: cfg.warmup, - devices: device_names, - browserstack: Some(cfg.browserstack), - ios_xcuitest: cfg.ios_xcuitest, - }); - } - - if function.trim().is_empty() { - bail!("function must not be empty"); - } - - let ios_xcuitest = match (ios_app, ios_test_suite) { - (Some(app), Some(test_suite)) => Some(IosXcuitestArtifacts { app, test_suite }), - (None, None) => None, - _ => bail!("both --ios-app and --ios-test-suite must be provided together"), - }; - - if target == MobileTarget::Ios && !devices.is_empty() && ios_xcuitest.is_none() { - bail!( - "iOS BrowserStack runs require --ios-app and --ios-test-suite or an ios_xcuitest config block" - ); - } - - Ok(RunSpec { - target, - function, - iterations, - warmup, - devices, - browserstack: None, - ios_xcuitest, - }) -} - -fn load_config(path: &Path) -> Result { - let contents = - fs::read_to_string(path).with_context(|| format!("reading config {:?}", path))?; - toml::from_str(&contents).with_context(|| format!("parsing config {:?}", path)) -} - -fn load_device_matrix(path: &Path) -> Result { - let contents = - fs::read_to_string(path).with_context(|| format!("reading device matrix {:?}", path))?; - serde_yaml::from_str(&contents).with_context(|| format!("parsing device matrix {:?}", path)) -} - -fn run_ios_build() -> Result<(PathBuf, PathBuf)> { - let root = repo_root()?; - run_cmd(ProcessCommand::new(root.join("scripts/build-ios.sh")).current_dir(&root))?; - - Ok(( - root.join("target/ios/sample_fns.xcframework"), - root.join("target/ios/include/sample_fns.h"), - )) -} - -#[derive(Debug, Clone)] -struct ResolvedBrowserStack { - username: String, - access_key: String, - project: Option, -} - -fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path) -> Result { - let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username.clone(), - access_key: creds.access_key.clone(), - }, - creds.project.clone(), - )?; - - // Upload the app-under-test APK. - let upload = client.upload_espresso_app(apk)?; - - // Upload the Espresso test-suite APK produced by Gradle. - // We rely on the standard androidTest debug output path. - let root = repo_root()?; - let test_apk = - root.join("android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk"); - let test_upload = client.upload_espresso_test_suite(&test_apk)?; - - // Schedule the Espresso build with both app and testSuite, as required by BrowserStack. - let run = client.schedule_espresso_run( - &spec.devices, - &upload.app_url, - &test_upload.test_suite_url, - )?; - println!( - "Queued BrowserStack Espresso build {} for devices: {}", - run.build_id, - spec.devices.join(", ") - ); - - Ok(RemoteRun::Android { - app_url: upload.app_url, - build_id: run.build_id, - }) -} - -fn trigger_browserstack_xcuitest( - spec: &RunSpec, - artifacts: &IosXcuitestArtifacts, -) -> Result { - let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username.clone(), - access_key: creds.access_key.clone(), - }, - creds.project.clone(), - )?; - - if !artifacts.app.exists() { - bail!( - "iOS app artifact not found at {:?}; provide a .ipa or zipped .app", - artifacts.app - ); - } - if !artifacts.test_suite.exists() { - bail!( - "iOS XCUITest test suite artifact not found at {:?}; provide the zipped test runner bundle", - artifacts.test_suite - ); - } - - let app_upload = client.upload_xcuitest_app(&artifacts.app)?; - let test_upload = client.upload_xcuitest_test_suite(&artifacts.test_suite)?; - let run = client.schedule_xcuitest_run( - &spec.devices, - &app_upload.app_url, - &test_upload.test_suite_url, - )?; - println!( - "Queued BrowserStack XCUITest build {} for devices: {}", - run.build_id, - spec.devices.join(", ") - ); - - Ok(RemoteRun::Ios { - app_url: app_upload.app_url, - test_suite_url: test_upload.test_suite_url, - build_id: run.build_id, - }) -} - -fn resolve_browserstack_credentials( - config: Option<&BrowserStackConfig>, -) -> Result { - let mut username = None; - let mut access_key = None; - let mut project = None; - - if let Some(cfg) = config { - username = Some(expand_env_var(&cfg.app_automate_username)?); - access_key = Some(expand_env_var(&cfg.app_automate_access_key)?); - project = cfg - .project - .as_ref() - .map(|p| expand_env_var(p)) - .transpose()?; - } - - if username.as_deref().map(str::is_empty).unwrap_or(true) { - if let Ok(val) = env::var("BROWSERSTACK_USERNAME") { - if !val.is_empty() { - username = Some(val); - } - } - } - if access_key.as_deref().map(str::is_empty).unwrap_or(true) { - if let Ok(val) = env::var("BROWSERSTACK_ACCESS_KEY") { - if !val.is_empty() { - access_key = Some(val); - } - } - } - if project.is_none() { - if let Ok(val) = env::var("BROWSERSTACK_PROJECT") { - if !val.is_empty() { - project = Some(val); - } - } - } - - let username = username.filter(|s| !s.is_empty()).ok_or_else(|| { - anyhow!("BrowserStack username missing; set BROWSERSTACK_USERNAME or provide in config") - })?; - let access_key = access_key.filter(|s| !s.is_empty()).ok_or_else(|| { - anyhow!("BrowserStack access key missing; set BROWSERSTACK_ACCESS_KEY or provide in config") - })?; - - Ok(ResolvedBrowserStack { - username, - access_key, - project, - }) -} - -fn expand_env_var(raw: &str) -> Result { - if let Some(stripped) = raw.strip_prefix("${").and_then(|s| s.strip_suffix('}')) { - let val = env::var(stripped) - .with_context(|| format!("resolving env var {stripped} for BrowserStack config"))?; - return Ok(val); - } - Ok(raw.to_string()) -} - -fn run_local_smoke(spec: &RunSpec) -> Result { - let bench_spec = sample_fns::BenchSpec { - name: spec.function.clone(), - iterations: spec.iterations, - warmup: spec.warmup, - }; - - let report = sample_fns::run_benchmark(bench_spec) - .map_err(|e| anyhow!("benchmark failed: {:?}", e))?; - - serde_json::to_value(&report).context("serializing benchmark report") -} - -fn persist_mobile_spec(spec: &RunSpec) -> Result<()> { - let root = repo_root()?; - let payload = json!({ - "function": spec.function, - "iterations": spec.iterations, - "warmup": spec.warmup, - }); - let contents = serde_json::to_string_pretty(&payload)?; - let targets = [ - root.join("target/mobile-spec/android/bench_spec.json"), - root.join("target/mobile-spec/ios/bench_spec.json"), - ]; - for path in targets { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("creating directory {:?}", parent))?; - } - write_file(&path, contents.as_bytes())?; - } - Ok(()) -} - -fn write_summary(summary: &RunSummary, output: Option<&Path>) -> Result<()> { - let json = serde_json::to_string_pretty(summary)?; - if let Some(path) = output { - write_file(path, json.as_bytes())?; - println!("Wrote run summary to {:?}", path); - } else { - println!("{json}"); - } - Ok(()) -} - -fn run_android_build(ndk_home: &str) -> Result { - let root = repo_root()?; - run_cmd( - ProcessCommand::new(root.join("scripts/build-android.sh")) - .env("ANDROID_NDK_HOME", ndk_home) - .current_dir(&root), - )?; - run_cmd( - ProcessCommand::new(root.join("scripts/sync-android-libs.sh")) - .env("ANDROID_NDK_HOME", ndk_home) - .current_dir(&root), - )?; - run_cmd( - ProcessCommand::new(root.join("android/gradlew")) - .arg(":app:assembleDebug") - .current_dir(root.join("android")), - )?; - - // Also build the androidTest (Espresso) test APK so it can be uploaded as the test suite. - run_cmd( - ProcessCommand::new(root.join("android/gradlew")) - .arg(":app:assembleAndroidTest") - .current_dir(root.join("android")), - )?; - - Ok(root.join("android/app/build/outputs/apk/debug/app-debug.apk")) -} - -fn load_dotenv() { - if let Ok(root) = repo_root() { - let path = root.join(".env.local"); - let _ = dotenvy::from_path(path); - } -} - -fn run_cmd(cmd: &mut ProcessCommand) -> Result<()> { - let desc = format!("{:?}", cmd); - let status = cmd.status().with_context(|| format!("running {desc}"))?; - if !status.success() { - bail!("command failed: {desc}"); - } - Ok(()) -} - -fn repo_root() -> Result { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .canonicalize() - .context("resolving repo root")?; - Ok(path) -} - -fn ensure_can_write(path: &Path) -> Result<()> { - if path.exists() { - bail!("refusing to overwrite existing file: {:?}", path); - } - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent) - .with_context(|| format!("creating parent directory {:?}", parent))?; - } - Ok(()) -} - -fn write_file(path: &Path, contents: &[u8]) -> Result<()> { - fs::write(path, contents).with_context(|| format!("writing file {:?}", path)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolves_cli_spec() { - let spec = resolve_run_spec( - MobileTarget::Android, - "sample_fns::fibonacci".into(), - 5, - 1, - vec!["pixel".into()], - None, - None, - None, - ) - .unwrap(); - assert_eq!(spec.function, "sample_fns::fibonacci"); - assert_eq!(spec.iterations, 5); - assert_eq!(spec.warmup, 1); - assert_eq!(spec.devices, vec!["pixel".to_string()]); - assert!(spec.browserstack.is_none()); - assert!(spec.ios_xcuitest.is_none()); - } - - #[test] - fn local_smoke_produces_samples() { - let spec = RunSpec { - target: MobileTarget::Android, - function: "sample_fns::fibonacci".into(), - iterations: 3, - warmup: 1, - devices: vec![], - browserstack: None, - ios_xcuitest: None, - }; - let report = run_local_smoke(&spec).expect("local harness"); - assert!(report["samples"].is_array()); - assert_eq!(report["spec"]["name"], "sample_fns::fibonacci"); - } - - #[test] - fn ios_requires_artifacts_for_browserstack() { - let err = resolve_run_spec( - MobileTarget::Ios, - "sample_fns::fibonacci".into(), - 1, - 0, - vec!["iphone".into()], - None, - None, - None, - ) - .unwrap_err(); - assert!( - err.to_string() - .contains("iOS BrowserStack runs require --ios-app and --ios-test-suite") - ); - } -} diff --git a/crates/bench-runner/Cargo.toml b/crates/bench-runner/Cargo.toml deleted file mode 100644 index ba4428a..0000000 --- a/crates/bench-runner/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "bench-runner" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -serde.workspace = true -thiserror.workspace = true diff --git a/crates/bench-runner/src/lib.rs b/crates/bench-runner/src/lib.rs deleted file mode 100644 index 915109b..0000000 --- a/crates/bench-runner/src/lib.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Shared benchmarking harness that will be compiled into mobile targets. -//! For now this runs on the host and provides the same API surface we will -//! expose over FFI to Kotlin/Swift. - -use serde::{Deserialize, Serialize}; -use std::time::{Duration, Instant}; -use thiserror::Error; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BenchSpec { - pub name: String, - pub iterations: u32, - pub warmup: u32, -} - -impl BenchSpec { - pub fn new(name: impl Into, iterations: u32, warmup: u32) -> Result { - if iterations == 0 { - return Err(BenchError::NoIterations); - } - - Ok(Self { - name: name.into(), - iterations, - warmup, - }) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BenchSample { - pub duration_ns: u64, -} - -impl BenchSample { - fn from_duration(duration: Duration) -> Self { - Self { - duration_ns: duration.as_nanos() as u64, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BenchReport { - pub spec: BenchSpec, - pub samples: Vec, -} - -#[derive(Debug, Error)] -pub enum BenchError { - #[error("iterations must be greater than zero")] - NoIterations, - #[error("benchmark function failed: {0}")] - Execution(String), -} - -pub fn run_closure(spec: BenchSpec, mut f: F) -> Result -where - F: FnMut() -> Result<(), BenchError>, -{ - if spec.iterations == 0 { - return Err(BenchError::NoIterations); - } - - for _ in 0..spec.warmup { - f()?; - } - - let mut samples = Vec::with_capacity(spec.iterations as usize); - for _ in 0..spec.iterations { - let start = Instant::now(); - f()?; - samples.push(BenchSample::from_duration(start.elapsed())); - } - - Ok(BenchReport { spec, samples }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn runs_benchmark() { - let spec = BenchSpec::new("noop", 3, 1).unwrap(); - let report = run_closure(spec, || Ok(())).unwrap(); - - assert_eq!(report.samples.len(), 3); - let non_zero = report.samples.iter().filter(|s| s.duration_ns > 0).count(); - assert!(non_zero >= 1); - } -} diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 9b55601..4ffac50 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -21,8 +21,8 @@ crate-type = ["lib"] [dependencies] # Core dependencies -mobench-runner = { version = "0.1", path = "../mobench-runner" } -mobench-macros = { version = "0.1", path = "../mobench-macros" } +mobench-runner = { version = "0.1.6", path = "../mobench-runner" } +mobench-macros = { version = "0.1.6", path = "../mobench-macros" } # Registry inventory.workspace = true diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index 8cc07a1..248f5fa 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -78,6 +78,7 @@ cargo mobench init --target android # or ios, or both ``` This creates: + - `bench-mobile/` - FFI wrapper crate - `android/` or `ios/` - Mobile app projects - `bench-config.toml` - Configuration file @@ -102,11 +103,13 @@ cargo mobench build --target android ### 4. Run on Devices Local device workflow (builds artifacts and writes the run spec; launch the app manually): + ```bash cargo mobench run --target android --function my_benchmark ``` BrowserStack: + ```bash export BROWSERSTACK_USERNAME=your_username export BROWSERSTACK_ACCESS_KEY=your_key @@ -114,6 +117,12 @@ export BROWSERSTACK_ACCESS_KEY=your_key cargo mobench run --target android --function my_benchmark --devices "Google Pixel 7-13.0" ``` +## Examples (Repository) + +- `examples/basic-benchmark`: minimal SDK usage with `#[benchmark]` +- `examples/ffi-benchmark`: full UniFFI surface with `run_benchmark` and FFI types +- `crates/sample-fns`: repository demo library used by Android/iOS test apps + ## API Documentation ### Core Functions @@ -326,9 +335,9 @@ fn btreemap_insert_1000() { │ ┌───────┴───────┐ ↓ ↓ -┌─────────────┐ ┌─────────────┐ -│ Android APK │ │ iOS IPA │ -└──────┬──────┘ └──────┬──────┘ +┌─────────────┐ ┌───────────────────────┐ +│ Android APK │ │ iOS xcframework / IPA │ +└──────┬──────┘ └──────┬────────────────┘ │ │ └───────┬───────┘ ↓ diff --git a/crates/mobench-sdk/src/runner.rs b/crates/mobench-sdk/src/runner.rs index 26b7dba..3c43d20 100644 --- a/crates/mobench-sdk/src/runner.rs +++ b/crates/mobench-sdk/src/runner.rs @@ -44,7 +44,7 @@ pub fn run_benchmark(spec: BenchSpec) -> Result { let closure = || (bench_fn.invoke)(&[]).map_err(|e| mobench_runner::BenchError::Execution(e.to_string())); - // Run the benchmark using bench-runner's timing infrastructure + // Run the benchmark using mobench-runner's timing infrastructure let report = run_closure(spec, closure)?; Ok(report) diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 87ab466..eed6932 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -22,12 +22,12 @@ path = "src/main.rs" [[bin]] name = "cargo-mobench" -path = "src/main.rs" +path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1", path = "../mobench-sdk" } -mobench-runner = { version = "0.1", path = "../mobench-runner" } +mobench-sdk = { version = "0.1.6", path = "../mobench-sdk" } +mobench-runner = { version = "0.1.6", path = "../mobench-runner" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/src/bin/cargo-mobench.rs b/crates/mobench/src/bin/cargo-mobench.rs new file mode 100644 index 0000000..f5e8e59 --- /dev/null +++ b/crates/mobench/src/bin/cargo-mobench.rs @@ -0,0 +1,6 @@ +fn main() { + if let Err(err) = mobench::run() { + eprintln!("{err:#}"); + std::process::exit(1); + } +} diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs new file mode 100644 index 0000000..0c7a816 --- /dev/null +++ b/crates/mobench/src/lib.rs @@ -0,0 +1,1961 @@ +use anyhow::{Context, Result, anyhow, bail}; +use clap::{Parser, Subcommand, ValueEnum}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::env; +use std::fmt::Write; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use browserstack::{BrowserStackAuth, BrowserStackClient}; + +mod browserstack; + +/// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. +#[derive(Parser, Debug)] +#[command(name = "mobench", author, version, about = "Mobile Rust benchmarking orchestrator", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Run a benchmark against a target platform (mobile integration stub for now). + Run { + #[arg(long, value_enum)] + target: MobileTarget, + #[arg(long, help = "Fully-qualified Rust function to benchmark")] + function: String, + #[arg(long, default_value_t = 100)] + iterations: u32, + #[arg(long, default_value_t = 10)] + warmup: u32, + #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] + devices: Vec, + #[arg(long, help = "Optional path to config file")] + config: Option, + #[arg(long, help = "Optional output path for JSON report")] + output: Option, + #[arg(long, help = "Write CSV summary alongside JSON")] + summary_csv: bool, + #[arg(long, help = "Skip mobile builds and only run the host harness")] + local_only: bool, + #[arg( + long, + help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" + )] + ios_app: Option, + #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] + ios_test_suite: Option, + #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] + fetch: bool, + #[arg(long, default_value = "target/browserstack")] + fetch_output_dir: PathBuf, + #[arg(long, default_value_t = 5)] + fetch_poll_interval_secs: u64, + #[arg(long, default_value_t = 300)] + fetch_timeout_secs: u64, + }, + /// Scaffold a base config file for the CLI. + Init { + #[arg(long, default_value = "bench-config.toml")] + output: PathBuf, + #[arg(long, value_enum, default_value_t = MobileTarget::Android)] + target: MobileTarget, + }, + /// Generate a sample device matrix file. + Plan { + #[arg(long, default_value = "device-matrix.yaml")] + output: PathBuf, + }, + /// Fetch BrowserStack build artifacts (logs, session JSON) for CI. + Fetch { + #[arg(long, value_enum)] + target: MobileTarget, + #[arg(long)] + build_id: String, + #[arg(long, default_value = "target/browserstack")] + output_dir: PathBuf, + #[arg(long, default_value_t = true)] + wait: bool, + #[arg(long, default_value_t = 10)] + poll_interval_secs: u64, + #[arg(long, default_value_t = 1800)] + timeout_secs: u64, + }, + /// Compare two run summaries for regressions. + Compare { + #[arg(long, help = "Baseline JSON summary to compare against")] + baseline: PathBuf, + #[arg(long, help = "Candidate JSON summary to compare")] + candidate: PathBuf, + #[arg(long, help = "Optional output path for markdown report")] + output: Option, + }, + /// Initialize a new benchmark project with SDK (Phase 1 MVP). + InitSdk { + #[arg(long, value_enum)] + target: SdkTarget, + #[arg(long, default_value = "bench-project")] + project_name: String, + #[arg(long, default_value = ".")] + output_dir: PathBuf, + #[arg(long, help = "Generate example benchmarks")] + examples: bool, + }, + /// Build mobile artifacts (Phase 1 MVP). + Build { + #[arg(long, value_enum)] + target: SdkTarget, + #[arg(long, help = "Build in release mode")] + release: bool, + }, + /// Package iOS app as IPA for distribution or testing. + PackageIpa { + #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] + scheme: String, + #[arg(long, value_enum, default_value = "adhoc", help = "Signing method")] + method: IosSigningMethodArg, + }, + /// List all discovered benchmark functions (Phase 1 MVP). + List, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum MobileTarget { + Android, + Ios, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +enum SdkTarget { + Android, + Ios, + Both, +} + +impl From for mobench_sdk::Target { + fn from(target: SdkTarget) -> Self { + match target { + SdkTarget::Android => mobench_sdk::Target::Android, + SdkTarget::Ios => mobench_sdk::Target::Ios, + SdkTarget::Both => mobench_sdk::Target::Both, + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +enum IosSigningMethodArg { + /// Ad-hoc signing (no Apple ID needed, works for BrowserStack) + Adhoc, + /// Development signing (requires Apple Developer account) + Development, +} + +impl From for mobench_sdk::builders::SigningMethod { + fn from(arg: IosSigningMethodArg) -> Self { + match arg { + IosSigningMethodArg::Adhoc => mobench_sdk::builders::SigningMethod::AdHoc, + IosSigningMethodArg::Development => mobench_sdk::builders::SigningMethod::Development, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct BrowserStackConfig { + app_automate_username: String, + app_automate_access_key: String, + project: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct IosXcuitestArtifacts { + app: PathBuf, + test_suite: PathBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +struct BenchConfig { + target: MobileTarget, + function: String, + iterations: u32, + warmup: u32, + device_matrix: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + device_tags: Option>, + browserstack: BrowserStackConfig, + #[serde(skip_serializing_if = "Option::is_none", default)] + ios_xcuitest: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct DeviceEntry { + name: String, + os: String, + os_version: String, + tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DeviceMatrix { + devices: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RunSpec { + target: MobileTarget, + function: String, + iterations: u32, + warmup: u32, + devices: Vec, + #[serde(skip_serializing, skip_deserializing, default)] + browserstack: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + ios_xcuitest: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "platform", rename_all = "lowercase")] +enum MobileArtifacts { + Android { + apk: PathBuf, + }, + Ios { + xcframework: PathBuf, + header: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + app: Option, + #[serde(skip_serializing_if = "Option::is_none")] + test_suite: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct RunSummary { + spec: RunSpec, + artifacts: Option, + local_report: Value, + remote_run: Option, + summary: SummaryReport, + #[serde(skip_serializing_if = "Option::is_none")] + benchmark_results: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + performance_metrics: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct SummaryReport { + generated_at: String, + generated_at_unix: u64, + target: MobileTarget, + function: String, + iterations: u32, + warmup: u32, + devices: Vec, + device_summaries: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct DeviceSummary { + device: String, + benchmarks: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct BenchmarkStats { + function: String, + samples: usize, + mean_ns: Option, + median_ns: Option, + p95_ns: Option, + min_ns: Option, + max_ns: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "platform", rename_all = "lowercase")] +enum RemoteRun { + Android { + app_url: String, + build_id: String, + }, + Ios { + app_url: String, + test_suite_url: String, + build_id: String, + }, +} + +pub fn run() -> Result<()> { + load_dotenv(); + let cli = Cli::parse(); + match cli.command { + Command::Run { + target, + function, + iterations, + warmup, + devices, + config, + output, + summary_csv, + local_only, + ios_app, + ios_test_suite, + fetch, + fetch_output_dir, + fetch_poll_interval_secs, + fetch_timeout_secs, + } => { + let spec = resolve_run_spec( + target, + function, + iterations, + warmup, + devices, + config.as_deref(), + ios_app, + ios_test_suite, + local_only, + )?; + let summary_paths = resolve_summary_paths(output.as_deref())?; + println!( + "Preparing benchmark run for {:?}: {} (iterations={}, warmup={})", + spec.target, spec.function, spec.iterations, spec.warmup + ); + persist_mobile_spec(&spec)?; + if !spec.devices.is_empty() { + println!("Devices: {}", spec.devices.join(", ")); + } + println!("JSON summary will be written to {:?}", summary_paths.json); + println!( + "Markdown summary will be written to {:?}", + summary_paths.markdown + ); + if summary_csv { + println!("CSV summary will be written to {:?}", summary_paths.csv); + } + + // Skip local smoke test - sample-fns uses direct dispatch, not inventory registry + // Benchmarks will run on the actual mobile device + println!("Skipping local smoke test - benchmarks will run on mobile device"); + let local_report = json!({ + "skipped": true, + "reason": "Local smoke test disabled - benchmarks run on mobile device only" + }); + let mut remote_run = None; + let artifacts = if local_only { + println!("Skipping mobile build: --local-only set"); + None + } else { + match spec.target { + MobileTarget::Android => { + let ndk = std::env::var("ANDROID_NDK_HOME") + .context("ANDROID_NDK_HOME must be set for Android builds")?; + let build = run_android_build(&ndk)?; + let apk = build.app_path; + println!("Built Android APK at {:?}", apk); + if spec.devices.is_empty() { + println!("Skipping BrowserStack upload/run: no devices provided"); + Some(MobileArtifacts::Android { apk }) + } else { + let test_apk = build.test_suite_path.as_ref().context( + "Android test suite APK missing; run ./gradlew assembleDebugAndroidTest", + )?; + let run = trigger_browserstack_espresso(&spec, &apk, test_apk)?; + remote_run = Some(run); + Some(MobileArtifacts::Android { apk }) + } + } + MobileTarget::Ios => { + let (xcframework, header) = run_ios_build()?; + println!("Built iOS xcframework at {:?}", xcframework); + let ios_xcuitest = spec.ios_xcuitest.clone(); + + if spec.devices.is_empty() { + println!("Skipping BrowserStack upload/run: no devices provided"); + } else { + let xcui = spec.ios_xcuitest.as_ref().context( + "iOS XCUITest artifacts required when targeting BrowserStack devices; provide --ios-app and --ios-test-suite or set ios_xcuitest in the config", + )?; + let run = trigger_browserstack_xcuitest(&spec, xcui)?; + remote_run = Some(run); + } + + Some(MobileArtifacts::Ios { + xcframework, + header, + app: ios_xcuitest.as_ref().map(|a| a.app.clone()), + test_suite: ios_xcuitest.map(|a| a.test_suite), + }) + } + } + }; + + let summary_placeholder = empty_summary(&spec); + let mut run_summary = RunSummary { + spec, + artifacts, + local_report, + remote_run, + summary: summary_placeholder, + benchmark_results: None, + performance_metrics: None, + }; + + if fetch && let Some(remote) = &run_summary.remote_run { + let build_id = match remote { + RemoteRun::Android { build_id, .. } => build_id, + RemoteRun::Ios { build_id, .. } => build_id, + }; + let creds = + resolve_browserstack_credentials(run_summary.spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + )?; + + let platform = match run_summary.spec.target { + MobileTarget::Android => "espresso", + MobileTarget::Ios => "xcuitest", + }; + + let dashboard_url = format!( + "https://app-automate.browserstack.com/dashboard/v2/builds/{}", + build_id + ); + + println!("Waiting for build {} to complete...", build_id); + println!("Dashboard: {}", dashboard_url); + + match client.wait_and_fetch_all_results_with_poll( + build_id, + platform, + Some(fetch_timeout_secs), + Some(fetch_poll_interval_secs), + ) { + Ok((bench_results, perf_metrics)) => { + println!( + "\n✓ Successfully fetched results from {} device(s)", + bench_results.len() + ); + + // Print summary of benchmark results + for (device, results) in &bench_results { + println!("\n Device: {}", device); + for (idx, result) in results.iter().enumerate() { + if let Some(function) = + result.get("function").and_then(|f| f.as_str()) + { + println!(" Benchmark {}: {}", idx + 1, function); + } + if let Some(mean) = result.get("mean_ns").and_then(|m| m.as_u64()) { + println!( + " Mean: {} ns ({:.2} ms)", + mean, + mean as f64 / 1_000_000.0 + ); + } + if let Some(samples) = + result.get("samples").and_then(|s| s.as_array()) + { + println!(" Samples: {}", samples.len()); + } + } + + // Print performance metrics if available + if let Some(metrics) = + perf_metrics.get(device).filter(|m| m.sample_count > 0) + { + println!("\n Performance Metrics:"); + if let Some(mem) = &metrics.memory { + println!(" Memory:"); + println!(" Peak: {:.2} MB", mem.peak_mb); + println!(" Average: {:.2} MB", mem.average_mb); + } + if let Some(cpu) = &metrics.cpu { + println!(" CPU:"); + println!(" Peak: {:.1}%", cpu.peak_percent); + println!(" Average: {:.1}%", cpu.average_percent); + } + } + } + + println!("\n View full results: {}", dashboard_url); + run_summary.benchmark_results = Some(bench_results.into_iter().collect()); + run_summary.performance_metrics = Some(perf_metrics.into_iter().collect()); + } + Err(e) => { + println!("\nWarning: Failed to fetch results: {}", e); + println!("Build may still be accessible at: {}", dashboard_url); + } + } + + // Also save detailed artifacts to separate directory + let output_root = fetch_output_dir.join(build_id); + if let Err(e) = fetch_browserstack_artifacts( + &client, + run_summary.spec.target, + build_id, + &output_root, + false, // Don't wait again, we already did + fetch_poll_interval_secs, + fetch_timeout_secs, + ) { + println!("Warning: Failed to fetch detailed artifacts: {}", e); + } + } else if fetch { + println!("No BrowserStack run to fetch (devices not provided?)"); + } + + run_summary.summary = build_summary(&run_summary)?; + write_summary(&run_summary, &summary_paths, summary_csv)?; + } + Command::Init { output, target } => { + write_config_template(&output, target)?; + println!("Wrote starter config to {:?}", output); + } + Command::Plan { output } => { + write_device_matrix_template(&output)?; + println!("Wrote sample device matrix to {:?}", output); + } + Command::Fetch { + target, + build_id, + output_dir, + wait, + poll_interval_secs, + timeout_secs, + } => { + let creds = resolve_browserstack_credentials(None)?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + )?; + let output_root = output_dir.join(&build_id); + fetch_browserstack_artifacts( + &client, + target, + &build_id, + &output_root, + wait, + poll_interval_secs, + timeout_secs, + )?; + } + Command::Compare { + baseline, + candidate, + output, + } => { + let report = compare_summaries(&baseline, &candidate)?; + write_compare_report(&report, output.as_deref())?; + } + Command::InitSdk { + target, + project_name, + output_dir, + examples, + } => { + cmd_init_sdk(target, project_name, output_dir, examples)?; + } + Command::Build { target, release } => { + cmd_build(target, release)?; + } + Command::PackageIpa { scheme, method } => { + cmd_package_ipa(&scheme, method)?; + } + Command::List => { + cmd_list()?; + } + } + + Ok(()) +} + +fn write_config_template(path: &Path, target: MobileTarget) -> Result<()> { + ensure_can_write(path)?; + + let ios_xcuitest = if target == MobileTarget::Ios { + Some(IosXcuitestArtifacts { + app: PathBuf::from("target/ios/BenchRunner.ipa"), + test_suite: PathBuf::from("target/ios/BenchRunnerUITests.zip"), + }) + } else { + None + }; + + let cfg = BenchConfig { + target, + function: "sample_fns::fibonacci".into(), + iterations: 100, + warmup: 10, + device_matrix: PathBuf::from("device-matrix.yaml"), + device_tags: Some(vec!["default".into()]), + browserstack: BrowserStackConfig { + app_automate_username: "${BROWSERSTACK_USERNAME}".into(), + app_automate_access_key: "${BROWSERSTACK_ACCESS_KEY}".into(), + project: Some("mobile-bench-rs".into()), + }, + ios_xcuitest, + }; + + let contents = toml::to_string_pretty(&cfg)?; + write_file(path, contents.as_bytes()) +} + +fn write_device_matrix_template(path: &Path) -> Result<()> { + ensure_can_write(path)?; + + let matrix = DeviceMatrix { + devices: vec![ + DeviceEntry { + name: "Pixel 7".into(), + os: "android".into(), + os_version: "13.0".into(), + tags: Some(vec!["default".into(), "pixel".into()]), + }, + DeviceEntry { + name: "iPhone 14".into(), + os: "ios".into(), + os_version: "16".into(), + tags: Some(vec!["default".into(), "iphone".into()]), + }, + ], + }; + + let contents = serde_yaml::to_string(&matrix)?; + write_file(path, contents.as_bytes()) +} + +fn fetch_browserstack_artifacts( + client: &BrowserStackClient, + target: MobileTarget, + build_id: &str, + output_root: &Path, + wait: bool, + poll_interval_secs: u64, + timeout_secs: u64, +) -> Result<()> { + fs::create_dir_all(output_root) + .with_context(|| format!("creating output dir {:?}", output_root))?; + + let base = browserstack_base_path(target); + let build_path = format!("{base}/builds/{build_id}"); + let sessions_path = format!("{base}/builds/{build_id}/sessions"); + + if wait { + wait_for_build(client, &build_path, poll_interval_secs, timeout_secs)?; + } + + let build_json = client.get_json(&build_path)?; + write_json(output_root.join("build.json"), &build_json)?; + + let mut session_ids = extract_session_ids(&build_json); + if session_ids.is_empty() { + match client.get_json(&sessions_path) { + Ok(value) => { + write_json(output_root.join("sessions.json"), &value)?; + session_ids = extract_session_ids(&value); + } + Err(err) => { + let msg = shorten_html_error(&err.to_string()); + println!("Sessions endpoint unavailable; falling back to build.json: {msg}"); + } + } + } + + if session_ids.is_empty() { + println!("No sessions found for build {}", build_id); + return Ok(()); + } + + for session_id in session_ids { + let session_path = format!("{base}/builds/{build_id}/sessions/{session_id}"); + let session_json = client.get_json(&session_path)?; + let session_dir = output_root.join(format!("session-{}", session_id)); + fs::create_dir_all(&session_dir) + .with_context(|| format!("creating session dir {:?}", session_dir))?; + write_json(session_dir.join("session.json"), &session_json)?; + + let mut bench_report: Option = None; + for (key, url) in extract_url_fields(&session_json) { + let file_name = filename_for_url(&key, &url); + let dest = session_dir.join(file_name); + if let Err(err) = client.download_url(&url, &dest) { + println!("Skipping download for {key}: {err}"); + continue; + } + if (key.contains("device_log") + || key.contains("instrumentation_log") + || key.contains("app_log")) + && let Ok(contents) = fs::read_to_string(&dest) + && let Some(parsed) = extract_bench_json(&contents) + { + bench_report = Some(parsed); + } + } + + if let Some(report) = bench_report { + write_json(session_dir.join("bench-report.json"), &report)?; + } + } + + println!("Fetched BrowserStack artifacts to {:?}", output_root); + Ok(()) +} + +fn browserstack_base_path(target: MobileTarget) -> &'static str { + match target { + MobileTarget::Android => "app-automate/espresso/v2", + MobileTarget::Ios => "app-automate/xcuitest/v2", + } +} + +fn wait_for_build( + client: &BrowserStackClient, + build_path: &str, + poll_interval_secs: u64, + timeout_secs: u64, +) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + loop { + let build_json = client.get_json(build_path)?; + if let Some(status) = build_json + .get("status") + .and_then(|val| val.as_str()) + .map(|val| val.to_lowercase()) + { + if status == "failed" || status == "error" { + println!("Build status: {status}"); + return Ok(()); + } + if status == "done" || status == "passed" || status == "completed" { + println!("Build status: {status}"); + return Ok(()); + } + println!("Build status: {status} (waiting)"); + } else { + println!("Build status missing; continuing without wait"); + return Ok(()); + } + + if Instant::now() >= deadline { + println!("Timed out waiting for build status"); + return Ok(()); + } + std::thread::sleep(Duration::from_secs(poll_interval_secs)); + } +} + +fn extract_session_ids(value: &Value) -> Vec { + let sessions = value + .get("sessions") + .and_then(|val| val.as_array()) + .or_else(|| value.as_array()); + let mut ids = Vec::new(); + if let Some(entries) = sessions { + for entry in entries { + let id = entry + .get("id") + .or_else(|| entry.get("session_id")) + .or_else(|| entry.get("sessionId")) + .and_then(|val| val.as_str()); + if let Some(id) = id { + ids.push(id.to_string()); + } + } + } + if ids.is_empty() + && let Some(devices) = value.get("devices").and_then(|val| val.as_array()) + { + for device in devices { + if let Some(sessions) = device.get("sessions").and_then(|val| val.as_array()) { + for entry in sessions { + if let Some(id) = entry.get("id").and_then(|val| val.as_str()) { + ids.push(id.to_string()); + } + } + } + } + } + ids +} + +fn extract_url_fields(value: &Value) -> Vec<(String, String)> { + let mut urls = Vec::new(); + extract_url_fields_recursive(value, "", &mut urls); + urls +} + +fn extract_url_fields_recursive(value: &Value, prefix: &str, out: &mut Vec<(String, String)>) { + match value { + Value::Object(map) => { + for (key, val) in map { + let next = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + if let Value::String(url) = val + && (url.starts_with("http") || url.starts_with("bs://")) + { + out.push((next.clone(), url.clone())); + } + extract_url_fields_recursive(val, &next, out); + } + } + Value::Array(items) => { + for (idx, val) in items.iter().enumerate() { + let next = format!("{}[{}]", prefix, idx); + extract_url_fields_recursive(val, &next, out); + } + } + _ => {} + } +} + +fn filename_for_url(key: &str, url: &str) -> String { + let stripped = url.split('?').next().unwrap_or(url); + let ext = Path::new(stripped) + .extension() + .and_then(|val| val.to_str()) + .unwrap_or("log"); + let mut safe = String::with_capacity(key.len()); + for ch in key.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + safe.push(ch); + } else { + safe.push('_'); + } + } + format!("{}.{}", safe, ext) +} + +fn extract_bench_json(contents: &str) -> Option { + let marker = "BENCH_JSON "; + for line in contents.lines().rev() { + if let Some(idx) = line.find(marker) { + let json_part = &line[idx + marker.len()..]; + if let Ok(value) = serde_json::from_str::(json_part) { + return Some(value); + } + } + } + None +} + +fn write_json(path: PathBuf, value: &Value) -> Result<()> { + let contents = serde_json::to_string_pretty(value)?; + write_file(&path, contents.as_bytes()) +} + +fn shorten_html_error(message: &str) -> String { + if message.contains("") || message.contains(", + config: Option<&Path>, + ios_app: Option, + ios_test_suite: Option, + local_only: bool, +) -> Result { + if let Some(cfg_path) = config { + let cfg = load_config(cfg_path)?; + let matrix = load_device_matrix(&cfg.device_matrix)?; + let device_names = match &cfg.device_tags { + Some(tags) if !tags.is_empty() => filter_devices_by_tags(matrix.devices, tags)?, + _ => matrix.devices.into_iter().map(|d| d.name).collect(), + }; + return Ok(RunSpec { + target: cfg.target, + function: cfg.function, + iterations: cfg.iterations, + warmup: cfg.warmup, + devices: device_names, + browserstack: Some(cfg.browserstack), + ios_xcuitest: cfg.ios_xcuitest, + }); + } + + if function.trim().is_empty() { + bail!("function must not be empty"); + } + + let ios_xcuitest = match (ios_app, ios_test_suite) { + (Some(app), Some(test_suite)) => Some(IosXcuitestArtifacts { app, test_suite }), + (None, None) => None, + _ => bail!("both --ios-app and --ios-test-suite must be provided together"), + }; + + let ios_xcuitest = if target == MobileTarget::Ios + && !local_only + && !devices.is_empty() + && ios_xcuitest.is_none() + { + Some(package_ios_xcuitest_artifacts()?) + } else { + ios_xcuitest + }; + + Ok(RunSpec { + target, + function, + iterations, + warmup, + devices, + browserstack: None, + ios_xcuitest, + }) +} + +fn load_config(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("reading config {:?}", path))?; + toml::from_str(&contents).with_context(|| format!("parsing config {:?}", path)) +} + +fn load_device_matrix(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("reading device matrix {:?}", path))?; + serde_yaml::from_str(&contents).with_context(|| format!("parsing device matrix {:?}", path)) +} + +fn filter_devices_by_tags(devices: Vec, tags: &[String]) -> Result> { + let wanted: Vec = tags + .iter() + .map(|tag| tag.trim().to_lowercase()) + .filter(|tag| !tag.is_empty()) + .collect(); + if wanted.is_empty() { + return Ok(devices.into_iter().map(|d| d.name).collect()); + } + + let mut matched = Vec::new(); + for device in devices { + let Some(device_tags) = device.tags.as_ref() else { + continue; + }; + let has_match = device_tags.iter().any(|tag| { + let candidate = tag.trim().to_lowercase(); + wanted.iter().any(|wanted_tag| wanted_tag == &candidate) + }); + if has_match { + matched.push(device.name); + } + } + + if matched.is_empty() { + bail!( + "no devices matched tags [{}] in device matrix", + wanted.join(", ") + ); + } + Ok(matched) +} + +fn run_ios_build() -> Result<(PathBuf, PathBuf)> { + let root = repo_root()?; + let crate_name = + detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); + let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); + let cfg = mobench_sdk::BuildConfig { + target: mobench_sdk::Target::Ios, + profile: mobench_sdk::BuildProfile::Debug, + incremental: true, + }; + let result = builder.build(&cfg)?; + let header = root.join("target/ios/include").join(format!( + "{}.h", + result + .app_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("module") + )); + Ok((result.app_path, header)) +} + +fn package_ios_xcuitest_artifacts() -> Result { + let root = repo_root()?; + let crate_name = + detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); + let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); + let cfg = mobench_sdk::BuildConfig { + target: mobench_sdk::Target::Ios, + profile: mobench_sdk::BuildProfile::Debug, + incremental: true, + }; + builder + .build(&cfg) + .context("Failed to build iOS xcframework before packaging")?; + let app = builder + .package_ipa("BenchRunner", mobench_sdk::builders::SigningMethod::AdHoc) + .context("Failed to package iOS IPA for BrowserStack")?; + let test_suite = builder + .package_xcuitest("BenchRunner") + .context("Failed to package iOS XCUITest runner for BrowserStack")?; + Ok(IosXcuitestArtifacts { app, test_suite }) +} + +#[derive(Debug, Clone)] +struct ResolvedBrowserStack { + username: String, + access_key: String, + project: Option, +} + +fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path, test_apk: &Path) -> Result { + let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username.clone(), + access_key: creds.access_key.clone(), + }, + creds.project.clone(), + )?; + + // Upload the app-under-test APK. + let upload = client.upload_espresso_app(apk)?; + + // Upload the Espresso test-suite APK produced by Gradle. + let test_upload = client.upload_espresso_test_suite(test_apk)?; + + // Schedule the Espresso build with both app and testSuite, as required by BrowserStack. + let run = client.schedule_espresso_run( + &spec.devices, + &upload.app_url, + &test_upload.test_suite_url, + )?; + println!( + "Queued BrowserStack Espresso build {} for devices: {}", + run.build_id, + spec.devices.join(", ") + ); + + Ok(RemoteRun::Android { + app_url: upload.app_url, + build_id: run.build_id, + }) +} + +fn trigger_browserstack_xcuitest( + spec: &RunSpec, + artifacts: &IosXcuitestArtifacts, +) -> Result { + let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username.clone(), + access_key: creds.access_key.clone(), + }, + creds.project.clone(), + )?; + + if !artifacts.app.exists() { + bail!( + "iOS app artifact not found at {:?}; provide a .ipa or zipped .app", + artifacts.app + ); + } + if !artifacts.test_suite.exists() { + bail!( + "iOS XCUITest test suite artifact not found at {:?}; provide the zipped test runner bundle", + artifacts.test_suite + ); + } + + let app_upload = client.upload_xcuitest_app(&artifacts.app)?; + let test_upload = client.upload_xcuitest_test_suite(&artifacts.test_suite)?; + let run = client.schedule_xcuitest_run( + &spec.devices, + &app_upload.app_url, + &test_upload.test_suite_url, + )?; + println!( + "Queued BrowserStack XCUITest build {} for devices: {}", + run.build_id, + spec.devices.join(", ") + ); + + Ok(RemoteRun::Ios { + app_url: app_upload.app_url, + test_suite_url: test_upload.test_suite_url, + build_id: run.build_id, + }) +} + +fn resolve_browserstack_credentials( + config: Option<&BrowserStackConfig>, +) -> Result { + let mut username = None; + let mut access_key = None; + let mut project = None; + + if let Some(cfg) = config { + username = Some(expand_env_var(&cfg.app_automate_username)?); + access_key = Some(expand_env_var(&cfg.app_automate_access_key)?); + project = cfg + .project + .as_ref() + .map(|p| expand_env_var(p)) + .transpose()?; + } + + if username.as_deref().map(str::is_empty).unwrap_or(true) + && let Ok(val) = env::var("BROWSERSTACK_USERNAME") + && !val.is_empty() + { + username = Some(val); + } + if access_key.as_deref().map(str::is_empty).unwrap_or(true) + && let Ok(val) = env::var("BROWSERSTACK_ACCESS_KEY") + && !val.is_empty() + { + access_key = Some(val); + } + if project.is_none() + && let Ok(val) = env::var("BROWSERSTACK_PROJECT") + && !val.is_empty() + { + project = Some(val); + } + + let username = username.filter(|s| !s.is_empty()).ok_or_else(|| { + anyhow!("BrowserStack username missing; set BROWSERSTACK_USERNAME or provide in config") + })?; + let access_key = access_key.filter(|s| !s.is_empty()).ok_or_else(|| { + anyhow!("BrowserStack access key missing; set BROWSERSTACK_ACCESS_KEY or provide in config") + })?; + + Ok(ResolvedBrowserStack { + username, + access_key, + project, + }) +} + +fn expand_env_var(raw: &str) -> Result { + if let Some(stripped) = raw.strip_prefix("${").and_then(|s| s.strip_suffix('}')) { + let val = env::var(stripped) + .with_context(|| format!("resolving env var {stripped} for BrowserStack config"))?; + return Ok(val); + } + Ok(raw.to_string()) +} + +#[cfg(test)] +fn run_local_smoke(spec: &RunSpec) -> Result { + println!("Running local smoke test for {}...", spec.function); + + let bench_spec = mobench_sdk::BenchSpec { + name: spec.function.clone(), + iterations: spec.iterations, + warmup: spec.warmup, + }; + + let report = + mobench_sdk::run_benchmark(bench_spec).map_err(|e| anyhow!("benchmark failed: {:?}", e))?; + + serde_json::to_value(&report).context("serializing benchmark report") +} + +fn persist_mobile_spec(spec: &RunSpec) -> Result<()> { + let root = repo_root()?; + let payload = json!({ + "function": spec.function, + "iterations": spec.iterations, + "warmup": spec.warmup, + }); + let contents = serde_json::to_string_pretty(&payload)?; + let targets = [ + root.join("target/mobile-spec/android/bench_spec.json"), + root.join("target/mobile-spec/ios/bench_spec.json"), + ]; + for path in targets { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating directory {:?}", parent))?; + } + write_file(&path, contents.as_bytes())?; + } + Ok(()) +} + +#[derive(Debug)] +struct SummaryPaths { + json: PathBuf, + markdown: PathBuf, + csv: PathBuf, +} + +fn resolve_summary_paths(output: Option<&Path>) -> Result { + let json = output + .map(ToOwned::to_owned) + .unwrap_or_else(|| PathBuf::from("run-summary.json")); + let markdown = json.with_extension("md"); + let csv = json.with_extension("csv"); + Ok(SummaryPaths { + json, + markdown, + csv, + }) +} + +fn empty_summary(spec: &RunSpec) -> SummaryReport { + SummaryReport { + generated_at: "pending".to_string(), + generated_at_unix: 0, + target: spec.target, + function: spec.function.clone(), + iterations: spec.iterations, + warmup: spec.warmup, + devices: spec.devices.clone(), + device_summaries: Vec::new(), + } +} + +fn build_summary(run_summary: &RunSummary) -> Result { + let generated_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("generating timestamp")? + .as_secs(); + let generated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| generated_at_unix.to_string()); + + let mut device_summaries = Vec::new(); + + if let Some(results) = &run_summary.benchmark_results { + for (device, entries) in results { + let mut benchmarks = Vec::new(); + for entry in entries { + let function = entry + .get("function") + .and_then(|f| f.as_str()) + .unwrap_or("unknown") + .to_string(); + let samples = extract_samples(entry); + let stats = compute_sample_stats(&samples); + let mean_ns = stats + .as_ref() + .map(|s| s.mean_ns) + .or_else(|| entry.get("mean_ns").and_then(|m| m.as_u64())); + + benchmarks.push(BenchmarkStats { + function, + samples: samples.len(), + mean_ns, + median_ns: stats.as_ref().map(|s| s.median_ns), + p95_ns: stats.as_ref().map(|s| s.p95_ns), + min_ns: stats.as_ref().map(|s| s.min_ns), + max_ns: stats.as_ref().map(|s| s.max_ns), + }); + } + + benchmarks.sort_by(|a, b| a.function.cmp(&b.function)); + device_summaries.push(DeviceSummary { + device: device.clone(), + benchmarks, + }); + } + } + + if device_summaries.is_empty() + && let Some(local_summary) = summarize_local_report(run_summary) + { + device_summaries.push(local_summary); + } + + Ok(SummaryReport { + generated_at, + generated_at_unix, + target: run_summary.spec.target, + function: run_summary.spec.function.clone(), + iterations: run_summary.spec.iterations, + warmup: run_summary.spec.warmup, + devices: run_summary.spec.devices.clone(), + device_summaries, + }) +} + +fn write_summary(summary: &RunSummary, paths: &SummaryPaths, summary_csv: bool) -> Result<()> { + let json = serde_json::to_string_pretty(summary)?; + ensure_parent_dir(&paths.json)?; + write_file(&paths.json, json.as_bytes())?; + println!("Wrote run summary to {:?}", paths.json); + + let markdown = render_markdown_summary(&summary.summary); + ensure_parent_dir(&paths.markdown)?; + write_file(&paths.markdown, markdown.as_bytes())?; + println!("Wrote markdown summary to {:?}", paths.markdown); + + if summary_csv { + let csv = render_csv_summary(&summary.summary); + ensure_parent_dir(&paths.csv)?; + write_file(&paths.csv, csv.as_bytes())?; + println!("Wrote CSV summary to {:?}", paths.csv); + } + Ok(()) +} + +fn ensure_parent_dir(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent).with_context(|| format!("creating directory {:?}", parent))?; + } + Ok(()) +} + +#[derive(Debug)] +struct CompareReport { + baseline: PathBuf, + candidate: PathBuf, + rows: Vec, +} + +#[derive(Debug)] +struct CompareRow { + device: String, + function: String, + baseline_median_ns: Option, + candidate_median_ns: Option, + median_delta_pct: Option, + baseline_p95_ns: Option, + candidate_p95_ns: Option, + p95_delta_pct: Option, +} + +fn compare_summaries(baseline: &Path, candidate: &Path) -> Result { + let baseline_summary = load_run_summary(baseline)?; + let candidate_summary = load_run_summary(candidate)?; + + let baseline_map = summary_lookup(&baseline_summary.summary); + let candidate_map = summary_lookup(&candidate_summary.summary); + + let mut rows = Vec::new(); + let mut devices: BTreeMap = BTreeMap::new(); + devices.extend(baseline_map.keys().map(|k| (k.clone(), ()))); + devices.extend(candidate_map.keys().map(|k| (k.clone(), ()))); + + for device in devices.keys() { + let mut functions: BTreeMap = BTreeMap::new(); + if let Some(entry) = baseline_map.get(device) { + functions.extend(entry.keys().map(|k| (k.clone(), ()))); + } + if let Some(entry) = candidate_map.get(device) { + functions.extend(entry.keys().map(|k| (k.clone(), ()))); + } + + for function in functions.keys() { + let baseline_stats = baseline_map + .get(device) + .and_then(|entry| entry.get(function)); + let candidate_stats = candidate_map + .get(device) + .and_then(|entry| entry.get(function)); + + let baseline_median = baseline_stats.and_then(|s| s.median_ns); + let candidate_median = candidate_stats.and_then(|s| s.median_ns); + let median_delta = percent_delta(baseline_median, candidate_median); + + let baseline_p95 = baseline_stats.and_then(|s| s.p95_ns); + let candidate_p95 = candidate_stats.and_then(|s| s.p95_ns); + let p95_delta = percent_delta(baseline_p95, candidate_p95); + + rows.push(CompareRow { + device: device.clone(), + function: function.clone(), + baseline_median_ns: baseline_median, + candidate_median_ns: candidate_median, + median_delta_pct: median_delta, + baseline_p95_ns: baseline_p95, + candidate_p95_ns: candidate_p95, + p95_delta_pct: p95_delta, + }); + } + } + + Ok(CompareReport { + baseline: baseline.to_path_buf(), + candidate: candidate.to_path_buf(), + rows, + }) +} + +fn load_run_summary(path: &Path) -> Result { + let contents = fs::read_to_string(path).with_context(|| format!("reading {:?}", path))?; + serde_json::from_str(&contents).with_context(|| format!("parsing summary {:?}", path)) +} + +fn summary_lookup(summary: &SummaryReport) -> BTreeMap> { + let mut map = BTreeMap::new(); + for device in &summary.device_summaries { + let mut functions = BTreeMap::new(); + for bench in &device.benchmarks { + functions.insert(bench.function.clone(), bench.clone()); + } + map.insert(device.device.clone(), functions); + } + map +} + +fn percent_delta(baseline: Option, candidate: Option) -> Option { + let baseline = baseline? as f64; + let candidate = candidate? as f64; + if baseline == 0.0 { + return None; + } + Some(((candidate - baseline) / baseline) * 100.0) +} + +fn write_compare_report(report: &CompareReport, output: Option<&Path>) -> Result<()> { + let markdown = render_compare_markdown(report); + if let Some(path) = output { + ensure_parent_dir(path)?; + write_file(path, markdown.as_bytes())?; + println!("Wrote compare report to {:?}", path); + } else { + println!("{markdown}"); + } + Ok(()) +} + +fn render_compare_markdown(report: &CompareReport) -> String { + let mut output = String::new(); + let _ = writeln!(output, "# Benchmark Comparison"); + let _ = writeln!(output); + let _ = writeln!(output, "- Baseline: {}", report.baseline.display()); + let _ = writeln!(output, "- Candidate: {}", report.candidate.display()); + let _ = writeln!(output); + let _ = writeln!( + output, + "| Device | Function | Median (base ms) | Median (cand ms) | Median Δ% | P95 (base ms) | P95 (cand ms) | P95 Δ% |" + ); + let _ = writeln!( + output, + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |" + ); + for row in &report.rows { + let _ = writeln!( + output, + "| {} | {} | {} | {} | {} | {} | {} | {} |", + row.device, + row.function, + format_ms(row.baseline_median_ns), + format_ms(row.candidate_median_ns), + format_delta(row.median_delta_pct), + format_ms(row.baseline_p95_ns), + format_ms(row.candidate_p95_ns), + format_delta(row.p95_delta_pct) + ); + } + output +} + +fn format_delta(value: Option) -> String { + value + .map(|delta| format!("{:+.2}%", delta)) + .unwrap_or_else(|| "-".to_string()) +} + +fn summarize_local_report(run_summary: &RunSummary) -> Option { + let samples = extract_samples(&run_summary.local_report); + if samples.is_empty() { + return None; + } + let stats = compute_sample_stats(&samples)?; + let function = run_summary + .local_report + .get("spec") + .and_then(|spec| spec.get("name")) + .and_then(|name| name.as_str()) + .unwrap_or(&run_summary.spec.function) + .to_string(); + + Some(DeviceSummary { + device: "local".to_string(), + benchmarks: vec![BenchmarkStats { + function, + samples: samples.len(), + mean_ns: Some(stats.mean_ns), + median_ns: Some(stats.median_ns), + p95_ns: Some(stats.p95_ns), + min_ns: Some(stats.min_ns), + max_ns: Some(stats.max_ns), + }], + }) +} + +#[derive(Clone, Debug)] +struct SampleStats { + mean_ns: u64, + median_ns: u64, + p95_ns: u64, + min_ns: u64, + max_ns: u64, +} + +fn compute_sample_stats(samples: &[u64]) -> Option { + if samples.is_empty() { + return None; + } + + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + let len = sorted.len(); + + let mean_ns = (sorted.iter().map(|v| *v as u128).sum::() / len as u128) as u64; + let median_ns = if len % 2 == 1 { + sorted[len / 2] + } else { + let lower = sorted[(len / 2) - 1]; + let upper = sorted[len / 2]; + (lower + upper) / 2 + }; + let p95_index = percentile_index(len, 0.95); + let p95_ns = sorted[p95_index]; + let min_ns = sorted[0]; + let max_ns = sorted[len - 1]; + + Some(SampleStats { + mean_ns, + median_ns, + p95_ns, + min_ns, + max_ns, + }) +} + +fn percentile_index(len: usize, percentile: f64) -> usize { + if len == 0 { + return 0; + } + let rank = (percentile * len as f64).ceil() as usize; + let index = rank.saturating_sub(1); + index.min(len - 1) +} + +fn extract_samples(value: &Value) -> Vec { + let Some(samples) = value.get("samples").and_then(|s| s.as_array()) else { + return Vec::new(); + }; + let mut durations = Vec::with_capacity(samples.len()); + for sample in samples { + if let Some(duration) = sample + .get("duration_ns") + .and_then(|duration| duration.as_u64()) + { + durations.push(duration); + } else if let Some(duration) = sample.as_u64() { + durations.push(duration); + } + } + durations +} + +fn render_markdown_summary(summary: &SummaryReport) -> String { + let mut output = String::new(); + let devices = if summary.devices.is_empty() { + "none".to_string() + } else { + summary.devices.join(", ") + }; + + let _ = writeln!(output, "# Benchmark Summary"); + let _ = writeln!(output); + let _ = writeln!(output, "- Generated: {}", summary.generated_at); + let _ = writeln!(output, "- Target: {:?}", summary.target); + let _ = writeln!(output, "- Function: {}", summary.function); + let _ = writeln!( + output, + "- Iterations/Warmup: {} / {}", + summary.iterations, summary.warmup + ); + let _ = writeln!(output, "- Devices: {}", devices); + let _ = writeln!(output); + + if summary.device_summaries.is_empty() { + let _ = writeln!(output, "No benchmark samples were collected."); + return output; + } + + for device in &summary.device_summaries { + let _ = writeln!(output, "## Device: {}", device.device); + let _ = writeln!(output); + let _ = writeln!( + output, + "| Function | Samples | Mean (ms) | Median (ms) | P95 (ms) | Min (ms) | Max (ms) |" + ); + let _ = writeln!(output, "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"); + for bench in &device.benchmarks { + let _ = writeln!( + output, + "| {} | {} | {} | {} | {} | {} | {} |", + bench.function, + bench.samples, + format_ms(bench.mean_ns), + format_ms(bench.median_ns), + format_ms(bench.p95_ns), + format_ms(bench.min_ns), + format_ms(bench.max_ns) + ); + } + let _ = writeln!(output); + } + + output +} + +fn render_csv_summary(summary: &SummaryReport) -> String { + let mut output = String::new(); + let _ = writeln!( + output, + "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns" + ); + for device in &summary.device_summaries { + for bench in &device.benchmarks { + let _ = writeln!( + output, + "{},{},{},{},{},{},{},{}", + device.device, + bench.function, + bench.samples, + bench.mean_ns.map_or(String::from(""), |v| v.to_string()), + bench.median_ns.map_or(String::from(""), |v| v.to_string()), + bench.p95_ns.map_or(String::from(""), |v| v.to_string()), + bench.min_ns.map_or(String::from(""), |v| v.to_string()), + bench.max_ns.map_or(String::from(""), |v| v.to_string()) + ); + } + } + output +} + +fn format_ms(value: Option) -> String { + value + .map(|ns| format!("{:.3}", ns as f64 / 1_000_000.0)) + .unwrap_or_else(|| "-".to_string()) +} + +fn run_android_build(_ndk_home: &str) -> Result { + let root = repo_root()?; + let crate_name = + detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); + + let cfg = mobench_sdk::BuildConfig { + target: mobench_sdk::Target::Android, + profile: mobench_sdk::BuildProfile::Debug, + incremental: true, + }; + let builder = mobench_sdk::builders::AndroidBuilder::new(&root, crate_name).verbose(true); + let result = builder.build(&cfg)?; + Ok(result) +} + +fn load_dotenv() { + if let Ok(root) = repo_root() { + let path = root.join(".env.local"); + let _ = dotenvy::from_path(path); + } +} + +fn repo_root() -> Result { + // Prefer the build-time repo root but fall back to the current directory for installed binaries. + let compiled = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(".."); + if let Ok(path) = compiled.canonicalize() { + return Ok(path); + } + std::env::current_dir().context("resolving repo root from current directory") +} + +fn ensure_can_write(path: &Path) -> Result<()> { + if path.exists() { + bail!("refusing to overwrite existing file: {:?}", path); + } + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent) + .with_context(|| format!("creating parent directory {:?}", parent))?; + } + Ok(()) +} + +fn write_file(path: &Path, contents: &[u8]) -> Result<()> { + fs::write(path, contents).with_context(|| format!("writing file {:?}", path)) +} + +/// Initialize a new benchmark project using mobench-sdk (Phase 1 MVP) +fn cmd_init_sdk( + target: SdkTarget, + project_name: String, + output_dir: PathBuf, + generate_examples: bool, +) -> Result<()> { + println!("Initializing benchmark project with mobench-sdk..."); + println!(" Project name: {}", project_name); + println!(" Target: {:?}", target); + println!(" Output directory: {:?}", output_dir); + + let config = mobench_sdk::InitConfig { + target: target.into(), + project_name: project_name.clone(), + output_dir: output_dir.clone(), + generate_examples, + }; + + mobench_sdk::codegen::generate_project(&config).context("Failed to generate project")?; + + println!("\n✓ Project initialized successfully!"); + println!("\nNext steps:"); + println!(" 1. Add benchmark functions to your code with #[benchmark]"); + println!(" 2. Run 'cargo build --target ' to build"); + println!(" 3. Run benchmarks with 'cargo mobench build --target '"); + + Ok(()) +} + +/// Build mobile artifacts using mobench-sdk (Phase 1 MVP) +fn cmd_build(target: SdkTarget, release: bool) -> Result<()> { + println!("Building mobile artifacts..."); + println!(" Target: {:?}", target); + println!(" Profile: {}", if release { "release" } else { "debug" }); + + let project_root = std::env::current_dir().context("Failed to get current directory")?; + let crate_name = detect_bench_mobile_crate_name(&project_root) + .unwrap_or_else(|_| "bench-mobile".to_string()); // Fallback for legacy layouts + + let build_config = mobench_sdk::BuildConfig { + target: target.into(), + profile: if release { + mobench_sdk::BuildProfile::Release + } else { + mobench_sdk::BuildProfile::Debug + }, + incremental: true, + }; + + match target { + SdkTarget::Android => { + let builder = + mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) + .verbose(true); + let result = builder.build(&build_config)?; + println!("\n✓ Android build completed!"); + println!(" APK: {:?}", result.app_path); + } + SdkTarget::Ios => { + let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name.clone()) + .verbose(true); + let result = builder.build(&build_config)?; + println!("\n✓ iOS build completed!"); + println!(" Framework: {:?}", result.app_path); + } + SdkTarget::Both => { + // Build Android + let android_builder = + mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) + .verbose(true); + let android_result = android_builder.build(&build_config)?; + println!("\n✓ Android build completed!"); + println!(" APK: {:?}", android_result.app_path); + + // Build iOS + let ios_builder = + mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + let ios_result = ios_builder.build(&build_config)?; + println!("\n✓ iOS build completed!"); + println!(" Framework: {:?}", ios_result.app_path); + } + } + + Ok(()) +} + +fn detect_bench_mobile_crate_name(root: &Path) -> Result { + // Try bench-mobile/ first (SDK projects) + let bench_mobile_path = root.join("bench-mobile").join("Cargo.toml"); + if bench_mobile_path.exists() { + let contents = fs::read_to_string(&bench_mobile_path) + .with_context(|| format!("reading bench-mobile manifest at {:?}", bench_mobile_path))?; + let value: toml::Value = toml::from_str(&contents) + .with_context(|| format!("parsing bench-mobile manifest {:?}", bench_mobile_path))?; + let name = value + .get("package") + .and_then(|pkg| pkg.get("name")) + .and_then(|n| n.as_str()) + .ok_or_else(|| { + anyhow!( + "bench-mobile package.name missing in {:?}", + bench_mobile_path + ) + })?; + return Ok(name.to_string()); + } + + // Fallback: Try crates/sample-fns (repository testing) + let sample_fns_path = root.join("crates").join("sample-fns").join("Cargo.toml"); + if sample_fns_path.exists() { + let contents = fs::read_to_string(&sample_fns_path) + .with_context(|| format!("reading sample-fns manifest at {:?}", sample_fns_path))?; + let value: toml::Value = toml::from_str(&contents) + .with_context(|| format!("parsing sample-fns manifest {:?}", sample_fns_path))?; + let name = value + .get("package") + .and_then(|pkg| pkg.get("name")) + .and_then(|n| n.as_str()) + .ok_or_else(|| anyhow!("sample-fns package.name missing in {:?}", sample_fns_path))?; + return Ok(name.to_string()); + } + + bail!("No benchmark crate found. Expected 'bench-mobile/' or 'crates/sample-fns/'") +} + +/// List all discovered benchmark functions (Phase 1 MVP) +fn cmd_list() -> Result<()> { + println!("Discovering benchmark functions...\n"); + + let benchmarks = mobench_sdk::discover_benchmarks(); + + if benchmarks.is_empty() { + println!("No benchmarks found."); + println!("\nTo add benchmarks:"); + println!(" 1. Add #[benchmark] attribute to functions"); + println!(" 2. Make sure mobench-sdk is in your dependencies"); + println!(" 3. Rebuild your project"); + } else { + println!("Found {} benchmark(s):", benchmarks.len()); + for bench in benchmarks { + println!(" - {}", bench.name); + } + } + + Ok(()) +} + +/// Package iOS app as IPA for distribution or testing +fn cmd_package_ipa(scheme: &str, method: IosSigningMethodArg) -> Result<()> { + println!("Packaging iOS app as IPA..."); + println!(" Scheme: {}", scheme); + println!(" Method: {:?}", method); + + let project_root = repo_root()?; + let crate_name = detect_bench_mobile_crate_name(&project_root) + .unwrap_or_else(|_| "bench-mobile".to_string()); + + let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + + let signing_method: mobench_sdk::builders::SigningMethod = method.into(); + let ipa_path = builder + .package_ipa(scheme, signing_method) + .context("Failed to package IPA")?; + + println!("\n✓ IPA packaged successfully!"); + println!(" Path: {:?}", ipa_path); + println!("\nYou can now:"); + println!(" - Install on device: Use Xcode or ios-deploy"); + println!( + " - Test on BrowserStack: cargo mobench run --target ios --ios-app {:?}", + ipa_path + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Register a lightweight benchmark for tests so the inventory contains at least one entry. + #[mobench_sdk::benchmark] + fn noop_benchmark() { + std::hint::black_box(1u8); + } + + #[test] + fn resolves_cli_spec() { + let spec = resolve_run_spec( + MobileTarget::Android, + "sample_fns::fibonacci".into(), + 5, + 1, + vec!["pixel".into()], + None, + None, + None, + false, + ) + .unwrap(); + assert_eq!(spec.function, "sample_fns::fibonacci"); + assert_eq!(spec.iterations, 5); + assert_eq!(spec.warmup, 1); + assert_eq!(spec.devices, vec!["pixel".to_string()]); + assert!(spec.browserstack.is_none()); + assert!(spec.ios_xcuitest.is_none()); + } + + #[test] + fn local_smoke_produces_samples() { + let spec = RunSpec { + target: MobileTarget::Android, + function: "noop_benchmark".into(), + iterations: 3, + warmup: 1, + devices: vec![], + browserstack: None, + ios_xcuitest: None, + }; + let report = run_local_smoke(&spec).expect("local harness"); + assert!(report["samples"].is_array()); + assert_eq!(report["spec"]["name"], "noop_benchmark"); + } + + #[test] + fn ios_requires_artifacts_for_browserstack() { + let spec = resolve_run_spec( + MobileTarget::Ios, + "sample_fns::fibonacci".into(), + 1, + 0, + vec!["iphone".into()], + None, + None, + None, + false, + ) + .expect("should auto-package iOS artifacts when missing"); + let ios_artifacts = spec + .ios_xcuitest + .expect("iOS artifacts should be populated"); + assert!(ios_artifacts.app.exists(), "iOS app artifact missing"); + assert!( + ios_artifacts.test_suite.exists(), + "iOS test suite artifact missing" + ); + } +} diff --git a/crates/mobench/src/main.rs b/crates/mobench/src/main.rs index f1d536e..f5e8e59 100644 --- a/crates/mobench/src/main.rs +++ b/crates/mobench/src/main.rs @@ -1,1961 +1,6 @@ -use anyhow::{Context, Result, anyhow, bail}; -use clap::{Parser, Subcommand, ValueEnum}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::collections::BTreeMap; -use std::env; -use std::fmt::Write; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use time::OffsetDateTime; -use time::format_description::well_known::Rfc3339; - -use browserstack::{BrowserStackAuth, BrowserStackClient}; - -mod browserstack; - -/// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. -#[derive(Parser, Debug)] -#[command(name = "mobench", author, version, about = "Mobile Rust benchmarking orchestrator", long_about = None)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// Run a benchmark against a target platform (mobile integration stub for now). - Run { - #[arg(long, value_enum)] - target: MobileTarget, - #[arg(long, help = "Fully-qualified Rust function to benchmark")] - function: String, - #[arg(long, default_value_t = 100)] - iterations: u32, - #[arg(long, default_value_t = 10)] - warmup: u32, - #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] - devices: Vec, - #[arg(long, help = "Optional path to config file")] - config: Option, - #[arg(long, help = "Optional output path for JSON report")] - output: Option, - #[arg(long, help = "Write CSV summary alongside JSON")] - summary_csv: bool, - #[arg(long, help = "Skip mobile builds and only run the host harness")] - local_only: bool, - #[arg( - long, - help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" - )] - ios_app: Option, - #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] - ios_test_suite: Option, - #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] - fetch: bool, - #[arg(long, default_value = "target/browserstack")] - fetch_output_dir: PathBuf, - #[arg(long, default_value_t = 5)] - fetch_poll_interval_secs: u64, - #[arg(long, default_value_t = 300)] - fetch_timeout_secs: u64, - }, - /// Scaffold a base config file for the CLI. - Init { - #[arg(long, default_value = "bench-config.toml")] - output: PathBuf, - #[arg(long, value_enum, default_value_t = MobileTarget::Android)] - target: MobileTarget, - }, - /// Generate a sample device matrix file. - Plan { - #[arg(long, default_value = "device-matrix.yaml")] - output: PathBuf, - }, - /// Fetch BrowserStack build artifacts (logs, session JSON) for CI. - Fetch { - #[arg(long, value_enum)] - target: MobileTarget, - #[arg(long)] - build_id: String, - #[arg(long, default_value = "target/browserstack")] - output_dir: PathBuf, - #[arg(long, default_value_t = true)] - wait: bool, - #[arg(long, default_value_t = 10)] - poll_interval_secs: u64, - #[arg(long, default_value_t = 1800)] - timeout_secs: u64, - }, - /// Compare two run summaries for regressions. - Compare { - #[arg(long, help = "Baseline JSON summary to compare against")] - baseline: PathBuf, - #[arg(long, help = "Candidate JSON summary to compare")] - candidate: PathBuf, - #[arg(long, help = "Optional output path for markdown report")] - output: Option, - }, - /// Initialize a new benchmark project with SDK (Phase 1 MVP). - InitSdk { - #[arg(long, value_enum)] - target: SdkTarget, - #[arg(long, default_value = "bench-project")] - project_name: String, - #[arg(long, default_value = ".")] - output_dir: PathBuf, - #[arg(long, help = "Generate example benchmarks")] - examples: bool, - }, - /// Build mobile artifacts (Phase 1 MVP). - Build { - #[arg(long, value_enum)] - target: SdkTarget, - #[arg(long, help = "Build in release mode")] - release: bool, - }, - /// Package iOS app as IPA for distribution or testing. - PackageIpa { - #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] - scheme: String, - #[arg(long, value_enum, default_value = "adhoc", help = "Signing method")] - method: IosSigningMethodArg, - }, - /// List all discovered benchmark functions (Phase 1 MVP). - List, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -enum MobileTarget { - Android, - Ios, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] -#[clap(rename_all = "lowercase")] -enum SdkTarget { - Android, - Ios, - Both, -} - -impl From for mobench_sdk::Target { - fn from(target: SdkTarget) -> Self { - match target { - SdkTarget::Android => mobench_sdk::Target::Android, - SdkTarget::Ios => mobench_sdk::Target::Ios, - SdkTarget::Both => mobench_sdk::Target::Both, - } - } -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] -#[clap(rename_all = "lowercase")] -enum IosSigningMethodArg { - /// Ad-hoc signing (no Apple ID needed, works for BrowserStack) - Adhoc, - /// Development signing (requires Apple Developer account) - Development, -} - -impl From for mobench_sdk::builders::SigningMethod { - fn from(arg: IosSigningMethodArg) -> Self { - match arg { - IosSigningMethodArg::Adhoc => mobench_sdk::builders::SigningMethod::AdHoc, - IosSigningMethodArg::Development => mobench_sdk::builders::SigningMethod::Development, - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct BrowserStackConfig { - app_automate_username: String, - app_automate_access_key: String, - project: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct IosXcuitestArtifacts { - app: PathBuf, - test_suite: PathBuf, -} - -#[derive(Debug, Serialize, Deserialize)] -struct BenchConfig { - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, - device_matrix: PathBuf, - #[serde(default, skip_serializing_if = "Option::is_none")] - device_tags: Option>, - browserstack: BrowserStackConfig, - #[serde(skip_serializing_if = "Option::is_none", default)] - ios_xcuitest: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct DeviceEntry { - name: String, - os: String, - os_version: String, - tags: Option>, -} - -#[derive(Debug, Serialize, Deserialize)] -struct DeviceMatrix { - devices: Vec, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct RunSpec { - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, - devices: Vec, - #[serde(skip_serializing, skip_deserializing, default)] - browserstack: Option, - #[serde(skip_serializing_if = "Option::is_none", default)] - ios_xcuitest: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "platform", rename_all = "lowercase")] -enum MobileArtifacts { - Android { - apk: PathBuf, - }, - Ios { - xcframework: PathBuf, - header: PathBuf, - #[serde(skip_serializing_if = "Option::is_none")] - app: Option, - #[serde(skip_serializing_if = "Option::is_none")] - test_suite: Option, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -struct RunSummary { - spec: RunSpec, - artifacts: Option, - local_report: Value, - remote_run: Option, - summary: SummaryReport, - #[serde(skip_serializing_if = "Option::is_none")] - benchmark_results: Option>>, - #[serde(skip_serializing_if = "Option::is_none")] - performance_metrics: Option>, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct SummaryReport { - generated_at: String, - generated_at_unix: u64, - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, - devices: Vec, - device_summaries: Vec, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct DeviceSummary { - device: String, - benchmarks: Vec, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct BenchmarkStats { - function: String, - samples: usize, - mean_ns: Option, - median_ns: Option, - p95_ns: Option, - min_ns: Option, - max_ns: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "platform", rename_all = "lowercase")] -enum RemoteRun { - Android { - app_url: String, - build_id: String, - }, - Ios { - app_url: String, - test_suite_url: String, - build_id: String, - }, -} - -fn main() -> Result<()> { - load_dotenv(); - let cli = Cli::parse(); - match cli.command { - Command::Run { - target, - function, - iterations, - warmup, - devices, - config, - output, - summary_csv, - local_only, - ios_app, - ios_test_suite, - fetch, - fetch_output_dir, - fetch_poll_interval_secs, - fetch_timeout_secs, - } => { - let spec = resolve_run_spec( - target, - function, - iterations, - warmup, - devices, - config.as_deref(), - ios_app, - ios_test_suite, - local_only, - )?; - let summary_paths = resolve_summary_paths(output.as_deref())?; - println!( - "Preparing benchmark run for {:?}: {} (iterations={}, warmup={})", - spec.target, spec.function, spec.iterations, spec.warmup - ); - persist_mobile_spec(&spec)?; - if !spec.devices.is_empty() { - println!("Devices: {}", spec.devices.join(", ")); - } - println!("JSON summary will be written to {:?}", summary_paths.json); - println!( - "Markdown summary will be written to {:?}", - summary_paths.markdown - ); - if summary_csv { - println!("CSV summary will be written to {:?}", summary_paths.csv); - } - - // Skip local smoke test - sample-fns uses direct dispatch, not inventory registry - // Benchmarks will run on the actual mobile device - println!("Skipping local smoke test - benchmarks will run on mobile device"); - let local_report = json!({ - "skipped": true, - "reason": "Local smoke test disabled - benchmarks run on mobile device only" - }); - let mut remote_run = None; - let artifacts = if local_only { - println!("Skipping mobile build: --local-only set"); - None - } else { - match spec.target { - MobileTarget::Android => { - let ndk = std::env::var("ANDROID_NDK_HOME") - .context("ANDROID_NDK_HOME must be set for Android builds")?; - let build = run_android_build(&ndk)?; - let apk = build.app_path; - println!("Built Android APK at {:?}", apk); - if spec.devices.is_empty() { - println!("Skipping BrowserStack upload/run: no devices provided"); - Some(MobileArtifacts::Android { apk }) - } else { - let test_apk = build.test_suite_path.as_ref().context( - "Android test suite APK missing; run ./gradlew assembleDebugAndroidTest", - )?; - let run = trigger_browserstack_espresso(&spec, &apk, test_apk)?; - remote_run = Some(run); - Some(MobileArtifacts::Android { apk }) - } - } - MobileTarget::Ios => { - let (xcframework, header) = run_ios_build()?; - println!("Built iOS xcframework at {:?}", xcframework); - let ios_xcuitest = spec.ios_xcuitest.clone(); - - if spec.devices.is_empty() { - println!("Skipping BrowserStack upload/run: no devices provided"); - } else { - let xcui = spec.ios_xcuitest.as_ref().context( - "iOS XCUITest artifacts required when targeting BrowserStack devices; provide --ios-app and --ios-test-suite or set ios_xcuitest in the config", - )?; - let run = trigger_browserstack_xcuitest(&spec, xcui)?; - remote_run = Some(run); - } - - Some(MobileArtifacts::Ios { - xcframework, - header, - app: ios_xcuitest.as_ref().map(|a| a.app.clone()), - test_suite: ios_xcuitest.map(|a| a.test_suite), - }) - } - } - }; - - let summary_placeholder = empty_summary(&spec); - let mut run_summary = RunSummary { - spec, - artifacts, - local_report, - remote_run, - summary: summary_placeholder, - benchmark_results: None, - performance_metrics: None, - }; - - if fetch && let Some(remote) = &run_summary.remote_run { - let build_id = match remote { - RemoteRun::Android { build_id, .. } => build_id, - RemoteRun::Ios { build_id, .. } => build_id, - }; - let creds = - resolve_browserstack_credentials(run_summary.spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username, - access_key: creds.access_key, - }, - creds.project, - )?; - - let platform = match run_summary.spec.target { - MobileTarget::Android => "espresso", - MobileTarget::Ios => "xcuitest", - }; - - let dashboard_url = format!( - "https://app-automate.browserstack.com/dashboard/v2/builds/{}", - build_id - ); - - println!("Waiting for build {} to complete...", build_id); - println!("Dashboard: {}", dashboard_url); - - match client.wait_and_fetch_all_results_with_poll( - build_id, - platform, - Some(fetch_timeout_secs), - Some(fetch_poll_interval_secs), - ) { - Ok((bench_results, perf_metrics)) => { - println!( - "\n✓ Successfully fetched results from {} device(s)", - bench_results.len() - ); - - // Print summary of benchmark results - for (device, results) in &bench_results { - println!("\n Device: {}", device); - for (idx, result) in results.iter().enumerate() { - if let Some(function) = - result.get("function").and_then(|f| f.as_str()) - { - println!(" Benchmark {}: {}", idx + 1, function); - } - if let Some(mean) = result.get("mean_ns").and_then(|m| m.as_u64()) { - println!( - " Mean: {} ns ({:.2} ms)", - mean, - mean as f64 / 1_000_000.0 - ); - } - if let Some(samples) = - result.get("samples").and_then(|s| s.as_array()) - { - println!(" Samples: {}", samples.len()); - } - } - - // Print performance metrics if available - if let Some(metrics) = - perf_metrics.get(device).filter(|m| m.sample_count > 0) - { - println!("\n Performance Metrics:"); - if let Some(mem) = &metrics.memory { - println!(" Memory:"); - println!(" Peak: {:.2} MB", mem.peak_mb); - println!(" Average: {:.2} MB", mem.average_mb); - } - if let Some(cpu) = &metrics.cpu { - println!(" CPU:"); - println!(" Peak: {:.1}%", cpu.peak_percent); - println!(" Average: {:.1}%", cpu.average_percent); - } - } - } - - println!("\n View full results: {}", dashboard_url); - run_summary.benchmark_results = Some(bench_results.into_iter().collect()); - run_summary.performance_metrics = Some(perf_metrics.into_iter().collect()); - } - Err(e) => { - println!("\nWarning: Failed to fetch results: {}", e); - println!("Build may still be accessible at: {}", dashboard_url); - } - } - - // Also save detailed artifacts to separate directory - let output_root = fetch_output_dir.join(build_id); - if let Err(e) = fetch_browserstack_artifacts( - &client, - run_summary.spec.target, - build_id, - &output_root, - false, // Don't wait again, we already did - fetch_poll_interval_secs, - fetch_timeout_secs, - ) { - println!("Warning: Failed to fetch detailed artifacts: {}", e); - } - } else if fetch { - println!("No BrowserStack run to fetch (devices not provided?)"); - } - - run_summary.summary = build_summary(&run_summary)?; - write_summary(&run_summary, &summary_paths, summary_csv)?; - } - Command::Init { output, target } => { - write_config_template(&output, target)?; - println!("Wrote starter config to {:?}", output); - } - Command::Plan { output } => { - write_device_matrix_template(&output)?; - println!("Wrote sample device matrix to {:?}", output); - } - Command::Fetch { - target, - build_id, - output_dir, - wait, - poll_interval_secs, - timeout_secs, - } => { - let creds = resolve_browserstack_credentials(None)?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username, - access_key: creds.access_key, - }, - creds.project, - )?; - let output_root = output_dir.join(&build_id); - fetch_browserstack_artifacts( - &client, - target, - &build_id, - &output_root, - wait, - poll_interval_secs, - timeout_secs, - )?; - } - Command::Compare { - baseline, - candidate, - output, - } => { - let report = compare_summaries(&baseline, &candidate)?; - write_compare_report(&report, output.as_deref())?; - } - Command::InitSdk { - target, - project_name, - output_dir, - examples, - } => { - cmd_init_sdk(target, project_name, output_dir, examples)?; - } - Command::Build { target, release } => { - cmd_build(target, release)?; - } - Command::PackageIpa { scheme, method } => { - cmd_package_ipa(&scheme, method)?; - } - Command::List => { - cmd_list()?; - } - } - - Ok(()) -} - -fn write_config_template(path: &Path, target: MobileTarget) -> Result<()> { - ensure_can_write(path)?; - - let ios_xcuitest = if target == MobileTarget::Ios { - Some(IosXcuitestArtifacts { - app: PathBuf::from("target/ios/BenchRunner.ipa"), - test_suite: PathBuf::from("target/ios/BenchRunnerUITests.zip"), - }) - } else { - None - }; - - let cfg = BenchConfig { - target, - function: "sample_fns::fibonacci".into(), - iterations: 100, - warmup: 10, - device_matrix: PathBuf::from("device-matrix.yaml"), - device_tags: Some(vec!["default".into()]), - browserstack: BrowserStackConfig { - app_automate_username: "${BROWSERSTACK_USERNAME}".into(), - app_automate_access_key: "${BROWSERSTACK_ACCESS_KEY}".into(), - project: Some("mobile-bench-rs".into()), - }, - ios_xcuitest, - }; - - let contents = toml::to_string_pretty(&cfg)?; - write_file(path, contents.as_bytes()) -} - -fn write_device_matrix_template(path: &Path) -> Result<()> { - ensure_can_write(path)?; - - let matrix = DeviceMatrix { - devices: vec![ - DeviceEntry { - name: "Pixel 7".into(), - os: "android".into(), - os_version: "13.0".into(), - tags: Some(vec!["default".into(), "pixel".into()]), - }, - DeviceEntry { - name: "iPhone 14".into(), - os: "ios".into(), - os_version: "16".into(), - tags: Some(vec!["default".into(), "iphone".into()]), - }, - ], - }; - - let contents = serde_yaml::to_string(&matrix)?; - write_file(path, contents.as_bytes()) -} - -fn fetch_browserstack_artifacts( - client: &BrowserStackClient, - target: MobileTarget, - build_id: &str, - output_root: &Path, - wait: bool, - poll_interval_secs: u64, - timeout_secs: u64, -) -> Result<()> { - fs::create_dir_all(output_root) - .with_context(|| format!("creating output dir {:?}", output_root))?; - - let base = browserstack_base_path(target); - let build_path = format!("{base}/builds/{build_id}"); - let sessions_path = format!("{base}/builds/{build_id}/sessions"); - - if wait { - wait_for_build(client, &build_path, poll_interval_secs, timeout_secs)?; - } - - let build_json = client.get_json(&build_path)?; - write_json(output_root.join("build.json"), &build_json)?; - - let mut session_ids = extract_session_ids(&build_json); - if session_ids.is_empty() { - match client.get_json(&sessions_path) { - Ok(value) => { - write_json(output_root.join("sessions.json"), &value)?; - session_ids = extract_session_ids(&value); - } - Err(err) => { - let msg = shorten_html_error(&err.to_string()); - println!("Sessions endpoint unavailable; falling back to build.json: {msg}"); - } - } - } - - if session_ids.is_empty() { - println!("No sessions found for build {}", build_id); - return Ok(()); - } - - for session_id in session_ids { - let session_path = format!("{base}/builds/{build_id}/sessions/{session_id}"); - let session_json = client.get_json(&session_path)?; - let session_dir = output_root.join(format!("session-{}", session_id)); - fs::create_dir_all(&session_dir) - .with_context(|| format!("creating session dir {:?}", session_dir))?; - write_json(session_dir.join("session.json"), &session_json)?; - - let mut bench_report: Option = None; - for (key, url) in extract_url_fields(&session_json) { - let file_name = filename_for_url(&key, &url); - let dest = session_dir.join(file_name); - if let Err(err) = client.download_url(&url, &dest) { - println!("Skipping download for {key}: {err}"); - continue; - } - if (key.contains("device_log") - || key.contains("instrumentation_log") - || key.contains("app_log")) - && let Ok(contents) = fs::read_to_string(&dest) - && let Some(parsed) = extract_bench_json(&contents) - { - bench_report = Some(parsed); - } - } - - if let Some(report) = bench_report { - write_json(session_dir.join("bench-report.json"), &report)?; - } - } - - println!("Fetched BrowserStack artifacts to {:?}", output_root); - Ok(()) -} - -fn browserstack_base_path(target: MobileTarget) -> &'static str { - match target { - MobileTarget::Android => "app-automate/espresso/v2", - MobileTarget::Ios => "app-automate/xcuitest/v2", - } -} - -fn wait_for_build( - client: &BrowserStackClient, - build_path: &str, - poll_interval_secs: u64, - timeout_secs: u64, -) -> Result<()> { - let deadline = Instant::now() + Duration::from_secs(timeout_secs); - loop { - let build_json = client.get_json(build_path)?; - if let Some(status) = build_json - .get("status") - .and_then(|val| val.as_str()) - .map(|val| val.to_lowercase()) - { - if status == "failed" || status == "error" { - println!("Build status: {status}"); - return Ok(()); - } - if status == "done" || status == "passed" || status == "completed" { - println!("Build status: {status}"); - return Ok(()); - } - println!("Build status: {status} (waiting)"); - } else { - println!("Build status missing; continuing without wait"); - return Ok(()); - } - - if Instant::now() >= deadline { - println!("Timed out waiting for build status"); - return Ok(()); - } - std::thread::sleep(Duration::from_secs(poll_interval_secs)); - } -} - -fn extract_session_ids(value: &Value) -> Vec { - let sessions = value - .get("sessions") - .and_then(|val| val.as_array()) - .or_else(|| value.as_array()); - let mut ids = Vec::new(); - if let Some(entries) = sessions { - for entry in entries { - let id = entry - .get("id") - .or_else(|| entry.get("session_id")) - .or_else(|| entry.get("sessionId")) - .and_then(|val| val.as_str()); - if let Some(id) = id { - ids.push(id.to_string()); - } - } - } - if ids.is_empty() - && let Some(devices) = value.get("devices").and_then(|val| val.as_array()) - { - for device in devices { - if let Some(sessions) = device.get("sessions").and_then(|val| val.as_array()) { - for entry in sessions { - if let Some(id) = entry.get("id").and_then(|val| val.as_str()) { - ids.push(id.to_string()); - } - } - } - } - } - ids -} - -fn extract_url_fields(value: &Value) -> Vec<(String, String)> { - let mut urls = Vec::new(); - extract_url_fields_recursive(value, "", &mut urls); - urls -} - -fn extract_url_fields_recursive(value: &Value, prefix: &str, out: &mut Vec<(String, String)>) { - match value { - Value::Object(map) => { - for (key, val) in map { - let next = if prefix.is_empty() { - key.clone() - } else { - format!("{}.{}", prefix, key) - }; - if let Value::String(url) = val - && (url.starts_with("http") || url.starts_with("bs://")) - { - out.push((next.clone(), url.clone())); - } - extract_url_fields_recursive(val, &next, out); - } - } - Value::Array(items) => { - for (idx, val) in items.iter().enumerate() { - let next = format!("{}[{}]", prefix, idx); - extract_url_fields_recursive(val, &next, out); - } - } - _ => {} - } -} - -fn filename_for_url(key: &str, url: &str) -> String { - let stripped = url.split('?').next().unwrap_or(url); - let ext = Path::new(stripped) - .extension() - .and_then(|val| val.to_str()) - .unwrap_or("log"); - let mut safe = String::with_capacity(key.len()); - for ch in key.chars() { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - safe.push(ch); - } else { - safe.push('_'); - } - } - format!("{}.{}", safe, ext) -} - -fn extract_bench_json(contents: &str) -> Option { - let marker = "BENCH_JSON "; - for line in contents.lines().rev() { - if let Some(idx) = line.find(marker) { - let json_part = &line[idx + marker.len()..]; - if let Ok(value) = serde_json::from_str::(json_part) { - return Some(value); - } - } - } - None -} - -fn write_json(path: PathBuf, value: &Value) -> Result<()> { - let contents = serde_json::to_string_pretty(value)?; - write_file(&path, contents.as_bytes()) -} - -fn shorten_html_error(message: &str) -> String { - if message.contains("") || message.contains(", - config: Option<&Path>, - ios_app: Option, - ios_test_suite: Option, - local_only: bool, -) -> Result { - if let Some(cfg_path) = config { - let cfg = load_config(cfg_path)?; - let matrix = load_device_matrix(&cfg.device_matrix)?; - let device_names = match &cfg.device_tags { - Some(tags) if !tags.is_empty() => filter_devices_by_tags(matrix.devices, tags)?, - _ => matrix.devices.into_iter().map(|d| d.name).collect(), - }; - return Ok(RunSpec { - target: cfg.target, - function: cfg.function, - iterations: cfg.iterations, - warmup: cfg.warmup, - devices: device_names, - browserstack: Some(cfg.browserstack), - ios_xcuitest: cfg.ios_xcuitest, - }); - } - - if function.trim().is_empty() { - bail!("function must not be empty"); - } - - let ios_xcuitest = match (ios_app, ios_test_suite) { - (Some(app), Some(test_suite)) => Some(IosXcuitestArtifacts { app, test_suite }), - (None, None) => None, - _ => bail!("both --ios-app and --ios-test-suite must be provided together"), - }; - - let ios_xcuitest = if target == MobileTarget::Ios - && !local_only - && !devices.is_empty() - && ios_xcuitest.is_none() - { - Some(package_ios_xcuitest_artifacts()?) - } else { - ios_xcuitest - }; - - Ok(RunSpec { - target, - function, - iterations, - warmup, - devices, - browserstack: None, - ios_xcuitest, - }) -} - -fn load_config(path: &Path) -> Result { - let contents = - fs::read_to_string(path).with_context(|| format!("reading config {:?}", path))?; - toml::from_str(&contents).with_context(|| format!("parsing config {:?}", path)) -} - -fn load_device_matrix(path: &Path) -> Result { - let contents = - fs::read_to_string(path).with_context(|| format!("reading device matrix {:?}", path))?; - serde_yaml::from_str(&contents).with_context(|| format!("parsing device matrix {:?}", path)) -} - -fn filter_devices_by_tags(devices: Vec, tags: &[String]) -> Result> { - let wanted: Vec = tags - .iter() - .map(|tag| tag.trim().to_lowercase()) - .filter(|tag| !tag.is_empty()) - .collect(); - if wanted.is_empty() { - return Ok(devices.into_iter().map(|d| d.name).collect()); - } - - let mut matched = Vec::new(); - for device in devices { - let Some(device_tags) = device.tags.as_ref() else { - continue; - }; - let has_match = device_tags.iter().any(|tag| { - let candidate = tag.trim().to_lowercase(); - wanted.iter().any(|wanted_tag| wanted_tag == &candidate) - }); - if has_match { - matched.push(device.name); - } - } - - if matched.is_empty() { - bail!( - "no devices matched tags [{}] in device matrix", - wanted.join(", ") - ); - } - Ok(matched) -} - -fn run_ios_build() -> Result<(PathBuf, PathBuf)> { - let root = repo_root()?; - let crate_name = - detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); - let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); - let cfg = mobench_sdk::BuildConfig { - target: mobench_sdk::Target::Ios, - profile: mobench_sdk::BuildProfile::Debug, - incremental: true, - }; - let result = builder.build(&cfg)?; - let header = root.join("target/ios/include").join(format!( - "{}.h", - result - .app_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("module") - )); - Ok((result.app_path, header)) -} - -fn package_ios_xcuitest_artifacts() -> Result { - let root = repo_root()?; - let crate_name = - detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); - let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); - let cfg = mobench_sdk::BuildConfig { - target: mobench_sdk::Target::Ios, - profile: mobench_sdk::BuildProfile::Debug, - incremental: true, - }; - builder - .build(&cfg) - .context("Failed to build iOS xcframework before packaging")?; - let app = builder - .package_ipa("BenchRunner", mobench_sdk::builders::SigningMethod::AdHoc) - .context("Failed to package iOS IPA for BrowserStack")?; - let test_suite = builder - .package_xcuitest("BenchRunner") - .context("Failed to package iOS XCUITest runner for BrowserStack")?; - Ok(IosXcuitestArtifacts { app, test_suite }) -} - -#[derive(Debug, Clone)] -struct ResolvedBrowserStack { - username: String, - access_key: String, - project: Option, -} - -fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path, test_apk: &Path) -> Result { - let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username.clone(), - access_key: creds.access_key.clone(), - }, - creds.project.clone(), - )?; - - // Upload the app-under-test APK. - let upload = client.upload_espresso_app(apk)?; - - // Upload the Espresso test-suite APK produced by Gradle. - let test_upload = client.upload_espresso_test_suite(test_apk)?; - - // Schedule the Espresso build with both app and testSuite, as required by BrowserStack. - let run = client.schedule_espresso_run( - &spec.devices, - &upload.app_url, - &test_upload.test_suite_url, - )?; - println!( - "Queued BrowserStack Espresso build {} for devices: {}", - run.build_id, - spec.devices.join(", ") - ); - - Ok(RemoteRun::Android { - app_url: upload.app_url, - build_id: run.build_id, - }) -} - -fn trigger_browserstack_xcuitest( - spec: &RunSpec, - artifacts: &IosXcuitestArtifacts, -) -> Result { - let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; - let client = BrowserStackClient::new( - BrowserStackAuth { - username: creds.username.clone(), - access_key: creds.access_key.clone(), - }, - creds.project.clone(), - )?; - - if !artifacts.app.exists() { - bail!( - "iOS app artifact not found at {:?}; provide a .ipa or zipped .app", - artifacts.app - ); - } - if !artifacts.test_suite.exists() { - bail!( - "iOS XCUITest test suite artifact not found at {:?}; provide the zipped test runner bundle", - artifacts.test_suite - ); - } - - let app_upload = client.upload_xcuitest_app(&artifacts.app)?; - let test_upload = client.upload_xcuitest_test_suite(&artifacts.test_suite)?; - let run = client.schedule_xcuitest_run( - &spec.devices, - &app_upload.app_url, - &test_upload.test_suite_url, - )?; - println!( - "Queued BrowserStack XCUITest build {} for devices: {}", - run.build_id, - spec.devices.join(", ") - ); - - Ok(RemoteRun::Ios { - app_url: app_upload.app_url, - test_suite_url: test_upload.test_suite_url, - build_id: run.build_id, - }) -} - -fn resolve_browserstack_credentials( - config: Option<&BrowserStackConfig>, -) -> Result { - let mut username = None; - let mut access_key = None; - let mut project = None; - - if let Some(cfg) = config { - username = Some(expand_env_var(&cfg.app_automate_username)?); - access_key = Some(expand_env_var(&cfg.app_automate_access_key)?); - project = cfg - .project - .as_ref() - .map(|p| expand_env_var(p)) - .transpose()?; - } - - if username.as_deref().map(str::is_empty).unwrap_or(true) - && let Ok(val) = env::var("BROWSERSTACK_USERNAME") - && !val.is_empty() - { - username = Some(val); - } - if access_key.as_deref().map(str::is_empty).unwrap_or(true) - && let Ok(val) = env::var("BROWSERSTACK_ACCESS_KEY") - && !val.is_empty() - { - access_key = Some(val); - } - if project.is_none() - && let Ok(val) = env::var("BROWSERSTACK_PROJECT") - && !val.is_empty() - { - project = Some(val); - } - - let username = username.filter(|s| !s.is_empty()).ok_or_else(|| { - anyhow!("BrowserStack username missing; set BROWSERSTACK_USERNAME or provide in config") - })?; - let access_key = access_key.filter(|s| !s.is_empty()).ok_or_else(|| { - anyhow!("BrowserStack access key missing; set BROWSERSTACK_ACCESS_KEY or provide in config") - })?; - - Ok(ResolvedBrowserStack { - username, - access_key, - project, - }) -} - -fn expand_env_var(raw: &str) -> Result { - if let Some(stripped) = raw.strip_prefix("${").and_then(|s| s.strip_suffix('}')) { - let val = env::var(stripped) - .with_context(|| format!("resolving env var {stripped} for BrowserStack config"))?; - return Ok(val); - } - Ok(raw.to_string()) -} - -#[cfg(test)] -fn run_local_smoke(spec: &RunSpec) -> Result { - println!("Running local smoke test for {}...", spec.function); - - let bench_spec = mobench_sdk::BenchSpec { - name: spec.function.clone(), - iterations: spec.iterations, - warmup: spec.warmup, - }; - - let report = - mobench_sdk::run_benchmark(bench_spec).map_err(|e| anyhow!("benchmark failed: {:?}", e))?; - - serde_json::to_value(&report).context("serializing benchmark report") -} - -fn persist_mobile_spec(spec: &RunSpec) -> Result<()> { - let root = repo_root()?; - let payload = json!({ - "function": spec.function, - "iterations": spec.iterations, - "warmup": spec.warmup, - }); - let contents = serde_json::to_string_pretty(&payload)?; - let targets = [ - root.join("target/mobile-spec/android/bench_spec.json"), - root.join("target/mobile-spec/ios/bench_spec.json"), - ]; - for path in targets { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("creating directory {:?}", parent))?; - } - write_file(&path, contents.as_bytes())?; - } - Ok(()) -} - -#[derive(Debug)] -struct SummaryPaths { - json: PathBuf, - markdown: PathBuf, - csv: PathBuf, -} - -fn resolve_summary_paths(output: Option<&Path>) -> Result { - let json = output - .map(ToOwned::to_owned) - .unwrap_or_else(|| PathBuf::from("run-summary.json")); - let markdown = json.with_extension("md"); - let csv = json.with_extension("csv"); - Ok(SummaryPaths { - json, - markdown, - csv, - }) -} - -fn empty_summary(spec: &RunSpec) -> SummaryReport { - SummaryReport { - generated_at: "pending".to_string(), - generated_at_unix: 0, - target: spec.target, - function: spec.function.clone(), - iterations: spec.iterations, - warmup: spec.warmup, - devices: spec.devices.clone(), - device_summaries: Vec::new(), - } -} - -fn build_summary(run_summary: &RunSummary) -> Result { - let generated_at_unix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("generating timestamp")? - .as_secs(); - let generated_at = OffsetDateTime::now_utc() - .format(&Rfc3339) - .unwrap_or_else(|_| generated_at_unix.to_string()); - - let mut device_summaries = Vec::new(); - - if let Some(results) = &run_summary.benchmark_results { - for (device, entries) in results { - let mut benchmarks = Vec::new(); - for entry in entries { - let function = entry - .get("function") - .and_then(|f| f.as_str()) - .unwrap_or("unknown") - .to_string(); - let samples = extract_samples(entry); - let stats = compute_sample_stats(&samples); - let mean_ns = stats - .as_ref() - .map(|s| s.mean_ns) - .or_else(|| entry.get("mean_ns").and_then(|m| m.as_u64())); - - benchmarks.push(BenchmarkStats { - function, - samples: samples.len(), - mean_ns, - median_ns: stats.as_ref().map(|s| s.median_ns), - p95_ns: stats.as_ref().map(|s| s.p95_ns), - min_ns: stats.as_ref().map(|s| s.min_ns), - max_ns: stats.as_ref().map(|s| s.max_ns), - }); - } - - benchmarks.sort_by(|a, b| a.function.cmp(&b.function)); - device_summaries.push(DeviceSummary { - device: device.clone(), - benchmarks, - }); - } - } - - if device_summaries.is_empty() - && let Some(local_summary) = summarize_local_report(run_summary) - { - device_summaries.push(local_summary); - } - - Ok(SummaryReport { - generated_at, - generated_at_unix, - target: run_summary.spec.target, - function: run_summary.spec.function.clone(), - iterations: run_summary.spec.iterations, - warmup: run_summary.spec.warmup, - devices: run_summary.spec.devices.clone(), - device_summaries, - }) -} - -fn write_summary(summary: &RunSummary, paths: &SummaryPaths, summary_csv: bool) -> Result<()> { - let json = serde_json::to_string_pretty(summary)?; - ensure_parent_dir(&paths.json)?; - write_file(&paths.json, json.as_bytes())?; - println!("Wrote run summary to {:?}", paths.json); - - let markdown = render_markdown_summary(&summary.summary); - ensure_parent_dir(&paths.markdown)?; - write_file(&paths.markdown, markdown.as_bytes())?; - println!("Wrote markdown summary to {:?}", paths.markdown); - - if summary_csv { - let csv = render_csv_summary(&summary.summary); - ensure_parent_dir(&paths.csv)?; - write_file(&paths.csv, csv.as_bytes())?; - println!("Wrote CSV summary to {:?}", paths.csv); - } - Ok(()) -} - -fn ensure_parent_dir(path: &Path) -> Result<()> { - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent).with_context(|| format!("creating directory {:?}", parent))?; - } - Ok(()) -} - -#[derive(Debug)] -struct CompareReport { - baseline: PathBuf, - candidate: PathBuf, - rows: Vec, -} - -#[derive(Debug)] -struct CompareRow { - device: String, - function: String, - baseline_median_ns: Option, - candidate_median_ns: Option, - median_delta_pct: Option, - baseline_p95_ns: Option, - candidate_p95_ns: Option, - p95_delta_pct: Option, -} - -fn compare_summaries(baseline: &Path, candidate: &Path) -> Result { - let baseline_summary = load_run_summary(baseline)?; - let candidate_summary = load_run_summary(candidate)?; - - let baseline_map = summary_lookup(&baseline_summary.summary); - let candidate_map = summary_lookup(&candidate_summary.summary); - - let mut rows = Vec::new(); - let mut devices: BTreeMap = BTreeMap::new(); - devices.extend(baseline_map.keys().map(|k| (k.clone(), ()))); - devices.extend(candidate_map.keys().map(|k| (k.clone(), ()))); - - for device in devices.keys() { - let mut functions: BTreeMap = BTreeMap::new(); - if let Some(entry) = baseline_map.get(device) { - functions.extend(entry.keys().map(|k| (k.clone(), ()))); - } - if let Some(entry) = candidate_map.get(device) { - functions.extend(entry.keys().map(|k| (k.clone(), ()))); - } - - for function in functions.keys() { - let baseline_stats = baseline_map - .get(device) - .and_then(|entry| entry.get(function)); - let candidate_stats = candidate_map - .get(device) - .and_then(|entry| entry.get(function)); - - let baseline_median = baseline_stats.and_then(|s| s.median_ns); - let candidate_median = candidate_stats.and_then(|s| s.median_ns); - let median_delta = percent_delta(baseline_median, candidate_median); - - let baseline_p95 = baseline_stats.and_then(|s| s.p95_ns); - let candidate_p95 = candidate_stats.and_then(|s| s.p95_ns); - let p95_delta = percent_delta(baseline_p95, candidate_p95); - - rows.push(CompareRow { - device: device.clone(), - function: function.clone(), - baseline_median_ns: baseline_median, - candidate_median_ns: candidate_median, - median_delta_pct: median_delta, - baseline_p95_ns: baseline_p95, - candidate_p95_ns: candidate_p95, - p95_delta_pct: p95_delta, - }); - } - } - - Ok(CompareReport { - baseline: baseline.to_path_buf(), - candidate: candidate.to_path_buf(), - rows, - }) -} - -fn load_run_summary(path: &Path) -> Result { - let contents = fs::read_to_string(path).with_context(|| format!("reading {:?}", path))?; - serde_json::from_str(&contents).with_context(|| format!("parsing summary {:?}", path)) -} - -fn summary_lookup(summary: &SummaryReport) -> BTreeMap> { - let mut map = BTreeMap::new(); - for device in &summary.device_summaries { - let mut functions = BTreeMap::new(); - for bench in &device.benchmarks { - functions.insert(bench.function.clone(), bench.clone()); - } - map.insert(device.device.clone(), functions); - } - map -} - -fn percent_delta(baseline: Option, candidate: Option) -> Option { - let baseline = baseline? as f64; - let candidate = candidate? as f64; - if baseline == 0.0 { - return None; - } - Some(((candidate - baseline) / baseline) * 100.0) -} - -fn write_compare_report(report: &CompareReport, output: Option<&Path>) -> Result<()> { - let markdown = render_compare_markdown(report); - if let Some(path) = output { - ensure_parent_dir(path)?; - write_file(path, markdown.as_bytes())?; - println!("Wrote compare report to {:?}", path); - } else { - println!("{markdown}"); - } - Ok(()) -} - -fn render_compare_markdown(report: &CompareReport) -> String { - let mut output = String::new(); - let _ = writeln!(output, "# Benchmark Comparison"); - let _ = writeln!(output); - let _ = writeln!(output, "- Baseline: {}", report.baseline.display()); - let _ = writeln!(output, "- Candidate: {}", report.candidate.display()); - let _ = writeln!(output); - let _ = writeln!( - output, - "| Device | Function | Median (base ms) | Median (cand ms) | Median Δ% | P95 (base ms) | P95 (cand ms) | P95 Δ% |" - ); - let _ = writeln!( - output, - "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |" - ); - for row in &report.rows { - let _ = writeln!( - output, - "| {} | {} | {} | {} | {} | {} | {} | {} |", - row.device, - row.function, - format_ms(row.baseline_median_ns), - format_ms(row.candidate_median_ns), - format_delta(row.median_delta_pct), - format_ms(row.baseline_p95_ns), - format_ms(row.candidate_p95_ns), - format_delta(row.p95_delta_pct) - ); - } - output -} - -fn format_delta(value: Option) -> String { - value - .map(|delta| format!("{:+.2}%", delta)) - .unwrap_or_else(|| "-".to_string()) -} - -fn summarize_local_report(run_summary: &RunSummary) -> Option { - let samples = extract_samples(&run_summary.local_report); - if samples.is_empty() { - return None; - } - let stats = compute_sample_stats(&samples)?; - let function = run_summary - .local_report - .get("spec") - .and_then(|spec| spec.get("name")) - .and_then(|name| name.as_str()) - .unwrap_or(&run_summary.spec.function) - .to_string(); - - Some(DeviceSummary { - device: "local".to_string(), - benchmarks: vec![BenchmarkStats { - function, - samples: samples.len(), - mean_ns: Some(stats.mean_ns), - median_ns: Some(stats.median_ns), - p95_ns: Some(stats.p95_ns), - min_ns: Some(stats.min_ns), - max_ns: Some(stats.max_ns), - }], - }) -} - -#[derive(Clone, Debug)] -struct SampleStats { - mean_ns: u64, - median_ns: u64, - p95_ns: u64, - min_ns: u64, - max_ns: u64, -} - -fn compute_sample_stats(samples: &[u64]) -> Option { - if samples.is_empty() { - return None; - } - - let mut sorted = samples.to_vec(); - sorted.sort_unstable(); - let len = sorted.len(); - - let mean_ns = (sorted.iter().map(|v| *v as u128).sum::() / len as u128) as u64; - let median_ns = if len % 2 == 1 { - sorted[len / 2] - } else { - let lower = sorted[(len / 2) - 1]; - let upper = sorted[len / 2]; - (lower + upper) / 2 - }; - let p95_index = percentile_index(len, 0.95); - let p95_ns = sorted[p95_index]; - let min_ns = sorted[0]; - let max_ns = sorted[len - 1]; - - Some(SampleStats { - mean_ns, - median_ns, - p95_ns, - min_ns, - max_ns, - }) -} - -fn percentile_index(len: usize, percentile: f64) -> usize { - if len == 0 { - return 0; - } - let rank = (percentile * len as f64).ceil() as usize; - let index = rank.saturating_sub(1); - index.min(len - 1) -} - -fn extract_samples(value: &Value) -> Vec { - let Some(samples) = value.get("samples").and_then(|s| s.as_array()) else { - return Vec::new(); - }; - let mut durations = Vec::with_capacity(samples.len()); - for sample in samples { - if let Some(duration) = sample - .get("duration_ns") - .and_then(|duration| duration.as_u64()) - { - durations.push(duration); - } else if let Some(duration) = sample.as_u64() { - durations.push(duration); - } - } - durations -} - -fn render_markdown_summary(summary: &SummaryReport) -> String { - let mut output = String::new(); - let devices = if summary.devices.is_empty() { - "none".to_string() - } else { - summary.devices.join(", ") - }; - - let _ = writeln!(output, "# Benchmark Summary"); - let _ = writeln!(output); - let _ = writeln!(output, "- Generated: {}", summary.generated_at); - let _ = writeln!(output, "- Target: {:?}", summary.target); - let _ = writeln!(output, "- Function: {}", summary.function); - let _ = writeln!( - output, - "- Iterations/Warmup: {} / {}", - summary.iterations, summary.warmup - ); - let _ = writeln!(output, "- Devices: {}", devices); - let _ = writeln!(output); - - if summary.device_summaries.is_empty() { - let _ = writeln!(output, "No benchmark samples were collected."); - return output; - } - - for device in &summary.device_summaries { - let _ = writeln!(output, "## Device: {}", device.device); - let _ = writeln!(output); - let _ = writeln!( - output, - "| Function | Samples | Mean (ms) | Median (ms) | P95 (ms) | Min (ms) | Max (ms) |" - ); - let _ = writeln!(output, "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"); - for bench in &device.benchmarks { - let _ = writeln!( - output, - "| {} | {} | {} | {} | {} | {} | {} |", - bench.function, - bench.samples, - format_ms(bench.mean_ns), - format_ms(bench.median_ns), - format_ms(bench.p95_ns), - format_ms(bench.min_ns), - format_ms(bench.max_ns) - ); - } - let _ = writeln!(output); - } - - output -} - -fn render_csv_summary(summary: &SummaryReport) -> String { - let mut output = String::new(); - let _ = writeln!( - output, - "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns" - ); - for device in &summary.device_summaries { - for bench in &device.benchmarks { - let _ = writeln!( - output, - "{},{},{},{},{},{},{},{}", - device.device, - bench.function, - bench.samples, - bench.mean_ns.map_or(String::from(""), |v| v.to_string()), - bench.median_ns.map_or(String::from(""), |v| v.to_string()), - bench.p95_ns.map_or(String::from(""), |v| v.to_string()), - bench.min_ns.map_or(String::from(""), |v| v.to_string()), - bench.max_ns.map_or(String::from(""), |v| v.to_string()) - ); - } - } - output -} - -fn format_ms(value: Option) -> String { - value - .map(|ns| format!("{:.3}", ns as f64 / 1_000_000.0)) - .unwrap_or_else(|| "-".to_string()) -} - -fn run_android_build(_ndk_home: &str) -> Result { - let root = repo_root()?; - let crate_name = - detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); - - let cfg = mobench_sdk::BuildConfig { - target: mobench_sdk::Target::Android, - profile: mobench_sdk::BuildProfile::Debug, - incremental: true, - }; - let builder = mobench_sdk::builders::AndroidBuilder::new(&root, crate_name).verbose(true); - let result = builder.build(&cfg)?; - Ok(result) -} - -fn load_dotenv() { - if let Ok(root) = repo_root() { - let path = root.join(".env.local"); - let _ = dotenvy::from_path(path); - } -} - -fn repo_root() -> Result { - // Prefer the build-time repo root but fall back to the current directory for installed binaries. - let compiled = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(".."); - if let Ok(path) = compiled.canonicalize() { - return Ok(path); - } - std::env::current_dir().context("resolving repo root from current directory") -} - -fn ensure_can_write(path: &Path) -> Result<()> { - if path.exists() { - bail!("refusing to overwrite existing file: {:?}", path); - } - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent) - .with_context(|| format!("creating parent directory {:?}", parent))?; - } - Ok(()) -} - -fn write_file(path: &Path, contents: &[u8]) -> Result<()> { - fs::write(path, contents).with_context(|| format!("writing file {:?}", path)) -} - -/// Initialize a new benchmark project using mobench-sdk (Phase 1 MVP) -fn cmd_init_sdk( - target: SdkTarget, - project_name: String, - output_dir: PathBuf, - generate_examples: bool, -) -> Result<()> { - println!("Initializing benchmark project with mobench-sdk..."); - println!(" Project name: {}", project_name); - println!(" Target: {:?}", target); - println!(" Output directory: {:?}", output_dir); - - let config = mobench_sdk::InitConfig { - target: target.into(), - project_name: project_name.clone(), - output_dir: output_dir.clone(), - generate_examples, - }; - - mobench_sdk::codegen::generate_project(&config).context("Failed to generate project")?; - - println!("\n✓ Project initialized successfully!"); - println!("\nNext steps:"); - println!(" 1. Add benchmark functions to your code with #[benchmark]"); - println!(" 2. Run 'cargo build --target ' to build"); - println!(" 3. Run benchmarks with 'cargo mobench build --target '"); - - Ok(()) -} - -/// Build mobile artifacts using mobench-sdk (Phase 1 MVP) -fn cmd_build(target: SdkTarget, release: bool) -> Result<()> { - println!("Building mobile artifacts..."); - println!(" Target: {:?}", target); - println!(" Profile: {}", if release { "release" } else { "debug" }); - - let project_root = std::env::current_dir().context("Failed to get current directory")?; - let crate_name = detect_bench_mobile_crate_name(&project_root) - .unwrap_or_else(|_| "bench-mobile".to_string()); // Fallback for legacy layouts - - let build_config = mobench_sdk::BuildConfig { - target: target.into(), - profile: if release { - mobench_sdk::BuildProfile::Release - } else { - mobench_sdk::BuildProfile::Debug - }, - incremental: true, - }; - - match target { - SdkTarget::Android => { - let builder = - mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) - .verbose(true); - let result = builder.build(&build_config)?; - println!("\n✓ Android build completed!"); - println!(" APK: {:?}", result.app_path); - } - SdkTarget::Ios => { - let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name.clone()) - .verbose(true); - let result = builder.build(&build_config)?; - println!("\n✓ iOS build completed!"); - println!(" Framework: {:?}", result.app_path); - } - SdkTarget::Both => { - // Build Android - let android_builder = - mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) - .verbose(true); - let android_result = android_builder.build(&build_config)?; - println!("\n✓ Android build completed!"); - println!(" APK: {:?}", android_result.app_path); - - // Build iOS - let ios_builder = - mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); - let ios_result = ios_builder.build(&build_config)?; - println!("\n✓ iOS build completed!"); - println!(" Framework: {:?}", ios_result.app_path); - } - } - - Ok(()) -} - -fn detect_bench_mobile_crate_name(root: &Path) -> Result { - // Try bench-mobile/ first (SDK projects) - let bench_mobile_path = root.join("bench-mobile").join("Cargo.toml"); - if bench_mobile_path.exists() { - let contents = fs::read_to_string(&bench_mobile_path) - .with_context(|| format!("reading bench-mobile manifest at {:?}", bench_mobile_path))?; - let value: toml::Value = toml::from_str(&contents) - .with_context(|| format!("parsing bench-mobile manifest {:?}", bench_mobile_path))?; - let name = value - .get("package") - .and_then(|pkg| pkg.get("name")) - .and_then(|n| n.as_str()) - .ok_or_else(|| { - anyhow!( - "bench-mobile package.name missing in {:?}", - bench_mobile_path - ) - })?; - return Ok(name.to_string()); - } - - // Fallback: Try crates/sample-fns (repository testing) - let sample_fns_path = root.join("crates").join("sample-fns").join("Cargo.toml"); - if sample_fns_path.exists() { - let contents = fs::read_to_string(&sample_fns_path) - .with_context(|| format!("reading sample-fns manifest at {:?}", sample_fns_path))?; - let value: toml::Value = toml::from_str(&contents) - .with_context(|| format!("parsing sample-fns manifest {:?}", sample_fns_path))?; - let name = value - .get("package") - .and_then(|pkg| pkg.get("name")) - .and_then(|n| n.as_str()) - .ok_or_else(|| anyhow!("sample-fns package.name missing in {:?}", sample_fns_path))?; - return Ok(name.to_string()); - } - - bail!("No benchmark crate found. Expected 'bench-mobile/' or 'crates/sample-fns/'") -} - -/// List all discovered benchmark functions (Phase 1 MVP) -fn cmd_list() -> Result<()> { - println!("Discovering benchmark functions...\n"); - - let benchmarks = mobench_sdk::discover_benchmarks(); - - if benchmarks.is_empty() { - println!("No benchmarks found."); - println!("\nTo add benchmarks:"); - println!(" 1. Add #[benchmark] attribute to functions"); - println!(" 2. Make sure mobench-sdk is in your dependencies"); - println!(" 3. Rebuild your project"); - } else { - println!("Found {} benchmark(s):", benchmarks.len()); - for bench in benchmarks { - println!(" - {}", bench.name); - } - } - - Ok(()) -} - -/// Package iOS app as IPA for distribution or testing -fn cmd_package_ipa(scheme: &str, method: IosSigningMethodArg) -> Result<()> { - println!("Packaging iOS app as IPA..."); - println!(" Scheme: {}", scheme); - println!(" Method: {:?}", method); - - let project_root = repo_root()?; - let crate_name = detect_bench_mobile_crate_name(&project_root) - .unwrap_or_else(|_| "bench-mobile".to_string()); - - let builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); - - let signing_method: mobench_sdk::builders::SigningMethod = method.into(); - let ipa_path = builder - .package_ipa(scheme, signing_method) - .context("Failed to package IPA")?; - - println!("\n✓ IPA packaged successfully!"); - println!(" Path: {:?}", ipa_path); - println!("\nYou can now:"); - println!(" - Install on device: Use Xcode or ios-deploy"); - println!( - " - Test on BrowserStack: cargo mobench run --target ios --ios-app {:?}", - ipa_path - ); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // Register a lightweight benchmark for tests so the inventory contains at least one entry. - #[mobench_sdk::benchmark] - fn noop_benchmark() { - std::hint::black_box(1u8); - } - - #[test] - fn resolves_cli_spec() { - let spec = resolve_run_spec( - MobileTarget::Android, - "sample_fns::fibonacci".into(), - 5, - 1, - vec!["pixel".into()], - None, - None, - None, - false, - ) - .unwrap(); - assert_eq!(spec.function, "sample_fns::fibonacci"); - assert_eq!(spec.iterations, 5); - assert_eq!(spec.warmup, 1); - assert_eq!(spec.devices, vec!["pixel".to_string()]); - assert!(spec.browserstack.is_none()); - assert!(spec.ios_xcuitest.is_none()); - } - - #[test] - fn local_smoke_produces_samples() { - let spec = RunSpec { - target: MobileTarget::Android, - function: "noop_benchmark".into(), - iterations: 3, - warmup: 1, - devices: vec![], - browserstack: None, - ios_xcuitest: None, - }; - let report = run_local_smoke(&spec).expect("local harness"); - assert!(report["samples"].is_array()); - assert_eq!(report["spec"]["name"], "noop_benchmark"); - } - - #[test] - fn ios_requires_artifacts_for_browserstack() { - let spec = resolve_run_spec( - MobileTarget::Ios, - "sample_fns::fibonacci".into(), - 1, - 0, - vec!["iphone".into()], - None, - None, - None, - false, - ) - .expect("should auto-package iOS artifacts when missing"); - let ios_artifacts = spec - .ios_xcuitest - .expect("iOS artifacts should be populated"); - assert!(ios_artifacts.app.exists(), "iOS app artifact missing"); - assert!( - ios_artifacts.test_suite.exists(), - "iOS test suite artifact missing" - ); +fn main() { + if let Err(err) = mobench::run() { + eprintln!("{err:#}"); + std::process::exit(1); } } diff --git a/crates/sample-fns/src/lib.rs b/crates/sample-fns/src/lib.rs index e1c128d..67c1335 100644 --- a/crates/sample-fns/src/lib.rs +++ b/crates/sample-fns/src/lib.rs @@ -42,7 +42,7 @@ pub enum BenchError { // Generate UniFFI scaffolding from proc macros uniffi::setup_scaffolding!(); -// Conversion from bench-runner types +// Conversion from mobench-runner types impl From for BenchSpec { fn from(spec: mobench_runner::BenchSpec) -> Self { Self { diff --git a/crates/sample-fns/src/sample_fns.udl b/crates/sample-fns/src/sample_fns.udl deleted file mode 100644 index 44ce090..0000000 --- a/crates/sample-fns/src/sample_fns.udl +++ /dev/null @@ -1,26 +0,0 @@ -namespace sample_fns { - [Throws=BenchError] - BenchReport run_benchmark(BenchSpec spec); -}; - -dictionary BenchSpec { - string name; - u32 iterations; - u32 warmup; -}; - -dictionary BenchSample { - u64 duration_ns; -}; - -dictionary BenchReport { - BenchSpec spec; - sequence samples; -}; - -[Error] -enum BenchError { - "InvalidIterations", - "UnknownFunction", - "ExecutionFailed", -}; diff --git a/examples/basic-benchmark/Cargo.toml b/examples/basic-benchmark/Cargo.toml index d69a4b0..a89e5fe 100644 --- a/examples/basic-benchmark/Cargo.toml +++ b/examples/basic-benchmark/Cargo.toml @@ -12,10 +12,3 @@ crate-type = ["lib", "cdylib", "staticlib"] # Use mobench-sdk for the #[benchmark] macro and registry mobench-sdk = { path = "../../crates/mobench-sdk" } inventory.workspace = true -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uniffi = { workspace = true, features = ["cli"] } -thiserror.workspace = true - -[build-dependencies] -uniffi = { workspace = true, features = ["build"] } diff --git a/examples/basic-benchmark/build.rs b/examples/basic-benchmark/build.rs index 8ac5b0a..c003aab 100644 --- a/examples/basic-benchmark/build.rs +++ b/examples/basic-benchmark/build.rs @@ -1,4 +1,3 @@ fn main() { - // Scaffolding is now generated via proc macros (uniffi::setup_scaffolding! in lib.rs) - // No UDL file processing needed + // No build-time steps required for the minimal example. } diff --git a/examples/basic-benchmark/src/lib.rs b/examples/basic-benchmark/src/lib.rs index 71f5292..b756ae6 100644 --- a/examples/basic-benchmark/src/lib.rs +++ b/examples/basic-benchmark/src/lib.rs @@ -1,113 +1,13 @@ -//! Basic benchmark examples demonstrating mobench-sdk usage +//! Basic benchmark examples demonstrating mobench-sdk usage. //! -//! This example crate shows how to write benchmarks using the mobench-sdk -//! with the #[benchmark] attribute macro. +//! This example keeps things minimal: register functions with #[benchmark] and +//! let the SDK handle discovery and execution. See `examples/ffi-benchmark` for +//! a full UniFFI-based FFI surface. use mobench_sdk::benchmark; const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; -/// Specification for a benchmark run. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] -pub struct BenchSpec { - pub name: String, - pub iterations: u32, - pub warmup: u32, -} - -/// A single benchmark sample with timing information. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] -pub struct BenchSample { - pub duration_ns: u64, -} - -/// Complete benchmark report with spec and timing samples. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] -pub struct BenchReport { - pub spec: BenchSpec, - pub samples: Vec, -} - -/// Error types for benchmark operations. -#[derive(Debug, thiserror::Error, uniffi::Error)] -#[uniffi(flat_error)] -pub enum BenchError { - #[error("iterations must be greater than zero")] - InvalidIterations, - - #[error("unknown benchmark function: {name}")] - UnknownFunction { name: String }, - - #[error("benchmark execution failed: {reason}")] - ExecutionFailed { reason: String }, -} - -// Generate UniFFI scaffolding from proc macros -uniffi::setup_scaffolding!(); - -// Conversion from mobench-sdk types -impl From for BenchSpec { - fn from(spec: mobench_sdk::BenchSpec) -> Self { - Self { - name: spec.name, - iterations: spec.iterations, - warmup: spec.warmup, - } - } -} - -impl From for mobench_sdk::BenchSpec { - fn from(spec: BenchSpec) -> Self { - Self { - name: spec.name, - iterations: spec.iterations, - warmup: spec.warmup, - } - } -} - -impl From for BenchSample { - fn from(sample: mobench_sdk::BenchSample) -> Self { - Self { - duration_ns: sample.duration_ns, - } - } -} - -impl From for BenchReport { - fn from(report: mobench_sdk::RunnerReport) -> Self { - Self { - spec: report.spec.into(), - samples: report.samples.into_iter().map(Into::into).collect(), - } - } -} - -impl From for BenchError { - fn from(err: mobench_sdk::BenchError) -> Self { - match err { - mobench_sdk::BenchError::Runner(runner_err) => BenchError::ExecutionFailed { - reason: runner_err.to_string(), - }, - mobench_sdk::BenchError::UnknownFunction(name) => BenchError::UnknownFunction { name }, - _ => BenchError::ExecutionFailed { - reason: err.to_string(), - }, - } - } -} - -/// Run a benchmark by name with the given specification -/// -/// This is the main FFI entry point called from mobile platforms. -/// It uses mobench-sdk's registry to discover and execute benchmarks. -#[uniffi::export] -pub fn run_benchmark(spec: BenchSpec) -> Result { - let sdk_spec: mobench_sdk::BenchSpec = spec.into(); - let report = mobench_sdk::run_benchmark(sdk_spec)?; - Ok(report.into()) -} - /// Compute fibonacci number iteratively. pub fn fibonacci(n: u32) -> u64 { match n { @@ -181,51 +81,19 @@ mod tests { } #[test] - fn test_run_benchmark_via_registry() { - // Test that benchmarks can be discovered via the registry - let benchmarks = mobench_sdk::discover_benchmarks(); + fn test_discover_benchmarks() { + let benchmarks: Vec<&mobench_sdk::BenchFunction> = mobench_sdk::discover_benchmarks(); assert!(benchmarks.len() >= 2, "Should find at least 2 benchmarks"); + } - // Test execution via FFI using registry name - let spec = BenchSpec { + #[test] + fn test_run_benchmark_via_sdk() { + let spec = mobench_sdk::BenchSpec { name: "basic_benchmark::bench_fibonacci".to_string(), iterations: 3, warmup: 1, }; - let report = run_benchmark(spec).unwrap(); + let report = mobench_sdk::run_benchmark(spec).unwrap(); assert_eq!(report.samples.len(), 3); } - - #[test] - fn test_run_benchmark_checksum() { - let spec = BenchSpec { - name: "basic_benchmark::bench_checksum".to_string(), - iterations: 2, - warmup: 0, - }; - let report = run_benchmark(spec).unwrap(); - assert_eq!(report.samples.len(), 2); - } - - #[test] - fn test_unknown_function_error() { - let spec = BenchSpec { - name: "unknown".to_string(), - iterations: 1, - warmup: 0, - }; - let result = run_benchmark(spec); - assert!(matches!(result, Err(BenchError::UnknownFunction { .. }))); - } - - #[test] - fn test_invalid_iterations() { - let spec = BenchSpec { - name: "basic_benchmark::bench_fibonacci".to_string(), - iterations: 0, - warmup: 0, - }; - let result = run_benchmark(spec); - assert!(matches!(result, Err(BenchError::ExecutionFailed { .. }))); - } } diff --git a/examples/ffi-benchmark/Cargo.toml b/examples/ffi-benchmark/Cargo.toml new file mode 100644 index 0000000..ec42edd --- /dev/null +++ b/examples/ffi-benchmark/Cargo.toml @@ -0,0 +1,20 @@ + [package] + name = "ffi-benchmark" + version = "0.1.0" + edition = "2021" + license.workspace = true + + [lib] + name = "ffi_benchmark" + crate-type = ["lib", "cdylib", "staticlib"] + + [dependencies] + mobench-sdk = { path = "../../crates/mobench-sdk" } + inventory.workspace = true + serde = { version = "1", features = ["derive"] } + serde_json = "1" + uniffi = { workspace = true, features = ["cli"] } + thiserror.workspace = true + + [build-dependencies] + uniffi = { workspace = true, features = ["build"] } diff --git a/examples/ffi-benchmark/src/lib.rs b/examples/ffi-benchmark/src/lib.rs new file mode 100644 index 0000000..84e524d --- /dev/null +++ b/examples/ffi-benchmark/src/lib.rs @@ -0,0 +1,231 @@ +//! FFI benchmark example demonstrating UniFFI integration. +//! +//! This example shows how to define a full FFI surface (types, errors, and +//! `run_benchmark`) for Kotlin/Swift bindings. For the minimal SDK-only usage, +//! see `examples/basic-benchmark`. + +use mobench_sdk::benchmark; + +const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; + +/// Specification for a benchmark run. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} + +/// A single benchmark sample with timing information. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchSample { + pub duration_ns: u64, +} + +/// Complete benchmark report with spec and timing samples. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchReport { + pub spec: BenchSpec, + pub samples: Vec, +} + +/// Error types for benchmark operations. +#[derive(Debug, thiserror::Error, uniffi::Error)] +#[uniffi(flat_error)] +pub enum BenchError { + #[error("iterations must be greater than zero")] + InvalidIterations, + + #[error("unknown benchmark function: {name}")] + UnknownFunction { name: String }, + + #[error("benchmark execution failed: {reason}")] + ExecutionFailed { reason: String }, +} + +// Generate UniFFI scaffolding from proc macros +uniffi::setup_scaffolding!(); + +// Conversion from mobench-sdk types +impl From for BenchSpec { + fn from(spec: mobench_sdk::BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for mobench_sdk::BenchSpec { + fn from(spec: BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for BenchSample { + fn from(sample: mobench_sdk::BenchSample) -> Self { + Self { + duration_ns: sample.duration_ns, + } + } +} + +impl From for BenchReport { + fn from(report: mobench_sdk::RunnerReport) -> Self { + Self { + spec: report.spec.into(), + samples: report.samples.into_iter().map(Into::into).collect(), + } + } +} + +impl From for BenchError { + fn from(err: mobench_sdk::BenchError) -> Self { + match err { + mobench_sdk::BenchError::Runner(runner_err) => BenchError::ExecutionFailed { + reason: runner_err.to_string(), + }, + mobench_sdk::BenchError::UnknownFunction(name) => BenchError::UnknownFunction { name }, + _ => BenchError::ExecutionFailed { + reason: err.to_string(), + }, + } + } +} + +/// Run a benchmark by name with the given specification. +/// +/// This is the main FFI entry point called from mobile platforms. +#[uniffi::export] +pub fn run_benchmark(spec: BenchSpec) -> Result { + let sdk_spec: mobench_sdk::BenchSpec = spec.into(); + let report = mobench_sdk::run_benchmark(sdk_spec)?; + Ok(report.into()) +} + +/// Compute fibonacci number iteratively. +pub fn fibonacci(n: u32) -> u64 { + match n { + 0 => 0, + 1 => 1, + _ => { + let mut a = 0u64; + let mut b = 1u64; + for _ in 2..=n { + let next = a.wrapping_add(b); + a = b; + b = next; + } + b + } + } +} + +/// Compute fibonacci in a more measurable way by doing it multiple times. +pub fn fibonacci_batch(n: u32, iterations: u32) -> u64 { + let mut result = 0u64; + for _ in 0..iterations { + result = result.wrapping_add(fibonacci(n)); + } + result +} + +/// Compute checksum by summing all bytes. +pub fn checksum(bytes: &[u8]) -> u64 { + bytes.iter().map(|&b| b as u64).sum() +} + +// ============================================================================ +// Benchmark Functions +// ============================================================================ +// These functions are marked with #[benchmark] and automatically registered +// with mobench-sdk's registry system. + +/// Benchmark: Fibonacci calculation (30th number, 1000 iterations) +#[benchmark] +pub fn bench_fibonacci() { + let result = fibonacci_batch(30, 1000); + std::hint::black_box(result); +} + +/// Benchmark: Checksum calculation on 1KB data (10000 iterations) +#[benchmark] +pub fn bench_checksum() { + let mut sum = 0u64; + for _ in 0..10000 { + sum = sum.wrapping_add(checksum(&CHECKSUM_INPUT)); + } + std::hint::black_box(sum); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fib_sequence() { + assert_eq!(fibonacci(0), 0); + assert_eq!(fibonacci(1), 1); + assert_eq!(fibonacci(10), 55); + assert_eq!(fibonacci(24), 46368); + } + + #[test] + fn checksum_matches() { + assert_eq!(checksum(&CHECKSUM_INPUT), 1024); + } + + #[test] + fn test_run_benchmark_via_registry() { + // Test that benchmarks can be discovered via the registry + let benchmarks = mobench_sdk::discover_benchmarks(); + assert!(benchmarks.len() >= 2, "Should find at least 2 benchmarks"); + + // Test execution via FFI using registry name + let spec = BenchSpec { + name: "ffi_benchmark::bench_fibonacci".to_string(), + iterations: 3, + warmup: 1, + }; + let report = run_benchmark(spec).unwrap(); + assert_eq!(report.samples.len(), 3); + } + + #[test] + fn test_run_benchmark_checksum() { + let spec = BenchSpec { + name: "ffi_benchmark::bench_checksum".to_string(), + iterations: 2, + warmup: 0, + }; + let report = run_benchmark(spec).unwrap(); + assert_eq!(report.samples.len(), 2); + } + + #[test] + fn test_unknown_function_error() { + let spec = BenchSpec { + name: "unknown".to_string(), + iterations: 1, + warmup: 0, + }; + let result = run_benchmark(spec); + assert!(matches!(result, Err(BenchError::UnknownFunction { .. }))); + } + + #[test] + fn test_invalid_iterations() { + let spec = BenchSpec { + name: "ffi_benchmark::bench_fibonacci".to_string(), + iterations: 0, + warmup: 0, + }; + let result = run_benchmark(spec); + assert!(matches!(result, Err(BenchError::ExecutionFailed { .. }))); + } +} diff --git a/scripts/bindgen.rs b/scripts/bindgen.rs deleted file mode 100644 index bb721d9..0000000 --- a/scripts/bindgen.rs +++ /dev/null @@ -1,56 +0,0 @@ -// Standalone script to generate UniFFI bindings -// Usage: cargo script scripts/bindgen.rs - -use std::env; -use std::path::PathBuf; - -fn main() -> Result<(), Box> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let root_dir = manifest_dir.parent().unwrap(); - let lib_path = root_dir.join("target/debug/libsample_fns.dylib"); - - if !lib_path.exists() { - let lib_path_so = root_dir.join("target/debug/libsample_fns.so"); - if lib_path_so.exists() { - generate_bindings(&lib_path_so, root_dir)?; - } else { - eprintln!("Error: Library not found. Run 'cargo build -p sample-fns' first"); - std::process::exit(1); - } - } else { - generate_bindings(&lib_path, root_dir)?; - } - - Ok(()) -} - -fn generate_bindings(lib_path: &Path, root_dir: &Path) -> Result<(), Box> { - use uniffi_bindgen::{bindings, library_mode}; - - // Generate Kotlin bindings - let kotlin_out = root_dir.join("android/app/src/main/java"); - library_mode::generate_bindings( - lib_path, - None, - &bindings::TargetLanguage::Kotlin, - &kotlin_out, - false, - )?; - - println!("✓ Kotlin bindings generated: {:?}", kotlin_out); - - // Generate Swift bindings - let swift_out = root_dir.join("ios/BenchRunner/BenchRunner/Generated"); - std::fs::create_dir_all(&swift_out)?; - library_mode::generate_bindings( - lib_path, - None, - &bindings::TargetLanguage::Swift, - &swift_out, - false, - )?; - - println!("✓ Swift bindings generated: {:?}", swift_out); - - Ok(()) -} diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh deleted file mode 100755 index 4b8100d..0000000 --- a/scripts/build-android-app.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ⚠️ DEPRECATION WARNING ⚠️ -# This script is legacy tooling for developing this repository. -# -# For SDK integrators, use instead: -# cargo run -p bench-cli -- build --target android -# -# This command does everything this script does, but in pure Rust with no dependencies -# on having this repo's scripts/ directory locally. - -# Convenience wrapper: build Rust libs for all Android ABIs, sync them into the app, -# then assemble the Android APK. -# -# Requires: -# - cargo-ndk installed -# - Android SDK/Gradle available - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -# Resolve ANDROID_NDK_HOME if not provided. -if [[ -z "${ANDROID_NDK_HOME:-}" ]]; then - DEFAULT_NDK="${HOME}/Library/Android/sdk/ndk/29.0.14206865" - if [[ -d "${DEFAULT_NDK}" ]]; then - export ANDROID_NDK_HOME="${DEFAULT_NDK}" - echo "ANDROID_NDK_HOME not set; defaulting to ${ANDROID_NDK_HOME}" - else - echo "ANDROID_NDK_HOME is not set and default NDK path not found; please export it before running." >&2 - exit 1 - fi -fi - -pushd "${ROOT_DIR}" >/dev/null -./scripts/build-android.sh -ABI="${UNIFFI_ANDROID_ABI:-arm64-v8a}" -case "${ABI}" in - arm64-v8a) - LIB_PATH="${ROOT_DIR}/target/android/aarch64-linux-android/arm64-v8a/libsample_fns.so" - ;; - x86_64) - LIB_PATH="${ROOT_DIR}/target/android/x86_64-linux-android/x86_64/libsample_fns.so" - ;; - armeabi-v7a) - LIB_PATH="${ROOT_DIR}/target/android/armv7-linux-androideabi/armeabi-v7a/libsample_fns.so" - ;; - *) - echo "Unknown UNIFFI_ANDROID_ABI=${ABI}; expected arm64-v8a, x86_64, or armeabi-v7a" >&2 - exit 1 - ;; -esac -UNIFFI_LIBRARY_PATH="${LIB_PATH}" ./scripts/generate-bindings.sh -./scripts/sync-android-libs.sh -popd >/dev/null - -pushd "${ROOT_DIR}/android" >/dev/null -./gradlew :app:assembleDebug -popd >/dev/null - -echo "Android build complete." diff --git a/scripts/build-android.sh b/scripts/build-android.sh deleted file mode 100755 index b00bbeb..0000000 --- a/scripts/build-android.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ⚠️ DEPRECATION WARNING ⚠️ -# This script is legacy tooling for developing this repository. -# -# For SDK integrators, use instead: -# cargo run -p bench-cli -- build --target android -# -# The CLI command handles all build steps automatically. - -# Build Rust shared libraries for Android targets using cargo-ndk. -# -# NOTE: If you modify the Rust API (sample_fns.udl), run: -# cargo run --bin generate-bindings --features bindgen -# before running this script to regenerate Kotlin bindings. -# -# Prereqs (install manually in CI/local before running): -# - Android NDK and toolchains available on PATH -# - cargo-ndk installed (`cargo install cargo-ndk`) -# -# By default builds sample-fns as a cdylib, producing libsample_fns.so per ABI. - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -CRATES=(${CRATES:-sample-fns}) -# Add x86_64 for emulator support; keep ARM for devices. -TARGET_ABIS=("aarch64-linux-android" "armv7-linux-androideabi" "x86_64-linux-android") -API_LEVEL=24 - -for CRATE in "${CRATES[@]}"; do - echo "Building Rust library for Android (crates/${CRATE})" - for ABI in "${TARGET_ABIS[@]}"; do - echo " -> ${ABI}" - cargo ndk \ - -t "${ABI}" \ - -o "${ROOT_DIR}/target/android/${ABI}" \ - --platform "${API_LEVEL}" \ - build -p "${CRATE}" --release - done -done - -echo "Finished. Outputs are under target/android//release." diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh deleted file mode 100755 index 1fb3fcb..0000000 --- a/scripts/build-ios.sh +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ⚠️ DEPRECATION WARNING ⚠️ -# This script is legacy tooling for developing this repository. -# -# For SDK integrators, use instead: -# cargo run -p bench-cli -- build --target ios -# -# The CLI command handles all build steps automatically including xcframework -# creation, binding generation, and code signing. - -# Build the Rust library for iOS targets and package as xcframework. -# UniFFI-generated headers (sample_fnsFFI.h) are used for the C ABI. -# -# NOTE: If you modify the Rust API (sample_fns.udl), run: -# cargo run --bin generate-bindings --features bindgen -# before running this script to regenerate Swift bindings and headers. -# -# Prereqs (install manually in CI/local before running): -# - Xcode command line tools -# - rustup targets: aarch64-apple-ios, aarch64-apple-ios-sim - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CRATE="sample-fns" -OUTPUT_DIR="${ROOT_DIR}/target/ios" -XCFRAMEWORK_PATH="${OUTPUT_DIR}/sample_fns.xcframework" - -# iOS targets to build -IOS_TARGETS=( - "aarch64-apple-ios" # iOS device (ARM64) - "aarch64-apple-ios-sim" # iOS simulator (ARM64, M1+ Macs) -) - -# Check for required iOS targets -for target in "${IOS_TARGETS[@]}"; do - if ! rustup target list --installed | grep -q "^${target}$"; then - echo "Installing Rust target: ${target}" - rustup target add "${target}" - fi -done - -echo "Building Rust libraries for iOS targets" -for target in "${IOS_TARGETS[@]}"; do - echo " -> Building for ${target}" - cargo build --release --target "${target}" -p "${CRATE}" -done - -echo "Creating xcframework structure" -rm -rf "${XCFRAMEWORK_PATH}" -mkdir -p "${XCFRAMEWORK_PATH}" - -# Create framework for each target -for target in "${IOS_TARGETS[@]}"; do - # Static library name: lib.a (crate name with underscores) - LIB_NAME="libsample_fns.a" - LIB_PATH="${ROOT_DIR}/target/${target}/release/${LIB_NAME}" - - if [[ ! -f "${LIB_PATH}" ]]; then - echo "Error: ${LIB_PATH} not found after build" >&2 - exit 1 - fi - - # Determine platform and architecture - case "${target}" in - aarch64-apple-ios) - PLATFORM="iPhoneOS" - XCFRAMEWORK_PLATFORM="ios" - ARCH="arm64" - FRAMEWORK_NAME="ios-arm64" - ;; - aarch64-apple-ios-sim) - PLATFORM="iPhoneSimulator" - XCFRAMEWORK_PLATFORM="ios-simulator" - ARCH="arm64" - FRAMEWORK_NAME="ios-simulator-arm64" - ;; - *) - echo "Unknown target: ${target}" >&2 - exit 1 - ;; - esac - - FRAMEWORK_DIR="${XCFRAMEWORK_PATH}/${FRAMEWORK_NAME}/sample_fns.framework" - mkdir -p "${FRAMEWORK_DIR}/Headers" - - # Copy library (framework binary should match module name) - cp "${LIB_PATH}" "${FRAMEWORK_DIR}/sample_fns" - - # Copy UniFFI-generated C header - UNIFFI_HEADER="${ROOT_DIR}/ios/BenchRunner/BenchRunner/Generated/sample_fnsFFI.h" - if [[ ! -f "${UNIFFI_HEADER}" ]]; then - echo "Error: UniFFI header not found at ${UNIFFI_HEADER}" >&2 - echo "Run: cargo run --bin generate-bindings --features bindgen" >&2 - exit 1 - fi - cp "${UNIFFI_HEADER}" "${FRAMEWORK_DIR}/Headers/" - - # Create Info.plist for this framework slice - cat > "${FRAMEWORK_DIR}/Info.plist" < - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - sample_fns - CFBundleIdentifier - dev.world.sample-fns - CFBundleInfoDictionaryVersion - 6.0 - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - MinimumOSVersion - 13.0 - CFBundleSupportedPlatforms - - ${PLATFORM} - - - -EOF - - # Create module map for UniFFI C bindings - cat > "${FRAMEWORK_DIR}/Headers/module.modulemap" < "${XCFRAMEWORK_PATH}/Info.plist" < - - - - AvailableLibraries - - - LibraryIdentifier - ios-arm64 - LibraryPath - sample_fns.framework - SupportedArchitectures - - arm64 - - SupportedPlatform - ios - SupportedPlatformVariant - - - - LibraryIdentifier - ios-simulator-arm64 - LibraryPath - sample_fns.framework - SupportedArchitectures - - arm64 - - SupportedPlatform - ios - SupportedPlatformVariant - simulator - - - CFBundlePackageType - XFWK - XCFrameworkFormatVersion - 1.0 - - -EOF - -echo "✓ iOS build complete. XCFramework created at: ${XCFRAMEWORK_PATH}" - -# Copy public header for CLI consumers (matches bench-cli expectation) -INCLUDE_DIR="${OUTPUT_DIR}/include" -mkdir -p "${INCLUDE_DIR}" -if [[ -f "${UNIFFI_HEADER}" ]]; then - cp "${UNIFFI_HEADER}" "${INCLUDE_DIR}/sample_fns.h" -else - echo "Error: UniFFI header still missing at ${UNIFFI_HEADER}" >&2 - exit 1 -fi - -# Code-sign the xcframework (required for Xcode) -echo "Signing xcframework..." -codesign --force --deep --sign - "${XCFRAMEWORK_PATH}" 2>/dev/null || { - echo "⚠️ Warning: Failed to sign xcframework. You may need to sign manually:" - echo " codesign --force --deep --sign - ${XCFRAMEWORK_PATH}" -} - -echo "✓ Build and signing complete" diff --git a/scripts/generate-bindings.sh b/scripts/generate-bindings.sh deleted file mode 100755 index 94ee5a3..0000000 --- a/scripts/generate-bindings.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ⚠️ DEPRECATION WARNING ⚠️ -# This script is legacy tooling for developing this repository. -# -# For SDK integrators, bindings are automatically generated during: -# cargo run -p bench-cli -- build --target -# -# You don't need to call this script separately. - -# Generate Kotlin and Swift bindings using UniFFI - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CRATE_DIR="${ROOT_DIR}/crates/sample-fns" - -if [[ -n "${UNIFFI_LIBRARY_PATH:-}" ]]; then - echo "Using UNIFFI_LIBRARY_PATH=${UNIFFI_LIBRARY_PATH}" -else - echo "Building sample-fns (release)..." - cargo build -p sample-fns --release - export UNIFFI_PROFILE=release -fi - -echo "Generating Kotlin + Swift bindings via sample-fns helper..." -cargo run -p sample-fns --bin generate-bindings --features bindgen diff --git a/scripts/sync-android-libs.sh b/scripts/sync-android-libs.sh deleted file mode 100755 index 4079b1f..0000000 --- a/scripts/sync-android-libs.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ⚠️ DEPRECATION WARNING ⚠️ -# This script is legacy tooling for developing this repository. -# -# For SDK integrators, use instead: -# cargo run -p bench-cli -- build --target android -# -# The CLI command automatically handles library copying. - -# Copy built Rust .so files into the Android app's jniLibs structure. -# Run scripts/build-android.sh first. - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -APP_JNILIBS="${ROOT_DIR}/android/app/src/main/jniLibs" -LIB_NAME="${LIB_NAME:-sample_fns}" -TARGET_LIB_NAME="${TARGET_LIB_NAME:-sample_fns}" - -declare -A ABI_MAP=( - ["aarch64-linux-android"]="arm64-v8a" - ["armv7-linux-androideabi"]="armeabi-v7a" - ["x86_64-linux-android"]="x86_64" -) - -for TRIPLE in "${!ABI_MAP[@]}"; do - # Cargo NDK may place outputs under /release or directly under the ABI folder. - SRC="${ROOT_DIR}/target/android/${TRIPLE}/release/lib${LIB_NAME}.so" - if [[ ! -f "${SRC}" ]]; then - ALT="${ROOT_DIR}/target/android/${TRIPLE}/${ABI_MAP[$TRIPLE]}/lib${LIB_NAME}.so" - if [[ -f "${ALT}" ]]; then - SRC="${ALT}" - fi - fi - DEST_DIR="${APP_JNILIBS}/${ABI_MAP[$TRIPLE]}" - DEST="${DEST_DIR}/lib${TARGET_LIB_NAME}.so" - if [[ ! -f "${SRC}" ]]; then - echo "Missing ${SRC}; build first with scripts/build-android.sh" >&2 - exit 1 - fi - mkdir -p "${DEST_DIR}" - cp "${SRC}" "${DEST}" - # Keep a compat copy for older loaders that expect uniffi_ prefix. - if [[ "${TARGET_LIB_NAME}" == "sample_fns" ]]; then - cp "${SRC}" "${DEST_DIR}/libuniffi_sample_fns.so" - fi - echo "Copied ${SRC} -> ${DEST}" -done - -echo "JNI libs synced." From b81d3f7439cea943e787e32382529a2fafb9bc2a Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 21 Jan 2026 14:23:12 -0300 Subject: [PATCH 059/196] DX Improvements v0.1.13: Setup/teardown, new commands, better errors (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor mobench layout and refresh README - split CLI entrypoint into library and cargo-mobench bin - update workspace manifests and lockfile for crate changes - rewrite README with current workflow and crate list * Remove legacy bench-cli crates and update docs Drop the unused bench-cli and bench-runner crates, update docs to reflect the current mobench-runner workflow, and adjust legacy scripts/comments to reference cargo mobench. * Split examples into minimal and FFI variants Simplify basic-benchmark to only show #[benchmark] usage and add a new ffi-benchmark example that contains the UniFFI types and entrypoint. Update workspace metadata and docs to reflect the new example layout. * Remove legacy sample_fns UDL Drop the unused UniFFI UDL file for sample-fns and update build script notes to reflect the proc-macro binding generation flow. * Remove legacy scripts and update docs Delete deprecated build scripts, switch CI and docs to cargo mobench commands, and refresh integration guidance for the new flow. * Refresh docs for mobench build flow Update CLAUDE, BUILD, and mobench-sdk README to remove script references, clarify the builder flow, and document the new examples. * Improve repo root detection with ancestor search The repo_root() function now walks up the directory tree looking for marker files instead of relying on the compile-time manifest path. This fixes cases where the CLI is run from subdirectories. Co-Authored-By: Claude Opus 4.5 * Bump version to 0.1.7 for release Update workspace version and inter-crate dependencies. Co-Authored-By: Claude Opus 4.5 * Add commit guidelines to CLAUDE.md * Output mobile artifacts to target/mobench/ by default - Add output_dir field to AndroidBuilder and IosBuilder - Default to {project_root}/target/mobench/ for all mobile artifacts - Add output_dir() builder method for customization - Add --output-dir CLI argument to `cargo mobench build` - Update all path references in builders to use output_dir This keeps generated mobile artifacts inside target/, following Rust conventions and preventing accidental commits of Android/iOS project files. * Update docs for target/mobench/ output directory - Update all path references from target/ios/ to target/mobench/ios/ - Update Android paths to target/mobench/android/ - Document --output-dir CLI flag - Update troubleshooting sections with correct paths * Add comprehensive docs.rs documentation - Add extensive crate-level documentation to all four crates: - mobench-sdk: Full SDK docs with examples, architecture, and best practices - mobench-macros: #[benchmark] macro documentation with usage examples - mobench-runner: Timing harness docs with runnable examples - mobench: CLI documentation with command reference - Document all public types with examples: - BenchSpec, BenchSample, BenchReport, BenchError - Target, BuildConfig, BuildProfile, BuildResult - InitConfig and builder types - Configure docs.rs metadata in all Cargo.toml files: - Add documentation URLs - Add maintenance badges - Configure rustdoc-args for docs.rs builds - Add serde_json dev-dependency to mobench-runner for doc tests * Bump version to 0.1.8 for release * Implement all IMPROVEMENTS.md tasks with enhanced DX This commit implements all 11 developer experience improvements from IMPROVEMENTS.md, making mobench significantly more user-friendly. ## Key Changes ### P0 - Critical (Tasks 1-3) - Fix aws-lc-rs Android NDK incompatibility (ring backend) - Fix workspace target directory detection (cargo metadata) - Auto-generate project scaffolding during build ### P1 - High Priority (Tasks 4-6) - Process template variables during build (TemplateContext) - Improve uniffi-bindgen handling (local binary + fallback) - Generate error handling code dynamically (generic catch) ### P2 - Medium Priority (Tasks 7-9) - Add mobench.toml configuration file support - Improve error messages with actionable suggestions - Add --crate-path flag for custom crate locations ### P3 - Nice to Have (Tasks 10-11) - Add --dry-run and --verbose CLI modes - Auto-generate local.properties for Android ## Code Quality Improvements - Extract shared utilities to builders/common.rs (DRY) - Consistent error message formatting with: - Clear description of what failed - Context (commands, paths, exit status) - Actionable fix suggestions - Links to documentation - Standardized path formatting using display() ## New Files - crates/mobench/src/config.rs - Configuration file support - crates/mobench-sdk/src/builders/common.rs - Shared utilities - IMPROVEMENTS.md - Task tracking document ## Consolidation - Merged mobench-runner crate into mobench-sdk - Timing functionality now in mobench-sdk/src/timing.rs * Fix iOS test failure in auto-build scaffolding The test `ios_requires_artifacts_for_browserstack` was failing because iOS project scaffolding was not being generated correctly during auto-build for BrowserStack. Root causes and fixes: 1. render_dir() was incorrectly joining prefix with file.path(), but file.path() from include_dir already returns the full relative path. Removed the prefix parameter entirely. 2. project.yml lacked .template extension, so template variables like {{PROJECT_NAME_PASCAL}} were not being substituted. Renamed to project.yml.template. 3. xcframework path in project.yml.template was wrong (../../target/ios/). Fixed to ../{{LIBRARY_NAME}}.xcframework since the iOS project is at target/mobench/ios/BenchRunner/. 4. ensure_ios_project now uses fixed "BenchRunner" for PROJECT_NAME_PASCAL to match the template directory structure, while deriving LIBRARY_NAME from the actual crate name. All 37 tests now pass. * Update documentation for consolidated crates and new features README updates: - Document mobench-runner consolidation into mobench-sdk - Add mobench.toml configuration file documentation - Document new CLI flags: --dry-run, --verbose, --crate-path - Update all artifact paths to target/mobench/ - Add examples for new features Rust doc comments: - Add comprehensive module docs to builders/{mod,android,ios,common}.rs - Document build pipelines, requirements, and examples - Add dry-run mode documentation - Note mobench-runner consolidation in timing.rs docs.rs configuration: - Add targets = ["x86_64-unknown-linux-gnu"] for docs.rs builds - Add #![cfg_attr(docsrs, feature(doc_cfg))] attributes - Add crates.io/docs.rs/license badges to crate roots - Add #[doc(cfg(...))] annotations for feature-gated items * Bump version to 0.1.9 for release - Update workspace version from 0.1.8 to 0.1.9 - Update mobench-sdk dependency in mobench crate - Update mobench-macros dependency in mobench-sdk crate - All mobench-runner versions have been yanked on crates.io * Remove IMPROVEMENTS.md after completing all tasks * Fix all DX issues from mobench 0.1.9 testing Critical bugs fixed (12): - Bugs 1-5: Template variables (PROJECT_NAME, PACKAGE_NAME, LIBRARY_NAME, PROJECT_NAME_PASCAL, APP_NAME) now properly substituted - Bug 6: Added gradle.properties template with AndroidX settings - Bug 7: Auto-generate Gradle wrapper if missing - Bug 8: Fixed AGP version from 8.13.2 to 8.2.2 - Bug 9: Package name in Kotlin templates now uses {{PACKAGE_NAME}} - Bug 10: Added x86_64-apple-ios target for Intel Mac simulators - Bug 11: Fixed path handling by canonicalizing project root - Bug 12: DEFAULT_FUNCTION now auto-detected from #[benchmark] functions High severity issues fixed (8): - Issue 1: Added post-template validation for unreplaced {{...}} patterns - Issue 2: codesign_xcframework now returns Err on failure - Issue 3: generate_xcode_project now returns Err on failure - Issue 4: Missing native libraries always warn (not just verbose) - Issue 5: Added validate_project_root() for early validation - Issue 6: cargo metadata fallback now warns when used - Issue 7: Kotlin catch block logs exceptions instead of swallowing - Issue 8: Added validate_build_artifacts() post-build validation Medium/Low issues fixed (4): - Added .gitignore templates for Android and iOS - Added README.md templates for Android and iOS New files: - templates/android/.gitignore - templates/android/README.md - templates/android/gradle.properties - templates/ios/BenchRunner/.gitignore - templates/ios/BenchRunner/README.md * Update documentation for v0.1.9 and fix inconsistencies - Fix all path references to use target/mobench/ as default output dir - Update CLAUDE.md version reference to v0.1.9 - Fix PROJECT_PLAN.md to reflect mobench-runner consolidation into mobench-sdk - Remove dead links to BROWSERSTACK_RUN_2.md (use BROWSERSTACK_METRICS.md) - Fix all init-sdk references to use correct init command - Fix duplicate command and step numbering in TESTING.md - Update iOS artifact paths in BENCH_SDK_INTEGRATION.md * Bump version to 0.1.10 for release * Fix CI workflow paths to use target/mobench/ output directory * Fix DX issues from 0.1.10 report Android fixes: - Add APK detection for both signed and unsigned builds - Parse output-metadata.json for actual APK filename - Add proguard-rules.pro template for release builds - Update .gitignore to exclude jniLibs and uniffi bindings iOS fixes: - Sanitize bundle identifiers (remove hyphens/underscores) - Framework bundle IDs now use alphanumeric chars only - Add logging for config loading failures - Update .gitignore to exclude xcodeproj, frameworks, bindings Config fixes: - Add logging for missing/invalid JSON keys in templates - Android MainActivity logs warnings for missing config values - iOS BenchRunnerFFI prints errors when JSON parsing fails * Bump version to 0.1.11 for release * Update CLAUDE.md version reference to 0.1.11 * Fix DX issues from 0.1.11 report P0 fixes: - Add testBuildType "release" to build.gradle templates - Gradle now creates assembleReleaseAndroidTest task P1 fixes: - local.properties only uses ANDROID_HOME/ANDROID_SDK_ROOT env vars - No longer probes filesystem for hardcoded SDK paths - Kotlin files moved to correct package directory structure P2 fixes: - iOS bundle ID now uses app name: dev.world.benchmobile.BenchRunner - No longer duplicates crate name in bundle ID * Fix all DX issues from bug reports Crate detection (P0): - Check current directory Cargo.toml before nested paths - Add read_package_name() helper for parsing Cargo.toml - Search order: current dir → bench-mobile/ → crates/{name}/ → {name}/ - Add 10 new tests for crate detection logic Error handling improvements: - Add Log.e() calls for Android exception handlers - Add print() statements for iOS config parsing failures - Log config sources (intent, bench_spec.json, default) - Show warnings when using fallback defaults Cross-platform consistency: - Standardize Android package names (remove underscores like iOS) - Both platforms now use dev.world.benchmobile format - Standardize version strings to 1.0.0 on both platforms - Add 3 new consistency tests Tests added: - test_find_crate_dir_current_directory_is_crate - test_find_crate_dir_nested_bench_mobile - test_find_crate_dir_crates_subdir - test_find_crate_dir_not_found - test_find_crate_dir_explicit_crate_path - test_read_package_name_standard - test_read_package_name_with_single_quotes - test_read_package_name_not_found - test_read_package_name_no_package_section - test_cross_platform_naming_consistency - test_cross_platform_version_consistency - test_bundle_id_prefix_consistency * Add --release flag and package-xcuitest command - Add --release flag to mobench run command for release builds (reduces APK size from ~544MB debug to ~133MB release) - Add mobench package-xcuitest command for iOS BrowserStack testing - Add output_dir option to package-ipa command - Update timing display format from μs to ms (or seconds if >=1000ms) - Auto-package iOS artifacts in release mode when --release is set * Update mobile timing display and documentation - Update Android/iOS apps to display timing in ms (or seconds if >=1000ms) - Update CLAUDE.md, TESTING.md, README with --release flag examples - Document package-xcuitest command across all relevant docs - Add release build recommendations for BrowserStack workflows * Fix iOS XCUITest benchmark report gap and add video capture delay iOS XCUITest was exiting in ~2 seconds without capturing benchmark data, while Android properly waited and extracted full timing reports. Changes: - XCUITest now waits up to 5 minutes for benchmark completion (not 2 sec) - Extract benchmark JSON via accessibility identifiers - Output JSON with BENCH_REPORT_JSON_START/END markers for log parsing - Add iOS-specific JSON extraction in fetch logic (handles NSLog prefixes) - Both Android and iOS now hold the report screen for 5 seconds so BrowserStack video recordings capture the benchmark results Files updated: - iOS XCUITest template and reference implementation - iOS ContentView with completion indicators and JSON exposure - iOS BenchRunnerFFI with JSON report generation - BrowserStack fetch logic for iOS log parsing - Android MainActivity with 5-second display hold * Sync top-level templates with SDK templates * Update documentation for --release flag and iOS XCUITest features - Add --release flag to BrowserStack examples in README.md - Update mobench-sdk README and rustdocs with --release recommendation - Update mobench CLI rustdocs with --release flag - Expand template READMEs with BrowserStack workflow and video capture info - Document benchmark report capture mechanism (5-second delay, JSON markers) * Fix iOS XCUITest BrowserStack detection and video capture - Add Info.plist to XCUITest bundle to fix BrowserStack test detection BrowserStack requires Info.plist with XCTContainsUITests=true to identify and run tests; without it, builds are skipped with "no tests detected" - Add info: section to UITests target in project.yml templates with proper bundle identifier (*.uitests suffix) - Increase XCUITest delay from 0.5s to 5s after benchmark completion to ensure BrowserStack video captures the results - Update initial display text from "Running benchmark..." to "Running benchmarks..." for better UX during video recording * Remove resolved issue tracking docs * Remove stale DX report doc * Bump version to 0.1.12 for release Changes in this release: - Fix iOS XCUITest BrowserStack detection (Info.plist added to UITests target) - Improve video capture for BrowserStack (5s delay for video evidence) - Show "Running benchmarks..." text before results appear - Sync top-level templates with SDK templates - Various DX fixes * Fix error message hints * Fix iOS XCUITest test name mismatch with BrowserStack Changed only-testing filter from testLaunchShowsBenchmarkReport to testLaunchAndCaptureBenchmarkReport to match what BrowserStack parses from the xctest bundle. * Bump version to 0.1.13 for release Fix iOS XCUITest test name mismatch with BrowserStack - changed only-testing filter to use testLaunchAndCaptureBenchmarkReport. * Add DX improvements: verify, summary, devices commands and SDK enhancements CLI improvements: - Add `mobench verify` command to validate registry, spec, and artifacts - Add `mobench summary` command to display benchmark result statistics - Add `mobench devices` command to list available BrowserStack devices - Add benchmark function validation before runs with helpful error messages - Add resolved spec and artifact location output at run start - Add run completion summary with build IDs and artifact paths BrowserStack improvements: - Add device listing APIs for Espresso and XCUITest - Add device validation with fuzzy matching and suggestions - Add helpful credential error messages with setup instructions - Add artifact correlation output (build ID, device, local paths) SDK improvements: - Add bench_meta.json with spec, commit hash, build time, and profile - Add embed_bench_spec() for bundling spec into app assets - Add UniFFI type templates for easier FFI boundary setup - Add git info helpers (commit, branch, dirty state detection) * Add comprehensive DX improvements: check command, macro validation, better errors SDK Improvements: - Add compile-time validation to #[benchmark] macro (no params, returns ()) - Add debug_benchmarks!() macro for verifying registration - Enhance UnknownFunction error to show available benchmarks - Enhance TimingError::NoIterations to show the actual value - Add Quick Setup Checklist to lib.rs documentation CLI Improvements: - Add `cargo mobench check` command for prerequisite validation - Add --progress flag for simplified build/run output - Add build progress feedback with status messages - Print clear results summary at end of runs BrowserStack Improvements: - Improve credential error messages with setup instructions - Add artifact pre-flight validation before uploads - Add upload progress indication with file sizes - Print dashboard link immediately after build starts - Improve device fuzzy matching with OS version suggestions * Update all documentation for DX improvements v0.1.14 README.md: - Add new commands to quick start (check, verify, summary, devices) - Add --progress flag example - Add v0.1.14 release notes with all new features CLAUDE.md: - Update version to v0.1.14 - Add check command as first build step - Document --progress flag and verify command - Add debug_benchmarks!() macro documentation - Document improved BrowserStack error messages BUILD.md: - Add new Prerequisites Check section - Document cargo mobench check command with examples - Add --progress flag to build commands TESTING.md: - Add prerequisite validation section - Document verify command in testing workflow - Add BrowserStack device validation section - Add summary command for viewing results BENCH_SDK_INTEGRATION.md: - Add Quick Setup Checklist section - Document #[benchmark] macro validation requirements - Add debug_benchmarks!() macro usage guide - Add verification and troubleshooting section FETCH_RESULTS_GUIDE.md: - Add summary command documentation - Add result analysis examples with different formats - Update best practices BROWSERSTACK_CI_INTEGRATION.md: - Add pre-flight validation section - Document devices command with fuzzy matching - Update GitHub Actions example with validation steps - Expand troubleshooting with credential error messages PROJECT_PLAN.md: - Add completed DX improvements section - Update roadmap with remaining P2 items * Add setup/teardown support to #[benchmark] macro This enables expensive setup to be excluded from benchmark timing: API: - #[benchmark] - Simple benchmark (unchanged) - #[benchmark(setup = fn)] - Setup runs once before iterations - #[benchmark(setup = fn, per_iteration)] - Setup runs before each iteration - #[benchmark(setup = fn, teardown = fn)] - Setup + cleanup Implementation: - timing.rs: Add run_closure_with_setup(), run_closure_with_setup_per_iter(), run_closure_with_setup_teardown() functions - registry.rs: Change BenchFunction.invoke to BenchFunction.runner for direct timing control - mobench-macros: Parse setup/teardown/per_iteration attributes and generate appropriate runner code - runner.rs: Simplify to delegate to registry runner Example: ```rust fn setup_proof() -> ProofInput { generate_complex_proof() // Not measured } #[benchmark(setup = setup_proof)] fn verify_proof(input: &ProofInput) { verify(&input.proof); // Only this is measured } ``` Tests: 135 passing * Document setup/teardown feature in README, CLAUDE.md, and integration guide README.md: - Add "Setup and Teardown" section with problem/solution examples - Add v0.1.15 release notes for setup/teardown feature CLAUDE.md: - Update version to v0.1.15 - Expand SDK Integration Pattern with setup/teardown examples - Update macro validation notes BENCH_SDK_INTEGRATION.md: - Add comprehensive "Setup and Teardown" section - Cover one-time setup, per-iteration, and teardown patterns - Add pattern selection guide table * Update version references to 0.1.13 and consolidate release notes - Updated CLAUDE.md version from v0.1.15 to v0.1.13 - Updated feature version markers (v0.1.14+, v0.1.15+) to v0.1.13+ - Consolidated README release notes into single v0.1.13 section - Fixed documentation for setup/teardown feature - Published mobench-macros, mobench-sdk, mobench v0.1.13 to crates.io * Fix documentation issues in BENCH_SDK_INTEGRATION.md - Remove redundant gradle steps (cargo mobench build handles test APK) - Add devices command to quick setup checklist for BrowserStack users * Add device selection guide with tier examples for BrowserStack - Added section 9 with Android and iOS device tiers (6 tiers each) - Flagship to Lowest performance tiers with real BrowserStack device specs - Multi-device benchmarking examples - Device validation commands - Renumbered section 10 (Verification and Troubleshooting) --------- Co-authored-by: dcbuilder.eth Co-authored-by: Claude Opus 4.5 --- BENCH_SDK_INTEGRATION.md | 453 ++++- BROWSERSTACK_CI_INTEGRATION.md | 186 +- BUILD.md | 62 + CLAUDE.md | 122 +- FETCH_RESULTS_GUIDE.md | 84 +- PROJECT_PLAN.md | 24 + README.md | 115 +- TESTING.md | 100 +- crates/mobench-macros/README.md | 57 +- crates/mobench-macros/src/lib.rs | 430 +++-- crates/mobench-sdk/src/builders/common.rs | 403 +++++ crates/mobench-sdk/src/builders/mod.rs | 3 +- crates/mobench-sdk/src/codegen.rs | 91 +- crates/mobench-sdk/src/lib.rs | 113 ++ crates/mobench-sdk/src/registry.rs | 12 +- crates/mobench-sdk/src/runner.rs | 26 +- crates/mobench-sdk/src/timing.rs | 289 ++- crates/mobench-sdk/src/types.rs | 8 +- crates/mobench-sdk/src/uniffi_types.rs | 349 ++++ crates/mobench/src/browserstack.rs | 590 ++++++- crates/mobench/src/lib.rs | 1962 ++++++++++++++++++++- crates/sample-fns/src/lib.rs | 2 +- docs/SETUP_TEARDOWN_DESIGN.md | 497 ++++++ examples/ffi-benchmark/src/lib.rs | 4 +- mobench-dx-spec.md | 156 ++ 25 files changed, 5905 insertions(+), 233 deletions(-) create mode 100644 crates/mobench-sdk/src/uniffi_types.rs create mode 100644 docs/SETUP_TEARDOWN_DESIGN.md create mode 100644 mobench-dx-spec.md diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index 0680abf..a7503d7 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -6,6 +6,86 @@ mobile benchmarks, and then run them on BrowserStack. > **Important**: This guide is for integrators importing `mobench-sdk` as a library. > All build functionality is available via `cargo mobench` commands. +## Quick Setup Checklist + +Before diving into the full guide, ensure your project meets these requirements: + +### Required Cargo.toml entries + +```toml +[dependencies] +mobench-sdk = "0.1" +inventory = "0.3" # Required for benchmark registration + +[lib] +# Required for mobile FFI - produces .so (Android) and .a (iOS) +crate-type = ["cdylib", "staticlib", "lib"] +``` + +### Benchmark Function Requirements + +**Simple benchmarks** (no setup attribute) must: +- Take **no parameters** +- Return **()** (unit type) - use `std::hint::black_box()` for results +- Be **public** (`pub fn`) + +```rust +use mobench_sdk::benchmark; + +// CORRECT - no params, returns () +#[benchmark] +pub fn my_benchmark() { + let input = create_input(); // Setup inside (gets measured) + let result = compute(input); + std::hint::black_box(result); // Consume result +} + +// WRONG - has parameters without setup (compile error) +#[benchmark] +pub fn bad_benchmark(data: &[u8]) { ... } + +// WRONG - returns a value (compile error) +#[benchmark] +pub fn bad_benchmark() -> u64 { 42 } +``` + +**Benchmarks with setup** must: +- Take **one parameter** matching the setup function's return type +- Return **()** (unit type) +- Be **public** (`pub fn`) + +```rust +// Setup function returns the input type +fn create_test_data() -> Vec { + vec![0u8; 1_000_000] +} + +// CORRECT - parameter type matches setup return type +#[benchmark(setup = create_test_data)] +pub fn process_data(data: &Vec) { + let sum: u64 = data.iter().map(|b| *b as u64).sum(); + std::hint::black_box(sum); +} +``` + +### Verify Your Setup + +After adding benchmarks, verify everything is working: + +```bash +# Check prerequisites are installed +cargo mobench check --target android + +# List discovered benchmarks +cargo mobench list + +# Verify registry, spec, and artifacts +cargo mobench verify --smoke-test --function my_crate::my_benchmark + +# (Optional) Validate BrowserStack device specs before running +cargo mobench devices --validate "Google Pixel 7-13.0" +``` + ## 1) Prerequisites Install the following tools (per platform): @@ -41,18 +121,242 @@ Add `#[mobench_sdk::benchmark]` to any function you want to run on devices. use mobench_sdk::benchmark; #[benchmark] -fn checksum_bench() { +pub fn checksum_bench() { let data = [1u8; 1024]; let sum: u64 = data.iter().map(|b| *b as u64).sum(); std::hint::black_box(sum); } ``` +### Macro Validation + +The `#[benchmark]` macro validates function signatures at compile time: + +**No parameters allowed:** +```rust +// ERROR: #[benchmark] functions must take no parameters. +// Found 1 parameter(s): data: &[u8] +#[benchmark] +pub fn bad_benchmark(data: &[u8]) { ... } +``` + +**Must return unit type:** +```rust +// ERROR: #[benchmark] functions must return () (unit type). +// Found return type: u64 +#[benchmark] +pub fn bad_benchmark() -> u64 { 42 } +``` + +The compiler provides helpful suggestions for fixing these issues. + +### Debugging Registration Issues + +If benchmarks aren't being discovered, use the `debug_benchmarks!()` macro: + +```rust +use mobench_sdk::{benchmark, debug_benchmarks}; + +#[benchmark] +pub fn my_benchmark() { + std::hint::black_box(42); +} + +// Generate the debug function +debug_benchmarks!(); + +fn main() { + // Print all registered benchmarks + _debug_print_benchmarks(); + // Output: + // Discovered benchmarks: + // - my_crate::my_benchmark +} +``` + +If no benchmarks are printed, the macro provides troubleshooting tips: +1. Ensure functions are annotated with `#[benchmark]` +2. Ensure functions are `pub` (public visibility) +3. Ensure the crate with benchmarks is linked into the binary +4. Check that `inventory` crate is in your dependencies + Benchmarks are identified by name at runtime. You can call them by: - Fully-qualified path (e.g., `my_crate::checksum_bench`) - Or suffix match (e.g., `checksum_bench`) +## Setup and Teardown + +When benchmarking functions that require expensive initialization, you want to exclude the setup time from your measurements. The `#[benchmark]` macro supports `setup`, `teardown`, and `per_iteration` attributes for this purpose. + +### The Problem: Expensive Setup Getting Measured + +Without setup/teardown support, initialization is included in timing: + +```rust +#[benchmark] +pub fn verify_proof() { + // This 5-second proof generation is measured (bad!) + let proof = generate_complex_proof(); + + // This 10ms verification is what we actually want to measure + verify(&proof); +} +``` + +### Solution 1: One-Time Setup (Default) + +Use the `setup` attribute to run initialization once before all iterations: + +```rust +// Setup runs once, returns data passed to benchmark +fn setup_proof() -> ProofInput { + generate_complex_proof() // Takes 5 seconds, NOT measured +} + +#[benchmark(setup = setup_proof)] +pub fn verify_proof(input: &ProofInput) { + // Only this is measured - same input reused for all iterations + verify(&input.proof); +} +``` + +**How it works:** +1. `setup_proof()` is called once before timing starts +2. The returned `ProofInput` is passed by reference to each iteration +3. All iterations share the same setup data +4. Setup time is excluded from measurements + +### Solution 2: Per-Iteration Setup + +For benchmarks that mutate their input, use `per_iteration` to get fresh data each iteration: + +```rust +fn generate_random_vec() -> Vec { + (0..1000).map(|_| rand::random()).collect() +} + +#[benchmark(setup = generate_random_vec, per_iteration)] +pub fn sort_benchmark(data: Vec) { + let mut data = data; // Takes ownership + data.sort(); // Mutates data - needs fresh input each time +} +``` + +**How it works:** +1. `generate_random_vec()` is called before EACH iteration +2. Data is passed by value (ownership transfer) +3. Each iteration gets fresh, unmutated data +4. Setup time for each iteration is excluded from measurements + +### Solution 3: Setup with Teardown + +For resources that need cleanup (database connections, temporary files, etc.): + +```rust +fn setup_db() -> Database { + Database::connect("test.db") +} + +fn cleanup_db(db: Database) { + db.close(); + std::fs::remove_file("test.db").ok(); +} + +#[benchmark(setup = setup_db, teardown = cleanup_db)] +pub fn db_query(db: &Database) { + db.query("SELECT * FROM users"); +} +``` + +**How it works:** +1. `setup_db()` is called once before timing starts +2. Database reference is passed to each iteration +3. After all iterations complete, `cleanup_db()` receives ownership +4. Both setup and teardown are excluded from measurements + +### Combining Per-Iteration with Teardown + +```rust +fn create_temp_file() -> TempFile { + TempFile::new("test_data.bin") +} + +fn delete_temp_file(file: TempFile) { + file.delete(); +} + +#[benchmark(setup = create_temp_file, teardown = delete_temp_file)] +pub fn write_benchmark(file: &TempFile) { + file.write_all(&[0u8; 1024]); +} +``` + +### Pattern Selection Guide + +| Pattern | When to Use | Setup Timing | Data Sharing | +|---------|-------------|--------------|--------------| +| `#[benchmark]` | Simple benchmarks, fast inline setup | N/A | N/A | +| `#[benchmark(setup = fn)]` | Expensive setup, read-only benchmark | Once | Shared reference | +| `#[benchmark(setup = fn, per_iteration)]` | Benchmarks that mutate input | Per iteration | Owned value | +| `#[benchmark(setup = fn, teardown = fn)]` | Resources needing cleanup | Once | Shared reference | + +Note: `per_iteration` and `teardown` cannot be combined, as `per_iteration` mode takes ownership of +the setup value, making cleanup via teardown semantically problematic. + +### Complete Example + +```rust +use mobench_sdk::benchmark; + +// Simple benchmark - setup is fast enough to include +#[benchmark] +pub fn fibonacci() { + let n = 30; + let result = fib(n); + std::hint::black_box(result); +} + +// Expensive one-time setup +fn load_model() -> Model { + Model::load_from_disk("large_model.bin") // 10 seconds +} + +#[benchmark(setup = load_model)] +pub fn inference(model: &Model) { + let output = model.predict(&[1.0, 2.0, 3.0]); + std::hint::black_box(output); +} + +// Per-iteration setup for mutable operations +fn create_shuffled_vec() -> Vec { + let mut v: Vec = (0..10000).collect(); + v.shuffle(&mut rand::thread_rng()); + v +} + +#[benchmark(setup = create_shuffled_vec, per_iteration)] +pub fn quicksort(mut data: Vec) { + data.sort_unstable(); + std::hint::black_box(data); +} + +// Setup + teardown for resource management +fn open_connection() -> DbConnection { + DbConnection::connect("postgres://localhost/bench") +} + +fn close_connection(conn: DbConnection) { + conn.execute("DROP TABLE IF EXISTS bench_temp"); + conn.close(); +} + +#[benchmark(setup = open_connection, teardown = close_connection)] +pub fn db_insert(conn: &DbConnection) { + conn.execute("INSERT INTO bench_temp VALUES (1, 'test')"); +} +``` + ## 4) Scaffold mobile projects From your repo root, create a mobile harness with the CLI: @@ -128,11 +432,10 @@ Build APK + test APK: ```bash cargo mobench build --target android -cd android -./gradlew :app:assembleDebugAndroidTest -cd .. ``` +The CLI automatically builds both the app APK and the test APK (androidTest) required for BrowserStack Espresso testing. + Run on BrowserStack: ```bash @@ -196,10 +499,152 @@ cargo mobench run \ - `--method development`: Requires Apple Developer account - `package-xcuitest`: Creates the XCUITest runner zip that BrowserStack uses to drive test automation. Outputs to `target/mobench/ios/BenchRunnerUITests.zip` +## 9) Device Selection Guide + +When benchmarking on BrowserStack, choosing appropriate devices helps ensure your code performs well across the spectrum of real-world hardware. Below are recommended devices for each performance tier. + +### Android Device Tiers + +| Tier | Example Device | BrowserStack Spec | Use Case | +|------|----------------|-------------------|----------| +| **Flagship** | Samsung Galaxy S24 Ultra | `"Samsung Galaxy S24 Ultra-14.0"` | Best-case performance, latest hardware | +| **High** | Google Pixel 8 | `"Google Pixel 8-14.0"` | Modern high-end, clean Android | +| **Medium-High** | Samsung Galaxy A54 | `"Samsung Galaxy A54-13.0"` | Popular mid-range, good baseline | +| **Medium** | Samsung Galaxy A33 | `"Samsung Galaxy A33 5G-13.0"` | Budget-conscious users | +| **Low** | Samsung Galaxy A13 | `"Samsung Galaxy A13-12.0"` | Entry-level smartphones | +| **Lowest** | Samsung Galaxy A03s | `"Samsung Galaxy A03s-12.0"` | Worst-case performance testing | + +### iOS Device Tiers + +| Tier | Example Device | BrowserStack Spec | Use Case | +|------|----------------|-------------------|----------| +| **Flagship** | iPhone 15 Pro Max | `"iPhone 15 Pro Max-17"` | Best-case performance, A17 Pro chip | +| **High** | iPhone 14 Pro | `"iPhone 14 Pro-17"` | Previous flagship, still powerful | +| **Medium-High** | iPhone 13 | `"iPhone 13-17"` | Mainstream device, A15 chip | +| **Medium** | iPhone 12 | `"iPhone 12-17"` | Older but still common | +| **Low** | iPhone SE 2022 | `"iPhone SE 2022-17"` | Budget iPhone, A15 chip | +| **Lowest** | iPhone 11 | `"iPhone 11-17"` | Oldest commonly supported | + +### Multi-Device Benchmarking + +Run benchmarks across multiple tiers to understand performance distribution: + +```bash +# Android: Test across performance spectrum +cargo mobench run \ + --target android \ + --function my_crate::my_benchmark \ + --iterations 50 \ + --warmup 5 \ + --devices "Samsung Galaxy S24 Ultra-14.0" "Samsung Galaxy A54-13.0" "Samsung Galaxy A13-12.0" \ + --release + +# iOS: Test across performance spectrum +cargo mobench run \ + --target ios \ + --function my_crate::my_benchmark \ + --iterations 50 \ + --warmup 5 \ + --devices "iPhone 15 Pro Max-17" "iPhone 13-17" "iPhone 11-17" \ + --release \ + --ios-app target/mobench/ios/BenchRunner.ipa \ + --ios-test-suite target/mobench/ios/BenchRunnerUITests.zip +``` + +### Validate Device Availability + +Before running, verify your device specs are valid: + +```bash +# Validate specific devices +cargo mobench devices --validate "Samsung Galaxy S24 Ultra-14.0" "iPhone 15 Pro Max-17" + +# List all available Android devices +cargo mobench devices --platform android + +# List all available iOS devices +cargo mobench devices --platform ios +``` + +**Tip**: Device availability on BrowserStack changes over time. Use `cargo mobench devices` to see currently available devices. + +## 10) Verification and Troubleshooting + +### Check Prerequisites + +Before building, verify all required tools are installed: + +```bash +# Check Android prerequisites +cargo mobench check --target android + +# Check iOS prerequisites +cargo mobench check --target ios + +# Output as JSON for CI +cargo mobench check --target android --format json +``` + +### Verify Benchmark Setup + +Use the `verify` command to validate your setup: + +```bash +# Full verification with smoke test +cargo mobench verify --target android --check-artifacts --smoke-test --function my_crate::my_benchmark + +# Check specific spec file +cargo mobench verify --spec-path target/mobench/android/app/src/main/assets/bench_spec.json +``` + +The verify command checks: +1. Benchmark registry has functions registered +2. Spec file exists and is valid +3. Build artifacts are present +4. Optional smoke test passes + +### View Result Summaries + +After running benchmarks, get statistics with the `summary` command: + +```bash +# Text summary (default) +cargo mobench summary results.json + +# JSON format +cargo mobench summary results.json --format json + +# CSV format +cargo mobench summary results.json --format csv +``` + +### Common Errors and Solutions + +**"unknown benchmark function":** +``` +Error: unknown benchmark function: 'my_func'. Available benchmarks: ["other_func"] + +Ensure the function is: + 1. Annotated with #[benchmark] + 2. Public (pub fn) + 3. Takes no parameters and returns () +``` + +**"iterations must be greater than zero":** +``` +Error: iterations must be greater than zero (got 0). Minimum recommended: 10 +``` + +**Benchmark not discovered:** +- Use `debug_benchmarks!()` macro to debug +- Verify function is `pub` and annotated with `#[benchmark]` +- Ensure `inventory` crate is a dependency + ## Notes - **No scripts needed**: All functionality is available via `cargo mobench` commands - **Use `--release` for BrowserStack**: Debug builds are ~544MB, release builds are ~133MB. Large artifacts can cause upload timeouts. +- **Validate before running**: Use `cargo mobench verify` to catch issues early - If you change FFI types, the build process automatically regenerates bindings - Android emulator ABI is typically `x86_64` in Android Studio - BrowserStack credentials must be set via `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` diff --git a/BROWSERSTACK_CI_INTEGRATION.md b/BROWSERSTACK_CI_INTEGRATION.md index e45de78..ea48a4c 100644 --- a/BROWSERSTACK_CI_INTEGRATION.md +++ b/BROWSERSTACK_CI_INTEGRATION.md @@ -5,9 +5,69 @@ This guide shows how to run benchmarks on BrowserStack and fetch results within ## Overview The BrowserStack client now supports: -1. **Scheduling runs** - Upload artifacts and start tests -2. **Polling for completion** - Wait for tests to finish (with timeout) -3. **Fetching results** - Download device logs and extract benchmark data +1. **Prerequisite checking** - Verify tools and credentials before builds +2. **Device validation** - Validate device specs before scheduling runs +3. **Scheduling runs** - Upload artifacts and start tests +4. **Polling for completion** - Wait for tests to finish (with timeout) +5. **Fetching results** - Download device logs and extract benchmark data +6. **Result analysis** - Display summary statistics from reports + +## Pre-flight Validation + +Before running benchmarks on BrowserStack, validate your setup: + +### Check Prerequisites + +```bash +# Verify Android build tools are installed +cargo mobench check --target android + +# Verify iOS build tools are installed +cargo mobench check --target ios + +# Output as JSON for CI parsing +cargo mobench check --target android --format json +``` + +The `check` command validates: +- Rust toolchain and cargo +- Android: NDK, cargo-ndk, Rust targets, JDK +- iOS: Xcode, xcodegen, Rust targets + +### Validate Devices + +Before scheduling runs, validate device specs: + +```bash +# Validate specific device specs +cargo mobench devices --validate "Google Pixel 7-13.0" "iPhone 14-16" + +# List available devices +cargo mobench devices --platform android +cargo mobench devices --platform ios + +# Output as JSON +cargo mobench devices --platform android --json +``` + +Invalid device specs return helpful suggestions: +``` +Invalid devices (1): + [ERROR] Google Pixle 7-13.0: Device not found + Suggestions: + - Google Pixel 7-13.0 + - Google Pixel 7 Pro-13.0 +``` + +### Verify Benchmark Setup + +```bash +# Verify registry, spec, and artifacts +cargo mobench verify --target android --check-artifacts + +# Include smoke test +cargo mobench verify --target android --smoke-test --function my_benchmark +``` ## Quick Example @@ -181,6 +241,15 @@ jobs: - name: Install mobench run: cargo install mobench + - name: Check prerequisites + run: cargo mobench check --target android + + - name: Validate devices + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + run: cargo mobench devices --validate "Google Pixel 7-13.0" + - name: Run benchmarks on BrowserStack env: BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} @@ -199,14 +268,22 @@ jobs: --fetch-timeout-secs 600 \ --output results.json - # Extract metrics for comparison - cat results.json | jq '.devices[0].samples | map(.duration_ns) | add / length' + - name: Display summary + run: cargo mobench summary results.json + + - name: Extract metrics + run: | + # JSON format for programmatic access + cargo mobench summary results.json --format json > metrics.json + cat metrics.json | jq '.[0].mean_ns' - name: Upload results uses: actions/upload-artifact@v4 with: name: benchmark-results - path: results.json + path: | + results.json + metrics.json ``` ## Advanced: Custom Result Processing @@ -283,6 +360,46 @@ match client.wait_and_fetch_all_results(build_id, "espresso", Some(600)) { ## Troubleshooting +### BrowserStack credentials not configured + +**Error**: +``` +BrowserStack credentials not configured. + +Set credentials using one of these methods: + + 1. Environment variables: + export BROWSERSTACK_USERNAME=your_username + export BROWSERSTACK_ACCESS_KEY=your_access_key + + 2. Config file (bench-config.toml): + [browserstack] + app_automate_username = "your_username" + app_automate_access_key = "your_access_key" + + 3. .env.local file in project root: + BROWSERSTACK_USERNAME=your_username + BROWSERSTACK_ACCESS_KEY=your_access_key + +Get credentials: https://app-automate.browserstack.com/ +(Navigate to Settings -> Access Key) +``` + +**Solution**: Set credentials using any of the three methods shown. + +### Device spec validation failed + +**Error**: +``` +Invalid devices (1): + [ERROR] Google Pixle 7-13.0: Device not found + Suggestions: + - Google Pixel 7-13.0 + - Google Pixel 7 Pro-13.0 +``` + +**Solution**: Use the suggested device name or run `cargo mobench devices` to see all available devices. + ### No benchmark results found **Cause**: The benchmark app didn't log results, or logs are in unexpected format. @@ -291,6 +408,7 @@ match client.wait_and_fetch_all_results(build_id, "espresso", Some(600)) { 1. Check device logs manually in BrowserStack dashboard 2. Verify your app logs benchmark results as JSON to stdout/logcat 3. Use `client.get_device_logs()` to inspect raw logs +4. Run `cargo mobench verify --smoke-test` to test locally first ### Build stuck in "running" state @@ -300,6 +418,7 @@ match client.wait_and_fetch_all_results(build_id, "espresso", Some(600)) { 1. Check the BrowserStack dashboard for device screenshots/video 2. Increase timeout if benchmarks legitimately take longer 3. Add health checks to your benchmark code +4. Use `--progress` flag to see detailed progress during runs ### Rate limiting @@ -310,8 +429,63 @@ match client.wait_and_fetch_all_results(build_id, "espresso", Some(600)) { 2. Use BrowserStack's webhook notifications instead of polling 3. Check your BrowserStack plan limits +### Prerequisites missing + +**Error**: Build fails with missing tools. + +**Solution**: Run `cargo mobench check --target ` to identify missing prerequisites and get fix suggestions. + +## New CLI Commands + +### `cargo mobench check` + +Validate prerequisites before building: + +```bash +cargo mobench check --target android [--format text|json] +``` + +### `cargo mobench devices` + +List and validate BrowserStack devices: + +```bash +# List all devices +cargo mobench devices + +# List by platform +cargo mobench devices --platform android + +# Validate specific specs +cargo mobench devices --validate "Google Pixel 7-13.0" "iPhone 14-16" + +# JSON output +cargo mobench devices --json +``` + +### `cargo mobench verify` + +Validate benchmark setup: + +```bash +cargo mobench verify \ + --target android \ + --check-artifacts \ + --smoke-test \ + --function my_benchmark +``` + +### `cargo mobench summary` + +Display statistics from results: + +```bash +cargo mobench summary results.json [--format text|json|csv] +``` + ## Next Steps - See `BROWSERSTACK_METRICS.md` for metrics and performance documentation +- See `FETCH_RESULTS_GUIDE.md` for detailed fetch and summary workflows - Check `crates/mobench/src/browserstack.rs` for full API documentation - Run `cargo doc --open -p mobench` for detailed API docs diff --git a/BUILD.md b/BUILD.md index 3909f7c..13769e5 100644 --- a/BUILD.md +++ b/BUILD.md @@ -3,17 +3,60 @@ Complete build instructions for Android and iOS targets. > **For SDK Integrators**: Use the CLI commands: +> - `cargo mobench check --target android` (validate prerequisites first) +> - `cargo mobench check --target ios` (validate prerequisites first) > - `cargo mobench build --target android` > - `cargo mobench build --target ios` > > See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide. ## Table of Contents +- [Prerequisites Check](#prerequisites-check) - [Prerequisites](#prerequisites) - [Android Build](#android-build) - [iOS Build](#ios-build) - [Common Issues](#common-issues) +## Prerequisites Check + +Before installing prerequisites manually, use the `check` command to validate your setup: + +```bash +# Check Android prerequisites +cargo mobench check --target android + +# Check iOS prerequisites +cargo mobench check --target ios + +# Check both platforms +cargo mobench check --target android +cargo mobench check --target ios +``` + +The `check` command validates: +- **Android**: `ANDROID_NDK_HOME` environment variable, `cargo-ndk` installation, Rust targets +- **iOS**: Xcode installation, `xcodegen`, Rust targets +- **Both**: Cargo, rustup, required Rust targets + +Output includes: +- Pass/fail status for each prerequisite +- Instructions for fixing missing prerequisites +- JSON output option (`--format json`) for CI integration + +Example output: +``` +Checking Android prerequisites... + [PASS] cargo found + [PASS] rustup found + [PASS] ANDROID_NDK_HOME set: /Users/you/Library/Android/sdk/ndk/29.0.14206865 + [PASS] cargo-ndk installed + [PASS] Rust target aarch64-linux-android installed + [PASS] Rust target armv7-linux-androideabi installed + [PASS] Rust target x86_64-linux-android installed + +All prerequisites satisfied! +``` + ## Prerequisites ### All Platforms @@ -75,9 +118,15 @@ xcodebuild -version ### Quick Start (Recommended) ```bash +# First, check prerequisites +cargo mobench check --target android + # Build everything and create APK in one command cargo mobench build --target android +# Or build with progress output for clearer feedback +cargo mobench build --target android --progress + # Install on connected device or emulator adb install -r target/mobench/android/app/build/outputs/apk/debug/app-debug.apk @@ -157,9 +206,15 @@ cargo mobench build --target android ### Quick Start (Recommended) ```bash +# First, check prerequisites +cargo mobench check --target ios + # Build Rust xcframework (includes automatic code signing) cargo mobench build --target ios +# Or build with progress output for clearer feedback +cargo mobench build --target ios --progress + # Generate Xcode project cd target/mobench/ios/BenchRunner xcodegen generate @@ -311,10 +366,17 @@ codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework ## Common Issues +### Prerequisite Validation + +**Tip**: Before troubleshooting, run `cargo mobench check --target ` to identify missing prerequisites. The check command provides specific instructions for each issue. + ### Android **Issue**: `ANDROID_NDK_HOME is not set` ```bash +# Run check to see the full prerequisite status +cargo mobench check --target android + # Find your NDK installation find ~/Library/Android/sdk/ndk -name "ndk-build" 2>/dev/null diff --git a/CLAUDE.md b/CLAUDE.md index 08e5a7b..ca90366 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,10 @@ Quick test commands: # Run all Rust tests cargo test --all +# Check prerequisites before building (validates NDK, Xcode, Rust targets) +cargo mobench check --target android +cargo mobench check --target ios + # Initialize SDK project cargo mobench init --target android --output bench-config.toml @@ -96,12 +100,23 @@ cargo mobench init --target android --output bench-config.toml cargo mobench build --target android cargo mobench build --target ios +# Build with progress output for clearer feedback +cargo mobench build --target android --progress + # Build to custom output directory cargo mobench build --target android --output-dir ./my-output # List discovered benchmarks cargo mobench list +# Verify benchmark setup (registry, spec, artifacts) +cargo mobench verify --target android --check-artifacts + +# View benchmark result statistics +cargo mobench summary results.json + +# List available BrowserStack devices +cargo mobench devices --platform android ``` ## Common Commands @@ -114,6 +129,10 @@ The `cargo mobench` CLI provides a unified build experience: # Install the CLI cargo install mobench +# Check prerequisites first (validates NDK, Xcode, Rust targets, etc.) +cargo mobench check --target android +cargo mobench check --target ios + # Initialize project (generates config and scaffolding) cargo mobench init --target android --output bench-config.toml @@ -123,6 +142,10 @@ cargo mobench build --target android # Build for iOS cargo mobench build --target ios +# Build with simplified progress output +cargo mobench build --target android --progress +cargo mobench build --target ios --progress + # Build for both platforms cargo mobench build --target android cargo mobench build --target ios @@ -136,15 +159,20 @@ cargo mobench package-xcuitest # Build in release mode (smaller artifacts, recommended for BrowserStack) cargo mobench build --target android --release cargo mobench build --target ios --release + +# Verify build artifacts exist and are valid +cargo mobench verify --target android --check-artifacts ``` **What the CLI does:** +- Validates prerequisites with `check` command before building - Automatically builds Rust libraries with correct targets - Generates or updates mobile app projects from embedded templates - Syncs native libraries into platform-specific directories - Builds APK (Android) or xcframework (iOS) - Outputs all artifacts to `target/mobench/` by default (use `--output-dir` to customize) +- Shows progress with `--progress` flag for clearer feedback - No manual script execution needed ### Repository Development Builds @@ -327,6 +355,72 @@ fn my_expensive_operation() { The macro automatically registers functions at compile time via the `inventory` crate. +**Setup and Teardown (v0.1.13+)**: The `#[benchmark]` macro supports setup and teardown for excluding expensive initialization from timing: + +```rust +// Setup runs once before all iterations (not measured) +fn setup_proof() -> ProofInput { + generate_complex_proof() // Expensive, but not timed +} + +#[benchmark(setup = setup_proof)] +fn verify_proof(input: &ProofInput) { + verify(&input.proof); // Only this is measured +} + +// Per-iteration setup for benchmarks that mutate input +fn generate_vec() -> Vec { (0..1000).collect() } + +#[benchmark(setup = generate_vec, per_iteration)] +fn sort_benchmark(data: Vec) { + let mut data = data; + data.sort(); // Gets fresh data each iteration +} + +// Setup + teardown for resources requiring cleanup +fn setup_db() -> Database { Database::connect("test.db") } +fn cleanup_db(db: Database) { db.close(); } + +#[benchmark(setup = setup_db, teardown = cleanup_db)] +fn db_query(db: &Database) { + db.query("SELECT *"); +} +``` + +**Macro Validation (v0.1.13+)**: The `#[benchmark]` macro validates function signatures at compile time: +- Simple benchmarks: no parameters, returns `()` +- With setup: one parameter matching setup return type +- Compile errors include helpful messages about requirements + +**Debugging Benchmark Registration**: Use the `debug_benchmarks!()` macro to verify benchmarks are properly registered: + +```rust +use mobench_sdk::{benchmark, debug_benchmarks}; + +#[benchmark] +fn my_benchmark() { + std::hint::black_box(42); +} + +// Generate debug function +debug_benchmarks!(); + +fn main() { + // Print all registered benchmarks + _debug_print_benchmarks(); + // Output: + // Discovered benchmarks: + // - my_crate::my_benchmark +} +``` + +If no benchmarks appear, check: +1. Functions are annotated with `#[benchmark]` +2. Functions are `pub` (public visibility) +3. Simple benchmarks: no parameters, returns `()` +4. Setup benchmarks: one parameter matching setup return type +5. The `inventory` crate is in your dependencies + ### FFI Boundary (`examples/ffi-benchmark`) The example crate uses **UniFFI proc macros** to generate type-safe bindings for Kotlin and Swift. The API is defined directly in Rust code with attributes: @@ -396,6 +490,30 @@ Credentials are resolved in this order: 2. Environment variables: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `BROWSERSTACK_PROJECT` 3. `.env.local` file (loaded automatically via `dotenvy`) +**Improved Error Messages (v0.1.13+)**: Missing credentials now show setup instructions: +- Instructions for setting environment variables +- Link to BrowserStack account settings page +- Hints for `.env.local` file setup + +**Device Validation**: Use `cargo mobench devices` to list and validate device specs: + +```bash +# List all available devices +cargo mobench devices + +# Filter by platform +cargo mobench devices --platform android +cargo mobench devices --platform ios + +# Validate device specs +cargo mobench devices --validate "Google Pixel 7-13.0" + +# Output as JSON +cargo mobench devices --json +``` + +Invalid device specs now show suggestions for similar devices. + ### CI/CD (`.github/workflows/mobile-bench.yml`) The workflow supports manual dispatch with platform selection: @@ -584,8 +702,10 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) ### Core SDK Crates - **`crates/mobench/`**: CLI tool (published to crates.io) - - `src/main.rs`: CLI entry point with commands (init, build, run, fetch, etc.) + - `src/lib.rs`: CLI entry point with commands (init, build, run, fetch, check, verify, summary, devices, etc.) + - `src/main.rs`: CLI binary wrapper - `src/browserstack.rs`: BrowserStack REST API client + - `src/config.rs`: Configuration file support for `mobench.toml` - **`crates/mobench-sdk/`**: Core SDK library (published to crates.io) - `src/lib.rs`: Public API surface - `src/timing.rs`: Lightweight timing harness (BenchSpec, BenchReport, run_closure) diff --git a/FETCH_RESULTS_GUIDE.md b/FETCH_RESULTS_GUIDE.md index bede8eb..a708ee9 100644 --- a/FETCH_RESULTS_GUIDE.md +++ b/FETCH_RESULTS_GUIDE.md @@ -232,14 +232,65 @@ This means: Verify your app logs JSON to stdout/logcat in the correct format. +## Analyzing Results with `summary` + +The `summary` command provides quick statistics from benchmark results: + +```bash +# Text summary (default) +cargo mobench summary results.json + +# Output: +# Benchmark Summary +# ================= +# Source: results.json +# Function: sample_fns::fibonacci +# Device: Google Pixel 7-13.0 +# +# Statistics: +# Samples: 30 +# Mean: 1,234,567 ns (1.23 ms) +# Median: 1,230,000 ns (1.23 ms) +# Min: 1,200,000 ns (1.20 ms) +# Max: 1,280,000 ns (1.28 ms) +# P95: 1,270,000 ns (1.27 ms) + +# JSON format for programmatic access +cargo mobench summary results.json --format json + +# CSV format for spreadsheets +cargo mobench summary results.json --format csv +``` + +### Using Summary in CI + +```yaml +- name: Run benchmarks + run: | + cargo mobench run --target android --function my_benchmark \ + --devices "Google Pixel 7-13.0" --release --fetch \ + --output results.json + +- name: Display summary + run: cargo mobench summary results.json + +- name: Export metrics + run: | + cargo mobench summary results.json --format json > metrics.json + # Use jq to extract specific values + MEAN_NS=$(jq '.[0].mean_ns' metrics.json) + echo "mean_ns=$MEAN_NS" >> $GITHUB_OUTPUT +``` + ## Best Practices 1. **Always use --fetch in CI** for automated pipelines 2. **Always use --release for BrowserStack** to reduce artifact sizes (~544MB debug vs ~133MB release) and prevent upload timeouts -3. **Set reasonable timeouts** based on your benchmark duration -4. **Check exit codes** - command succeeds even if fetch warns -5. **Archive results** as CI artifacts for historical tracking -6. **Use GitHub Actions summaries** to display results inline +3. **Use summary command** to quickly analyze results +4. **Set reasonable timeouts** based on your benchmark duration +5. **Check exit codes** - command succeeds even if fetch warns +6. **Archive results** as CI artifacts for historical tracking +7. **Use GitHub Actions summaries** to display results inline ## Comparison with Manual Workflow @@ -272,8 +323,33 @@ cargo mobench run \ # Results already in results.json! ``` +## New CLI Commands for Results + +### `cargo mobench summary` + +Display statistics from any benchmark report file: + +```bash +cargo mobench summary [--format text|json|csv] +``` + +Supports multiple report formats: +- `RunSummary` from `mobench run --output` +- `BenchReport` from direct timing output +- Fetched BrowserStack results + +### `cargo mobench verify` + +Validate setup before running benchmarks: + +```bash +cargo mobench verify --target android --check-artifacts --smoke-test +``` + ## See Also - `BROWSERSTACK_CI_INTEGRATION.md` - Programmatic API for custom workflows - `BROWSERSTACK_METRICS.md` - Metrics and performance documentation - `cargo mobench run --help` - Full CLI options +- `cargo mobench summary --help` - Summary command options +- `cargo mobench verify --help` - Verification command options diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 7b2af74..025a4dd 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -47,9 +47,33 @@ - [x] Add GH Actions summary/annotations for benchmark results. - [x] Add regression comparison command (compare two JSON summaries). +## DX Improvements (v2) - Completed + +### SDK Improvements +- [x] `#[benchmark]` macro validates function signature at compile time (no params, returns `()`) +- [x] Helpful compile errors with suggestions for fixing signature issues +- [x] `debug_benchmarks!()` macro for debugging registration issues +- [x] Better error messages: `UnknownFunction` shows available benchmarks +- [x] Better error messages: `TimingError::NoIterations` shows actual value provided +- [x] Quick Setup Checklist in SDK lib.rs documentation + +### New CLI Commands +- [x] `cargo mobench check` - Validates prerequisites (NDK, Xcode, Rust targets, etc.) +- [x] `cargo mobench verify` - Validates registry, spec, and artifacts with optional smoke test +- [x] `cargo mobench summary` - Displays result statistics (text/json/csv formats) +- [x] `cargo mobench devices` - Lists and validates BrowserStack devices + +### BrowserStack Improvements +- [x] Better credential error messages with 3 setup methods (env vars, config file, .env.local) +- [x] Artifact validation before upload (checks file exists and size) +- [x] Device fuzzy matching with suggestions for typos +- [x] Device validation via `--validate` flag + ## Suggested Next Tasks - [ ] Stretch: parallel device runs, retries, percentile stats, optional energy/thermal readings where available. +- [ ] Rich reporting dashboard (P2 from DX spec) +- [ ] Spec snapshots and result comparisons across builds (P2 from DX spec) ## In-Repo Placeholders (current) diff --git a/README.md b/README.md index e26b167..111b722 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,29 @@ cargo install mobench # Add the SDK to your project cargo add mobench-sdk inventory +# Check prerequisites before building +cargo mobench check --target android +cargo mobench check --target ios + # Build artifacts (outputs to target/mobench/ by default) cargo mobench build --target android cargo mobench build --target ios +# Build with progress output for clearer feedback +cargo mobench build --target android --progress + # Run a benchmark locally cargo mobench run --target android --function sample_fns::fibonacci # Run on BrowserStack (use --release for smaller APK uploads) cargo mobench run --target android --function sample_fns::fibonacci \ --devices "Google Pixel 7-13.0" --release + +# List available BrowserStack devices +cargo mobench devices --platform android + +# View benchmark results summary +cargo mobench summary results.json ``` ## Configuration @@ -80,11 +93,111 @@ CLI flags override config file values when provided. - `PROJECT_PLAN.md`: goals and backlog - `CLAUDE.md`: developer guide +## Setup and Teardown + +For benchmarks that require expensive setup (like generating test data or initializing connections), you can exclude setup time from measurements using the `setup` attribute. + +### The Problem + +Without setup/teardown, expensive initialization is measured as part of your benchmark: + +```rust +#[benchmark] +fn verify_proof() { + let proof = generate_complex_proof(); // This is measured (bad!) + verify(&proof); // This is what we want to measure +} +``` + +### The Solution + +Use the `setup` attribute to run initialization once before timing begins: + +```rust +// Setup function runs once before all iterations (not timed) +fn setup_proof() -> ProofInput { + generate_complex_proof() // Takes 5 seconds, but not measured +} + +#[benchmark(setup = setup_proof)] +fn verify_proof(input: &ProofInput) { + verify(&input.proof); // Only this is measured +} +``` + +### Per-Iteration Setup + +For benchmarks that mutate their input, use `per_iteration` to get fresh data each iteration: + +```rust +fn generate_random_vec() -> Vec { + (0..1000).map(|_| rand::random()).collect() +} + +#[benchmark(setup = generate_random_vec, per_iteration)] +fn sort_benchmark(data: Vec) { + let mut data = data; + data.sort(); // Each iteration gets a fresh unsorted vec +} +``` + +### Setup with Teardown + +For resources that need cleanup (database connections, temp files, etc.): + +```rust +fn setup_db() -> Database { Database::connect("test.db") } +fn cleanup_db(db: Database) { db.close(); std::fs::remove_file("test.db").ok(); } + +#[benchmark(setup = setup_db, teardown = cleanup_db)] +fn db_query(db: &Database) { + db.query("SELECT * FROM users"); +} +``` + +### When to Use Each Pattern + +| Pattern | Use Case | +|---------|----------| +| `#[benchmark]` | Simple benchmarks with no setup or fast inline setup | +| `#[benchmark(setup = fn)]` | Expensive one-time setup, reused across iterations | +| `#[benchmark(setup = fn, per_iteration)]` | Benchmarks that mutate input, need fresh data each time | +| `#[benchmark(setup = fn, teardown = fn)]` | Resources requiring cleanup (connections, files, etc.) | + ## Release Notes ### v0.1.13 -- **Fix iOS XCUITest test name mismatch**: Changed BrowserStack `only-testing` filter to use `testLaunchAndCaptureBenchmarkReport` which matches what BrowserStack parses from the xctest bundle +- **Setup and teardown support**: `#[benchmark]` macro now supports `setup`, `teardown`, and `per_iteration` attributes for excluding expensive initialization from timing measurements + ```rust + fn setup_data() -> Vec { vec![0u8; 10_000_000] } + + #[benchmark(setup = setup_data)] + fn process_data(data: &Vec) { + // Only this is measured, not the setup + } + ``` +- **New `check` command**: Validates prerequisites (NDK, Xcode, Rust targets, etc.) before building + ```bash + cargo mobench check --target android + cargo mobench check --target ios + ``` +- **New `verify` command**: Validates registry, spec, and artifacts +- **New `summary` command**: Displays benchmark result statistics (avg/min/max/median) +- **New `devices` command**: Lists available BrowserStack devices with validation +- **`--progress` flag**: Simplified step-by-step output for `build` and `run` commands +- **Consolidated `mobench-runner` into `mobench-sdk`**: The timing harness is now part of `mobench-sdk` as the `timing` module, simplifying the dependency graph +- **SDK improvements**: + - `#[benchmark]` macro now validates function signature at compile time (no params, returns `()`) + - New `debug_benchmarks!()` macro for verifying benchmark registration + - Better error messages with available benchmarks list +- **BrowserStack improvements**: + - Better credential error messages with setup instructions + - Artifact pre-flight validation before uploads + - Upload progress indication with file sizes + - Dashboard link printed immediately when build starts + - Improved device fuzzy matching with suggestions +- **Fix iOS XCUITest test name mismatch**: Changed BrowserStack `only-testing` filter to use `testLaunchAndCaptureBenchmarkReport` ### v0.1.12 diff --git a/TESTING.md b/TESTING.md index 8a178d4..6bff0f3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -17,6 +17,20 @@ This document provides comprehensive testing instructions for mobile-bench-rs. ## Prerequisites +### Prerequisite Validation (Recommended) + +Before installing prerequisites manually, use the `check` command to validate your setup: + +```bash +# Check Android prerequisites +cargo mobench check --target android + +# Check iOS prerequisites +cargo mobench check --target ios +``` + +The check command will identify missing tools and provide installation instructions. + ### Rust ```bash # Install Rust if not already installed @@ -83,9 +97,18 @@ The `Mobile Bench (manual)` workflow uploads summary artifacts: ### Method 1: Quick All-in-One Build ```bash +# First, validate prerequisites +cargo mobench check --target android + # Build everything and create APK cargo mobench build --target android +# Or build with progress output for clearer feedback +cargo mobench build --target android --progress + +# Verify the build artifacts +cargo mobench verify --target android --check-artifacts + # Install on connected device/emulator adb install -r target/mobench/android/app/build/outputs/apk/debug/app-debug.apk @@ -178,10 +201,19 @@ Statistics: ### Build and Run ```bash +# Step 0: Validate prerequisites +cargo mobench check --target ios + # Step 1: Build Rust xcframework (includes automatic code signing) cargo mobench build --target ios -# This script: +# Or build with progress output for clearer feedback +cargo mobench build --target ios --progress + +# Verify the build artifacts +cargo mobench verify --target ios --check-artifacts + +# This build step: # - Compiles Rust for aarch64-apple-ios (device) and aarch64-apple-ios-sim (simulator) # - Creates xcframework with proper structure: # target/mobench/ios/sample_fns.xcframework/ @@ -293,10 +325,39 @@ Statistics: ## Troubleshooting +### General Validation + +Before troubleshooting specific issues, use these validation commands: + +```bash +# Check prerequisites +cargo mobench check --target android +cargo mobench check --target ios + +# Verify benchmark setup (registry, spec, artifacts) +cargo mobench verify --target android --check-artifacts +cargo mobench verify --target ios --check-artifacts + +# List discovered benchmarks +cargo mobench list + +# Validate BrowserStack device specs +cargo mobench devices --validate "Google Pixel 7-13.0" +``` + +The `verify` command validates: +- **Registry**: Benchmark functions are properly registered +- **Spec**: `bench_spec.json` exists and is valid (if `--spec-path` provided) +- **Artifacts**: Build outputs exist and are consistent (if `--check-artifacts`) +- **Smoke test**: Runs a local test with minimal iterations (if `--smoke-test`) + ### Android **Problem**: `ANDROID_NDK_HOME is not set` ```bash +# First, run check to see the full prerequisite status +cargo mobench check --target android + # Solution: Export the NDK path export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/29.0.14206865 # Or add to ~/.zshrc / ~/.bashrc @@ -467,6 +528,43 @@ cargo test --all See the main [README.md](README.md) for BrowserStack testing instructions. +#### Device Validation + +Before running tests on BrowserStack, validate your device specifications: + +```bash +# List all available BrowserStack devices +cargo mobench devices + +# Filter by platform +cargo mobench devices --platform android +cargo mobench devices --platform ios + +# Validate specific device specs +cargo mobench devices --validate "Google Pixel 7-13.0" +cargo mobench devices --validate "iPhone 14-16" + +# Output as JSON for scripting +cargo mobench devices --json +``` + +If a device spec is invalid, the command will suggest similar available devices. + +#### View Benchmark Results + +After a benchmark run, use the `summary` command to view statistics: + +```bash +# Display result statistics (avg/min/max/median) +cargo mobench summary results.json + +# Output as JSON +cargo mobench summary results.json --format json + +# Output as CSV +cargo mobench summary results.json --format csv +``` + #### Release Builds for BrowserStack Always use the `--release` flag when running benchmarks on BrowserStack. Debug builds are significantly larger and may cause upload timeouts: diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index e7b1e6d..978b118 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -101,18 +101,67 @@ fn my_benchmark() { inventory::submit! { BenchFunction { name: "my_benchmark", - invoke: |_args| { - my_benchmark(); - Ok(()) + runner: |spec| { + run_closure(spec, || { + my_benchmark(); + Ok(()) + }) } } } ``` +## Setup and Teardown + +For benchmarks that need expensive setup that shouldn't be measured: + +```rust +use mobench_macros::benchmark; + +fn setup_data() -> Vec { + vec![0u8; 1_000_000] // Not measured +} + +#[benchmark(setup = setup_data)] +fn hash_benchmark(data: &Vec) { + std::hint::black_box(compute_hash(data)); // Only this is measured +} +``` + +### Per-Iteration Setup + +For benchmarks that mutate their input (e.g., sorting): + +```rust +fn generate_random_vec() -> Vec { + (0..1000).collect() +} + +#[benchmark(setup = generate_random_vec, per_iteration)] +fn sort_benchmark(data: Vec) { + let mut data = data; + data.sort(); + std::hint::black_box(data); +} +``` + +### Setup and Teardown + +```rust +fn setup_db() -> Database { Database::connect("test.db") } +fn cleanup_db(db: Database) { db.close(); } + +#[benchmark(setup = setup_db, teardown = cleanup_db)] +fn db_query(db: &Database) { + db.query("SELECT * FROM users"); +} +``` + ## Requirements - Functions must be regular functions (not async) -- Functions should not take parameters +- Without setup: no parameters allowed +- With setup: exactly one parameter (reference to setup result, or owned for per_iteration) - Functions should use `std::hint::black_box()` to prevent optimization of results ## Best Practices diff --git a/crates/mobench-macros/src/lib.rs b/crates/mobench-macros/src/lib.rs index b00f242..1278c9b 100644 --- a/crates/mobench-macros/src/lib.rs +++ b/crates/mobench-macros/src/lib.rs @@ -27,6 +27,23 @@ //! } //! ``` //! +//! ## Setup and Teardown +//! +//! For benchmarks that need expensive setup that shouldn't be measured: +//! +//! ```ignore +//! use mobench_sdk::benchmark; +//! +//! fn setup_data() -> Vec { +//! vec![0u8; 1_000_000] // Not measured +//! } +//! +//! #[benchmark(setup = setup_data)] +//! fn hash_benchmark(data: &Vec) { +//! std::hint::black_box(compute_hash(data)); // Only this is measured +//! } +//! ``` +//! //! ## How It Works //! //! The `#[benchmark]` macro: @@ -34,40 +51,15 @@ //! 1. **Preserves the original function** - The function remains callable as normal //! 2. **Registers with inventory** - Creates a static registration that the SDK discovers at runtime //! 3. **Captures the fully-qualified name** - Uses `module_path!()` to generate unique names like `my_crate::my_module::my_benchmark` +//! 4. **Handles setup/teardown** - If specified, wraps the benchmark with setup/teardown that aren't timed //! //! ## Requirements //! //! - The [`inventory`](https://crates.io/crates/inventory) crate must be in your dependency tree -//! - Functions must have no parameters and return `()` +//! - Simple benchmarks: no parameters, returns `()` +//! - With setup: exactly one parameter (reference to setup result), returns `()` //! - The function should not panic during normal execution //! -//! ## Example: Multiple Benchmarks -//! -//! ```ignore -//! use mobench_sdk::benchmark; -//! -//! #[benchmark] -//! fn benchmark_sorting() { -//! let mut data: Vec = (0..1000).rev().collect(); -//! data.sort(); -//! std::hint::black_box(data); -//! } -//! -//! #[benchmark] -//! fn benchmark_hashing() { -//! use std::collections::hash_map::DefaultHasher; -//! use std::hash::{Hash, Hasher}; -//! -//! let mut hasher = DefaultHasher::new(); -//! "hello world".hash(&mut hasher); -//! std::hint::black_box(hasher.finish()); -//! } -//! ``` -//! -//! Both functions will be registered with names like: -//! - `my_crate::benchmark_sorting` -//! - `my_crate::benchmark_hashing` -//! //! ## Crate Ecosystem //! //! This crate is part of the mobench ecosystem: @@ -75,19 +67,123 @@ //! - **[`mobench-sdk`](https://crates.io/crates/mobench-sdk)** - Core SDK with timing harness (re-exports this macro) //! - **[`mobench`](https://crates.io/crates/mobench)** - CLI tool //! - **`mobench-macros`** (this crate) - Proc macros -//! -//! Note: The `mobench-runner` crate has been consolidated into `mobench-sdk` as the `timing` module. use proc_macro::TokenStream; use quote::quote; -use syn::{ItemFn, parse_macro_input}; +use syn::{ + parse::{Parse, ParseStream}, + parse_macro_input, + punctuated::Punctuated, + Ident, ItemFn, ReturnType, Token, +}; + +/// Arguments to the benchmark attribute +struct BenchmarkArgs { + setup: Option, + teardown: Option, + per_iteration: bool, +} + +impl Parse for BenchmarkArgs { + fn parse(input: ParseStream) -> syn::Result { + let mut setup = None; + let mut teardown = None; + let mut per_iteration = false; + + if input.is_empty() { + return Ok(Self { + setup, + teardown, + per_iteration, + }); + } + + // Parse key = value pairs separated by commas + let args = Punctuated::::parse_terminated(input)?; + + for arg in args { + match arg { + BenchmarkArg::Setup(ident) => { + if setup.is_some() { + return Err(syn::Error::new_spanned(ident, "duplicate setup argument")); + } + setup = Some(ident); + } + BenchmarkArg::Teardown(ident) => { + if teardown.is_some() { + return Err(syn::Error::new_spanned( + ident, + "duplicate teardown argument", + )); + } + teardown = Some(ident); + } + BenchmarkArg::PerIteration => { + per_iteration = true; + } + } + } + + // Validate: teardown without setup is invalid + if teardown.is_some() && setup.is_none() { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "teardown requires setup to be specified", + )); + } + + // Validate: per_iteration with teardown is not supported + if per_iteration && teardown.is_some() { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "per_iteration mode is not compatible with teardown", + )); + } + + Ok(Self { + setup, + teardown, + per_iteration, + }) + } +} + +enum BenchmarkArg { + Setup(Ident), + Teardown(Ident), + PerIteration, +} + +impl Parse for BenchmarkArg { + fn parse(input: ParseStream) -> syn::Result { + let name: Ident = input.parse()?; + + match name.to_string().as_str() { + "setup" => { + input.parse::()?; + let value: Ident = input.parse()?; + Ok(BenchmarkArg::Setup(value)) + } + "teardown" => { + input.parse::()?; + let value: Ident = input.parse()?; + Ok(BenchmarkArg::Teardown(value)) + } + "per_iteration" => Ok(BenchmarkArg::PerIteration), + _ => Err(syn::Error::new_spanned( + name, + "expected 'setup', 'teardown', or 'per_iteration'", + )), + } + } +} /// Marks a function as a benchmark for mobile execution. /// /// This attribute macro registers the function in the global benchmark registry, /// making it discoverable and executable by the mobench runtime. /// -/// # Usage +/// # Basic Usage /// /// ```ignore /// use mobench_sdk::benchmark; @@ -99,98 +195,79 @@ use syn::{ItemFn, parse_macro_input}; /// } /// ``` /// -/// # Function Requirements -/// -/// The annotated function must: -/// - Take no parameters -/// - Return `()` (unit type) -/// - Not panic during normal execution -/// -/// # Best Practices +/// # With Setup (setup runs once, not measured) /// -/// ## Use `black_box` to Prevent Optimization +/// ```ignore +/// use mobench_sdk::benchmark; /// -/// Always wrap results with [`std::hint::black_box`] to prevent the compiler -/// from optimizing away the computation: +/// fn setup_proof() -> ProofInput { +/// ProofInput::generate() // Expensive, not measured +/// } /// -/// ```ignore -/// #[benchmark] -/// fn good_benchmark() { -/// let result = compute_something(); -/// std::hint::black_box(result); // Prevents optimization +/// #[benchmark(setup = setup_proof)] +/// fn verify_proof(input: &ProofInput) { +/// verify(&input.proof); // Only this is measured /// } /// ``` /// -/// ## Avoid Side Effects -/// -/// Benchmarks should be deterministic. Avoid: -/// - File I/O -/// - Network calls -/// - Random number generation (unless seeded) -/// - Global mutable state -/// -/// ## Keep Benchmarks Focused -/// -/// Each benchmark should measure one specific operation: +/// # With Per-Iteration Setup (for mutating benchmarks) /// /// ```ignore -/// // Good: Focused benchmark -/// #[benchmark] -/// fn benchmark_json_parse() { -/// let json = r#"{"key": "value"}"#; -/// let parsed: serde_json::Value = serde_json::from_str(json).unwrap(); -/// std::hint::black_box(parsed); +/// use mobench_sdk::benchmark; +/// +/// fn generate_random_vec() -> Vec { +/// (0..1000).map(|_| rand::random()).collect() /// } /// -/// // Avoid: Multiple operations in one benchmark -/// #[benchmark] -/// fn benchmark_everything() { -/// let json = create_json(); // Measured -/// let parsed = parse_json(&json); // Measured -/// let serialized = serialize(parsed); // Measured -/// std::hint::black_box(serialized); +/// #[benchmark(setup = generate_random_vec, per_iteration)] +/// fn sort_benchmark(data: Vec) { +/// let mut data = data; +/// data.sort(); +/// std::hint::black_box(data); /// } /// ``` /// -/// # Generated Code -/// -/// The macro generates code equivalent to: +/// # With Setup and Teardown /// /// ```ignore -/// fn my_benchmark() { -/// // Original function body -/// } +/// use mobench_sdk::benchmark; +/// +/// fn setup_db() -> Database { Database::connect("test.db") } +/// fn cleanup_db(db: Database) { db.close(); } /// -/// inventory::submit! { -/// mobench_sdk::registry::BenchFunction { -/// name: "my_crate::my_module::my_benchmark", -/// invoke: |_args| { -/// my_benchmark(); -/// Ok(()) -/// }, -/// } +/// #[benchmark(setup = setup_db, teardown = cleanup_db)] +/// fn db_query(db: &Database) { +/// db.query("SELECT * FROM users"); /// } /// ``` /// -/// # Discovering Benchmarks +/// # Function Requirements +/// +/// **Without setup:** +/// - Take no parameters +/// - Return `()` (unit type) +/// +/// **With setup:** +/// - Take exactly one parameter (reference to setup result, or owned for per_iteration) +/// - Return `()` (unit type) /// -/// Registered benchmarks can be discovered at runtime: +/// # Best Practices /// -/// ```ignore -/// use mobench_sdk::{discover_benchmarks, list_benchmark_names}; +/// ## Use `black_box` to Prevent Optimization /// -/// // Get all benchmark names -/// for name in list_benchmark_names() { -/// println!("Found: {}", name); -/// } +/// Always wrap results with [`std::hint::black_box`] to prevent the compiler +/// from optimizing away the computation: /// -/// // Get full benchmark info -/// for bench in discover_benchmarks() { -/// println!("Benchmark: {}", bench.name); +/// ```ignore +/// #[benchmark] +/// fn good_benchmark() { +/// let result = compute_something(); +/// std::hint::black_box(result); // Prevents optimization /// } /// ``` #[proc_macro_attribute] -pub fn benchmark(_attr: TokenStream, item: TokenStream) -> TokenStream { +pub fn benchmark(attr: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(attr as BenchmarkArgs); let input_fn = parse_macro_input!(item as ItemFn); let fn_name = &input_fn.sig.ident; @@ -200,9 +277,91 @@ pub fn benchmark(_attr: TokenStream, item: TokenStream) -> TokenStream { let block = &input_fn.block; let attrs = &input_fn.attrs; - // Get the module path for fully-qualified name - // Note: This will generate the fully-qualified name at compile time - let module_path = quote! { module_path!() }; + // Validate based on whether setup is provided + if args.setup.is_some() { + // With setup: must have exactly one parameter + if input_fn.sig.inputs.len() != 1 { + let param_count = input_fn.sig.inputs.len(); + return syn::Error::new_spanned( + &input_fn.sig, + format!( + "#[benchmark(setup = ...)] functions must take exactly one parameter.\n\ + Found {} parameter(s).\n\n\ + Example:\n\ + fn setup_data() -> MyData {{ ... }}\n\n\ + #[benchmark(setup = setup_data)]\n\ + fn {}(input: &MyData) {{\n\ + // input is the result of setup_data()\n\ + }}", + param_count, fn_name_str + ), + ) + .to_compile_error() + .into(); + } + } else { + // No setup: must have no parameters + if !input_fn.sig.inputs.is_empty() { + let param_count = input_fn.sig.inputs.len(); + let param_names: Vec = input_fn + .sig + .inputs + .iter() + .map(|arg| match arg { + syn::FnArg::Receiver(_) => "self".to_string(), + syn::FnArg::Typed(pat) => quote!(#pat).to_string(), + }) + .collect(); + return syn::Error::new_spanned( + &input_fn.sig.inputs, + format!( + "#[benchmark] functions must take no parameters.\n\ + Found {} parameter(s): {}\n\n\ + If you need setup data, use the setup attribute:\n\n\ + fn setup_data() -> MyData {{ ... }}\n\n\ + #[benchmark(setup = setup_data)]\n\ + fn {}(input: &MyData) {{\n\ + // Your benchmark code using input\n\ + }}", + param_count, + param_names.join(", "), + fn_name_str + ), + ) + .to_compile_error() + .into(); + } + } + + // Validate: function must return () (unit type) + match &input_fn.sig.output { + ReturnType::Default => {} // () return type is OK + ReturnType::Type(_, return_type) => { + let type_str = quote!(#return_type).to_string(); + if type_str.trim() != "()" { + return syn::Error::new_spanned( + return_type, + format!( + "#[benchmark] functions must return () (unit type).\n\ + Found return type: {}\n\n\ + Benchmark results should be consumed with std::hint::black_box() \ + rather than returned:\n\n\ + #[benchmark]\n\ + fn {}() {{\n\ + let result = compute_something();\n\ + std::hint::black_box(result); // Prevents optimization\n\ + }}", + type_str, fn_name_str + ), + ) + .to_compile_error() + .into(); + } + } + } + + // Generate the runner based on configuration + let runner = generate_runner(fn_name, &args); let expanded = quote! { // Preserve the original function @@ -214,14 +373,73 @@ pub fn benchmark(_attr: TokenStream, item: TokenStream) -> TokenStream { // Register the function with inventory ::inventory::submit! { ::mobench_sdk::registry::BenchFunction { - name: ::std::concat!(#module_path, "::", #fn_name_str), - invoke: |_args| { - #fn_name(); - Ok(()) - }, + name: ::std::concat!(::std::module_path!(), "::", #fn_name_str), + runner: #runner, } } }; TokenStream::from(expanded) } + +fn generate_runner(fn_name: &Ident, args: &BenchmarkArgs) -> proc_macro2::TokenStream { + match (&args.setup, &args.teardown, args.per_iteration) { + // No setup - simple benchmark + (None, None, _) => quote! { + |spec: ::mobench_sdk::timing::BenchSpec| -> ::std::result::Result<::mobench_sdk::timing::BenchReport, ::mobench_sdk::timing::TimingError> { + ::mobench_sdk::timing::run_closure(spec, || { + #fn_name(); + Ok(()) + }) + } + }, + + // Setup only, runs once before all iterations + (Some(setup), None, false) => quote! { + |spec: ::mobench_sdk::timing::BenchSpec| -> ::std::result::Result<::mobench_sdk::timing::BenchReport, ::mobench_sdk::timing::TimingError> { + ::mobench_sdk::timing::run_closure_with_setup( + spec, + || #setup(), + |input| { + #fn_name(input); + Ok(()) + }, + ) + } + }, + + // Setup only, per iteration (for mutating benchmarks) + (Some(setup), None, true) => quote! { + |spec: ::mobench_sdk::timing::BenchSpec| -> ::std::result::Result<::mobench_sdk::timing::BenchReport, ::mobench_sdk::timing::TimingError> { + ::mobench_sdk::timing::run_closure_with_setup_per_iter( + spec, + || #setup(), + |input| { + #fn_name(input); + Ok(()) + }, + ) + } + }, + + // Setup + teardown (per_iteration with teardown is rejected during parsing) + (Some(setup), Some(teardown), false) => quote! { + |spec: ::mobench_sdk::timing::BenchSpec| -> ::std::result::Result<::mobench_sdk::timing::BenchReport, ::mobench_sdk::timing::TimingError> { + ::mobench_sdk::timing::run_closure_with_setup_teardown( + spec, + || #setup(), + |input| { + #fn_name(input); + Ok(()) + }, + |input| #teardown(input), + ) + } + }, + + // These cases are rejected during parsing, but we need to handle them + (None, Some(_), _) | (Some(_), Some(_), true) => { + quote! { compile_error!("invalid benchmark configuration") } + } + } +} diff --git a/crates/mobench-sdk/src/builders/common.rs b/crates/mobench-sdk/src/builders/common.rs index d64efad..a48deda 100644 --- a/crates/mobench-sdk/src/builders/common.rs +++ b/crates/mobench-sdk/src/builders/common.rs @@ -304,6 +304,338 @@ pub fn read_package_name(cargo_toml_path: &Path) -> Option { None } +/// Embeds a bench spec JSON file into the Android assets and iOS bundle resources. +/// +/// This function writes a `bench_spec.json` file to the appropriate location for +/// both Android (assets directory) and iOS (bundle resources) so the mobile app +/// can read the benchmark configuration at runtime. +/// +/// # Arguments +/// * `output_dir` - The mobench output directory (e.g., `target/mobench`) +/// * `spec` - The benchmark specification as a JSON-serializable struct +/// +/// # Example +/// ```ignore +/// use mobench_sdk::builders::common::embed_bench_spec; +/// use mobench_sdk::BenchSpec; +/// +/// let spec = BenchSpec { +/// name: "my_crate::my_benchmark".to_string(), +/// iterations: 100, +/// warmup: 10, +/// }; +/// +/// embed_bench_spec(Path::new("target/mobench"), &spec)?; +/// ``` +pub fn embed_bench_spec(output_dir: &Path, spec: &S) -> Result<(), BenchError> { + let spec_json = serde_json::to_string_pretty(spec).map_err(|e| { + BenchError::Build(format!("Failed to serialize bench spec: {}", e)) + })?; + + // Android: Write to assets directory + let android_assets_dir = output_dir.join("android/app/src/main/assets"); + if output_dir.join("android").exists() { + std::fs::create_dir_all(&android_assets_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to create Android assets directory at {}: {}", + android_assets_dir.display(), + e + )) + })?; + let android_spec_path = android_assets_dir.join("bench_spec.json"); + std::fs::write(&android_spec_path, &spec_json).map_err(|e| { + BenchError::Build(format!( + "Failed to write Android bench spec to {}: {}", + android_spec_path.display(), + e + )) + })?; + } + + // iOS: Write to Resources directory in the Xcode project + let ios_resources_dir = output_dir.join("ios/BenchRunner/BenchRunner/Resources"); + if output_dir.join("ios/BenchRunner").exists() { + std::fs::create_dir_all(&ios_resources_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to create iOS Resources directory at {}: {}", + ios_resources_dir.display(), + e + )) + })?; + let ios_spec_path = ios_resources_dir.join("bench_spec.json"); + std::fs::write(&ios_spec_path, &spec_json).map_err(|e| { + BenchError::Build(format!( + "Failed to write iOS bench spec to {}: {}", + ios_spec_path.display(), + e + )) + })?; + } + + Ok(()) +} + +/// Represents a benchmark specification for embedding. +/// +/// This is a simple struct that can be serialized to JSON and embedded +/// in mobile app bundles. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct EmbeddedBenchSpec { + /// The benchmark function name (e.g., "my_crate::my_benchmark") + pub function: String, + /// Number of benchmark iterations + pub iterations: u32, + /// Number of warmup iterations + pub warmup: u32, +} + +/// Build metadata for artifact correlation and traceability. +/// +/// This struct captures metadata about the build environment to enable +/// reproducibility and debugging of benchmark results. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BenchMeta { + /// Benchmark specification that was used + pub spec: EmbeddedBenchSpec, + /// Git commit hash (if in a git repository) + #[serde(skip_serializing_if = "Option::is_none")] + pub commit_hash: Option, + /// Git branch name (if available) + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + /// Whether the git working directory was dirty + #[serde(skip_serializing_if = "Option::is_none")] + pub dirty: Option, + /// Build timestamp in RFC3339 format + pub build_time: String, + /// Build timestamp as Unix epoch seconds + pub build_time_unix: u64, + /// Target platform ("android" or "ios") + pub target: String, + /// Build profile ("debug" or "release") + pub profile: String, + /// mobench version + pub mobench_version: String, + /// Rust version used for the build + #[serde(skip_serializing_if = "Option::is_none")] + pub rust_version: Option, + /// Host OS (e.g., "macos", "linux") + pub host_os: String, +} + +/// Gets the current git commit hash (short form). +pub fn get_git_commit() -> Option { + let output = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok()?; + + if output.status.success() { + let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !hash.is_empty() { + return Some(hash); + } + } + None +} + +/// Gets the current git branch name. +pub fn get_git_branch() -> Option { + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .ok()?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !branch.is_empty() && branch != "HEAD" { + return Some(branch); + } + } + None +} + +/// Checks if the git working directory has uncommitted changes. +pub fn is_git_dirty() -> Option { + let output = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .ok()?; + + if output.status.success() { + let status = String::from_utf8_lossy(&output.stdout); + Some(!status.trim().is_empty()) + } else { + None + } +} + +/// Gets the Rust version. +pub fn get_rust_version() -> Option { + let output = Command::new("rustc") + .args(["--version"]) + .output() + .ok()?; + + if output.status.success() { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !version.is_empty() { + return Some(version); + } + } + None +} + +/// Creates a BenchMeta instance with current build information. +pub fn create_bench_meta(spec: &EmbeddedBenchSpec, target: &str, profile: &str) -> BenchMeta { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + + // Format as RFC3339 + let build_time = { + let secs = now.as_secs(); + // Simple UTC timestamp formatting + let days_since_epoch = secs / 86400; + let remaining_secs = secs % 86400; + let hours = remaining_secs / 3600; + let minutes = (remaining_secs % 3600) / 60; + let seconds = remaining_secs % 60; + + // Calculate year, month, day from days since epoch (1970-01-01) + // Simplified calculation - good enough for build metadata + let (year, month, day) = days_to_ymd(days_since_epoch); + + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + year, month, day, hours, minutes, seconds + ) + }; + + BenchMeta { + spec: spec.clone(), + commit_hash: get_git_commit(), + branch: get_git_branch(), + dirty: is_git_dirty(), + build_time, + build_time_unix: now.as_secs(), + target: target.to_string(), + profile: profile.to_string(), + mobench_version: env!("CARGO_PKG_VERSION").to_string(), + rust_version: get_rust_version(), + host_os: env::consts::OS.to_string(), + } +} + +/// Convert days since epoch to (year, month, day). +/// Simplified Gregorian calendar calculation. +fn days_to_ymd(days: u64) -> (i32, u32, u32) { + let mut remaining_days = days as i64; + let mut year = 1970i32; + + // Advance years + loop { + let days_in_year = if is_leap_year(year) { 366 } else { 365 }; + if remaining_days < days_in_year { + break; + } + remaining_days -= days_in_year; + year += 1; + } + + // Days in each month (non-leap year) + let days_in_months: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + let mut month = 1u32; + for (i, &days_in_month) in days_in_months.iter().enumerate() { + let mut dim = days_in_month; + if i == 1 && is_leap_year(year) { + dim = 29; + } + if remaining_days < dim { + break; + } + remaining_days -= dim; + month += 1; + } + + (year, month, remaining_days as u32 + 1) +} + +fn is_leap_year(year: i32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +/// Embeds build metadata (bench_meta.json) alongside bench_spec.json in mobile app bundles. +/// +/// This function creates a `bench_meta.json` file that contains: +/// - The benchmark specification +/// - Git commit hash and branch (if available) +/// - Build timestamp +/// - Target platform and profile +/// - mobench and Rust versions +/// +/// # Arguments +/// * `output_dir` - The mobench output directory (e.g., `target/mobench`) +/// * `spec` - The benchmark specification +/// * `target` - Target platform ("android" or "ios") +/// * `profile` - Build profile ("debug" or "release") +pub fn embed_bench_meta( + output_dir: &Path, + spec: &EmbeddedBenchSpec, + target: &str, + profile: &str, +) -> Result<(), BenchError> { + let meta = create_bench_meta(spec, target, profile); + let meta_json = serde_json::to_string_pretty(&meta).map_err(|e| { + BenchError::Build(format!("Failed to serialize bench meta: {}", e)) + })?; + + // Android: Write to assets directory + let android_assets_dir = output_dir.join("android/app/src/main/assets"); + if output_dir.join("android").exists() { + std::fs::create_dir_all(&android_assets_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to create Android assets directory at {}: {}", + android_assets_dir.display(), + e + )) + })?; + let android_meta_path = android_assets_dir.join("bench_meta.json"); + std::fs::write(&android_meta_path, &meta_json).map_err(|e| { + BenchError::Build(format!( + "Failed to write Android bench meta to {}: {}", + android_meta_path.display(), + e + )) + })?; + } + + // iOS: Write to Resources directory in the Xcode project + let ios_resources_dir = output_dir.join("ios/BenchRunner/BenchRunner/Resources"); + if output_dir.join("ios/BenchRunner").exists() { + std::fs::create_dir_all(&ios_resources_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to create iOS Resources directory at {}: {}", + ios_resources_dir.display(), + e + )) + })?; + let ios_meta_path = ios_resources_dir.join("bench_meta.json"); + std::fs::write(&ios_meta_path, &meta_json).map_err(|e| { + BenchError::Build(format!( + "Failed to write iOS bench meta to {}: {}", + ios_meta_path.display(), + e + )) + })?; + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -409,4 +741,75 @@ members = ["crates/*"] std::fs::remove_dir_all(&temp_dir).unwrap(); } + + #[test] + fn test_create_bench_meta() { + let spec = EmbeddedBenchSpec { + function: "test_crate::my_benchmark".to_string(), + iterations: 100, + warmup: 10, + }; + + let meta = create_bench_meta(&spec, "android", "release"); + + assert_eq!(meta.spec.function, "test_crate::my_benchmark"); + assert_eq!(meta.spec.iterations, 100); + assert_eq!(meta.spec.warmup, 10); + assert_eq!(meta.target, "android"); + assert_eq!(meta.profile, "release"); + assert!(!meta.mobench_version.is_empty()); + assert!(!meta.host_os.is_empty()); + assert!(!meta.build_time.is_empty()); + assert!(meta.build_time_unix > 0); + // Build time should be in RFC3339 format (roughly YYYY-MM-DDTHH:MM:SSZ) + assert!(meta.build_time.contains('T')); + assert!(meta.build_time.ends_with('Z')); + } + + #[test] + fn test_days_to_ymd_epoch() { + // Day 0 should be January 1, 1970 + let (year, month, day) = days_to_ymd(0); + assert_eq!(year, 1970); + assert_eq!(month, 1); + assert_eq!(day, 1); + } + + #[test] + fn test_days_to_ymd_known_date() { + // January 21, 2026 is approximately 20,474 days since epoch + // (2026 - 1970 = 56 years, with leap years) + // Let's test a simpler case: 365 days = January 1, 1971 + let (year, month, day) = days_to_ymd(365); + assert_eq!(year, 1971); + assert_eq!(month, 1); + assert_eq!(day, 1); + } + + #[test] + fn test_is_leap_year() { + assert!(!is_leap_year(1970)); // Not divisible by 4 + assert!(is_leap_year(2000)); // Divisible by 400 + assert!(!is_leap_year(1900)); // Divisible by 100 but not 400 + assert!(is_leap_year(2024)); // Divisible by 4, not by 100 + } + + #[test] + fn test_bench_meta_serialization() { + let spec = EmbeddedBenchSpec { + function: "my_func".to_string(), + iterations: 50, + warmup: 5, + }; + + let meta = create_bench_meta(&spec, "ios", "debug"); + let json = serde_json::to_string(&meta).expect("serialization should work"); + + // Verify it contains expected fields + assert!(json.contains("my_func")); + assert!(json.contains("ios")); + assert!(json.contains("debug")); + assert!(json.contains("build_time")); + assert!(json.contains("mobench_version")); + } } diff --git a/crates/mobench-sdk/src/builders/mod.rs b/crates/mobench-sdk/src/builders/mod.rs index 1aa9919..36e65bb 100644 --- a/crates/mobench-sdk/src/builders/mod.rs +++ b/crates/mobench-sdk/src/builders/mod.rs @@ -63,8 +63,9 @@ pub mod android; pub mod ios; -mod common; +pub mod common; // Re-export builders pub use android::AndroidBuilder; pub use ios::{IosBuilder, SigningMethod}; +pub use common::{embed_bench_spec, embed_bench_meta, EmbeddedBenchSpec, BenchMeta, create_bench_meta}; diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 1659049..7ffd619 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -220,7 +220,7 @@ impl From for BenchError { reason: runner_err.to_string(), } } - mobench_sdk::BenchError::UnknownFunction(name) => { + mobench_sdk::BenchError::UnknownFunction(name, _available) => { BenchError::UnknownFunction { name } } _ => BenchError::ExecutionFailed { @@ -791,6 +791,95 @@ pub fn detect_default_function(crate_dir: &Path, crate_name: &str) -> Option Vec { + let lib_rs = crate_dir.join("src/lib.rs"); + if !lib_rs.exists() { + return Vec::new(); + } + + let Ok(file) = fs::File::open(&lib_rs) else { + return Vec::new(); + }; + let reader = BufReader::new(file); + + let mut benchmarks = Vec::new(); + let mut found_benchmark_attr = false; + let crate_name_normalized = crate_name.replace('-', "_"); + + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + + // Check for #[benchmark] attribute + if trimmed == "#[benchmark]" || trimmed.starts_with("#[benchmark(") { + found_benchmark_attr = true; + continue; + } + + // If we found a benchmark attribute, look for the function definition + if found_benchmark_attr { + // Look for "fn function_name" or "pub fn function_name" + if let Some(fn_pos) = trimmed.find("fn ") { + let after_fn = &trimmed[fn_pos + 3..]; + // Extract function name (until '(' or whitespace) + let fn_name: String = after_fn + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + + if !fn_name.is_empty() { + benchmarks.push(format!("{}::{}", crate_name_normalized, fn_name)); + } + found_benchmark_attr = false; + } + // Reset if we hit a line that's not a function definition + // (could be another attribute or comment) + if !trimmed.starts_with('#') && !trimmed.starts_with("//") && !trimmed.is_empty() { + found_benchmark_attr = false; + } + } + } + + benchmarks +} + +/// Validates that a benchmark function exists in the crate source +/// +/// # Arguments +/// +/// * `crate_dir` - Path to the crate directory containing Cargo.toml +/// * `crate_name` - Name of the crate (used as prefix for the function names) +/// * `function_name` - The function name to validate (with or without crate prefix) +/// +/// # Returns +/// +/// `true` if the function is found, `false` otherwise +pub fn validate_benchmark_exists(crate_dir: &Path, crate_name: &str, function_name: &str) -> bool { + let benchmarks = detect_all_benchmarks(crate_dir, crate_name); + let crate_name_normalized = crate_name.replace('-', "_"); + + // Normalize the function name - add crate prefix if missing + let normalized_name = if function_name.contains("::") { + function_name.to_string() + } else { + format!("{}::{}", crate_name_normalized, function_name) + }; + + benchmarks.iter().any(|b| b == &normalized_name) +} + /// Resolves the default benchmark function for a project /// /// This function attempts to auto-detect benchmark functions from the crate's source. diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index bd38a33..b246910 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -13,6 +13,51 @@ //! run on mobile devices. It handles the complexity of cross-compilation, FFI bindings, //! and mobile app packaging automatically. //! +//! ## Quick Setup Checklist +//! +//! Before using mobench-sdk, ensure your project is configured correctly: +//! +//! ### Required Cargo.toml entries +//! +//! ```toml +//! [dependencies] +//! mobench-sdk = "0.1" +//! inventory = "0.3" # Required for benchmark registration +//! +//! [lib] +//! # Required for mobile FFI - produces .so (Android) and .a (iOS) +//! crate-type = ["cdylib", "staticlib", "lib"] +//! ``` +//! +//! ### When UniFFI is needed +//! +//! If you're creating custom FFI types for your benchmarks (custom errors, specs, etc.), +//! you'll also need UniFFI: +//! +//! ```toml +//! [dependencies] +//! uniffi = { version = "0.28", features = ["cli"] } +//! thiserror = "1.0" # For custom error types +//! serde = { version = "1.0", features = ["derive"] } # For serialization +//! +//! [build-dependencies] +//! uniffi = { version = "0.28", features = ["build"] } +//! ``` +//! +//! For most use cases, the SDK's built-in types are sufficient and UniFFI setup +//! is handled automatically by `cargo mobench build`. +//! +//! ### Troubleshooting +//! +//! If benchmarks aren't being discovered: +//! 1. Ensure functions are annotated with `#[benchmark]` +//! 2. Ensure functions are `pub` (public visibility) +//! 3. Ensure functions take no parameters and return `()` +//! 4. Use the [`debug_benchmarks!`] macro to print registered benchmarks +//! +//! For complete integration instructions, see +//! [BENCH_SDK_INTEGRATION.md](https://github.com/worldcoin/mobile-bench-rs/blob/main/BENCH_SDK_INTEGRATION.md) +//! //! ## Quick Start //! //! ### 1. Add Dependencies @@ -285,6 +330,10 @@ pub mod timing; pub mod types; +// UniFFI integration helpers +// This module provides template types and conversion traits for UniFFI integration +pub mod uniffi_types; + // Full SDK modules - only with "full" feature #[cfg(feature = "full")] #[cfg_attr(docsrs, doc(cfg(feature = "full")))] @@ -332,6 +381,70 @@ pub use timing::{run_closure, TimingError}; /// ``` pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Generates a debug function that prints all discovered benchmarks. +/// +/// This macro is useful for debugging benchmark registration issues. +/// It creates a function `_debug_print_benchmarks()` that you can call +/// to see which benchmarks have been registered via `#[benchmark]`. +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::{benchmark, debug_benchmarks}; +/// +/// #[benchmark] +/// fn my_benchmark() { +/// std::hint::black_box(42); +/// } +/// +/// // Generate the debug function +/// debug_benchmarks!(); +/// +/// fn main() { +/// // Print all registered benchmarks +/// _debug_print_benchmarks(); +/// // Output: +/// // Discovered benchmarks: +/// // - my_crate::my_benchmark +/// } +/// ``` +/// +/// # Troubleshooting +/// +/// If no benchmarks are printed: +/// 1. Ensure functions are annotated with `#[benchmark]` +/// 2. Ensure functions are `pub` (public visibility) +/// 3. Ensure the crate with benchmarks is linked into the binary +/// 4. Check that `inventory` crate is in your dependencies +#[cfg(feature = "full")] +#[cfg_attr(docsrs, doc(cfg(feature = "full")))] +#[macro_export] +macro_rules! debug_benchmarks { + () => { + /// Prints all discovered benchmark functions to stdout. + /// + /// This function is generated by the `debug_benchmarks!()` macro + /// and is useful for debugging benchmark registration issues. + pub fn _debug_print_benchmarks() { + println!("Discovered benchmarks:"); + let names = $crate::list_benchmark_names(); + if names.is_empty() { + println!(" (none found)"); + println!(); + println!("Troubleshooting:"); + println!(" 1. Ensure functions are annotated with #[benchmark]"); + println!(" 2. Ensure functions are pub (public visibility)"); + println!(" 3. Ensure the crate with benchmarks is linked into the binary"); + println!(" 4. Check that 'inventory' crate is in your dependencies"); + } else { + for name in names { + println!(" - {}", name); + } + } + } + }; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/mobench-sdk/src/registry.rs b/crates/mobench-sdk/src/registry.rs index 459b793..da2c373 100644 --- a/crates/mobench-sdk/src/registry.rs +++ b/crates/mobench-sdk/src/registry.rs @@ -3,21 +3,21 @@ //! This module provides runtime discovery of benchmark functions that have been //! marked with the `#[benchmark]` attribute macro. -use crate::types::BenchError; +use crate::timing::{BenchReport, BenchSpec, TimingError}; /// A registered benchmark function /// /// This struct is submitted to the global registry by the `#[benchmark]` macro. -/// It contains the function's name and a closure that invokes it. +/// It contains the function's name and a runner that executes the benchmark. pub struct BenchFunction { /// Fully-qualified name of the benchmark function (e.g., "my_crate::my_module::my_bench") pub name: &'static str, - /// Function that invokes the benchmark + /// Runner function that executes the benchmark with timing /// - /// Takes optional arguments and returns a Result. - /// Arguments are currently unused but reserved for future parameterization. - pub invoke: fn(&[String]) -> Result<(), BenchError>, + /// Takes a BenchSpec and returns a BenchReport directly. + /// The runner handles setup/teardown internally. + pub runner: fn(BenchSpec) -> Result, } // Register the BenchFunction type with inventory diff --git a/crates/mobench-sdk/src/runner.rs b/crates/mobench-sdk/src/runner.rs index ae4763b..bacc66b 100644 --- a/crates/mobench-sdk/src/runner.rs +++ b/crates/mobench-sdk/src/runner.rs @@ -3,14 +3,15 @@ //! This module provides the execution engine that runs registered benchmarks //! and collects timing data. -use crate::registry::find_benchmark; -use crate::timing::{run_closure, TimingError}; -use crate::types::{BenchError, BenchSpec, RunnerReport}; +use crate::registry::{find_benchmark, list_benchmark_names}; +use crate::timing::BenchSpec; +use crate::types::{BenchError, RunnerReport}; /// Runs a benchmark by name /// /// Looks up the benchmark function in the registry and executes it with the -/// given specification. +/// given specification. The benchmark's runner handles all timing, including +/// any setup/teardown logic. /// /// # Arguments /// @@ -37,15 +38,16 @@ use crate::types::{BenchError, BenchSpec, RunnerReport}; /// ``` pub fn run_benchmark(spec: BenchSpec) -> Result { // Find the benchmark function in the registry - let bench_fn = - find_benchmark(&spec.name).ok_or_else(|| BenchError::UnknownFunction(spec.name.clone()))?; + let bench_fn = find_benchmark(&spec.name).ok_or_else(|| { + let available = list_benchmark_names() + .into_iter() + .map(String::from) + .collect(); + BenchError::UnknownFunction(spec.name.clone(), available) + })?; - // Create a closure that invokes the registered function - let closure = - || (bench_fn.invoke)(&[]).map_err(|e| TimingError::Execution(e.to_string())); - - // Run the benchmark using the timing infrastructure - let report = run_closure(spec, closure)?; + // Call the runner directly - it handles setup/teardown and timing internally + let report = (bench_fn.runner)(spec)?; Ok(report) } diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index c949508..a4f8052 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -151,7 +151,7 @@ impl BenchSpec { /// ``` pub fn new(name: impl Into, iterations: u32, warmup: u32) -> Result { if iterations == 0 { - return Err(TimingError::NoIterations); + return Err(TimingError::NoIterations { count: iterations }); } Ok(Self { @@ -243,15 +243,19 @@ pub struct BenchReport { /// /// // Zero iterations produces an error /// let result = BenchSpec::new("test", 0, 10); -/// assert!(matches!(result, Err(TimingError::NoIterations))); +/// assert!(matches!(result, Err(TimingError::NoIterations { .. }))); /// ``` #[derive(Debug, Error)] pub enum TimingError { - /// The iteration count was zero. + /// The iteration count was zero or invalid. /// /// At least one iteration is required to produce a measurement. - #[error("iterations must be greater than zero")] - NoIterations, + /// The error includes the actual value provided for diagnostic purposes. + #[error("iterations must be greater than zero (got {count}). Minimum recommended: 10")] + NoIterations { + /// The invalid iteration count that was provided. + count: u32, + }, /// The benchmark function failed during execution. /// @@ -326,7 +330,9 @@ where F: FnMut() -> Result<(), TimingError>, { if spec.iterations == 0 { - return Err(TimingError::NoIterations); + return Err(TimingError::NoIterations { + count: spec.iterations, + }); } // Warmup phase - not measured @@ -345,6 +351,196 @@ where Ok(BenchReport { spec, samples }) } +/// Runs a benchmark with setup that executes once before all iterations. +/// +/// The setup function is called once before timing begins, then the benchmark +/// runs multiple times using a reference to the setup result. This is useful +/// for expensive initialization that shouldn't be included in timing. +/// +/// # Arguments +/// +/// * `spec` - Benchmark configuration specifying iterations and warmup +/// * `setup` - Function that creates the input data (called once, not timed) +/// * `f` - Benchmark closure that receives a reference to setup result +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::timing::{BenchSpec, run_closure_with_setup}; +/// +/// fn setup_data() -> Vec { +/// vec![0u8; 1_000_000] // Expensive allocation not measured +/// } +/// +/// let spec = BenchSpec::new("hash_benchmark", 100, 10)?; +/// let report = run_closure_with_setup(spec, setup_data, |data| { +/// std::hint::black_box(compute_hash(data)); +/// Ok(()) +/// })?; +/// ``` +pub fn run_closure_with_setup( + spec: BenchSpec, + setup: S, + mut f: F, +) -> Result +where + S: FnOnce() -> T, + F: FnMut(&T) -> Result<(), TimingError>, +{ + if spec.iterations == 0 { + return Err(TimingError::NoIterations { + count: spec.iterations, + }); + } + + // Setup phase - not timed + let input = setup(); + + // Warmup phase - not recorded + for _ in 0..spec.warmup { + f(&input)?; + } + + // Measurement phase + let mut samples = Vec::with_capacity(spec.iterations as usize); + for _ in 0..spec.iterations { + let start = Instant::now(); + f(&input)?; + samples.push(BenchSample::from_duration(start.elapsed())); + } + + Ok(BenchReport { spec, samples }) +} + +/// Runs a benchmark with per-iteration setup. +/// +/// Setup runs before each iteration and is not timed. The benchmark takes +/// ownership of the setup result, making this suitable for benchmarks that +/// mutate their input (e.g., sorting). +/// +/// # Arguments +/// +/// * `spec` - Benchmark configuration specifying iterations and warmup +/// * `setup` - Function that creates fresh input for each iteration (not timed) +/// * `f` - Benchmark closure that takes ownership of setup result +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::timing::{BenchSpec, run_closure_with_setup_per_iter}; +/// +/// fn generate_random_vec() -> Vec { +/// (0..1000).map(|_| rand::random()).collect() +/// } +/// +/// let spec = BenchSpec::new("sort_benchmark", 100, 10)?; +/// let report = run_closure_with_setup_per_iter(spec, generate_random_vec, |mut data| { +/// data.sort(); +/// std::hint::black_box(data); +/// Ok(()) +/// })?; +/// ``` +pub fn run_closure_with_setup_per_iter( + spec: BenchSpec, + mut setup: S, + mut f: F, +) -> Result +where + S: FnMut() -> T, + F: FnMut(T) -> Result<(), TimingError>, +{ + if spec.iterations == 0 { + return Err(TimingError::NoIterations { + count: spec.iterations, + }); + } + + // Warmup phase + for _ in 0..spec.warmup { + let input = setup(); + f(input)?; + } + + // Measurement phase + let mut samples = Vec::with_capacity(spec.iterations as usize); + for _ in 0..spec.iterations { + let input = setup(); // Not timed + + let start = Instant::now(); + f(input)?; // Only this is timed + samples.push(BenchSample::from_duration(start.elapsed())); + } + + Ok(BenchReport { spec, samples }) +} + +/// Runs a benchmark with setup and teardown. +/// +/// Setup runs once before all iterations, teardown runs once after all +/// iterations complete. Neither is included in timing. +/// +/// # Arguments +/// +/// * `spec` - Benchmark configuration specifying iterations and warmup +/// * `setup` - Function that creates the input data (called once, not timed) +/// * `f` - Benchmark closure that receives a reference to setup result +/// * `teardown` - Function that cleans up the input (called once, not timed) +/// +/// # Example +/// +/// ```ignore +/// use mobench_sdk::timing::{BenchSpec, run_closure_with_setup_teardown}; +/// +/// fn setup_db() -> Database { Database::connect("test.db") } +/// fn cleanup_db(db: Database) { db.close(); std::fs::remove_file("test.db").ok(); } +/// +/// let spec = BenchSpec::new("db_benchmark", 100, 10)?; +/// let report = run_closure_with_setup_teardown( +/// spec, +/// setup_db, +/// |db| { db.query("SELECT *"); Ok(()) }, +/// cleanup_db, +/// )?; +/// ``` +pub fn run_closure_with_setup_teardown( + spec: BenchSpec, + setup: S, + mut f: F, + teardown: D, +) -> Result +where + S: FnOnce() -> T, + F: FnMut(&T) -> Result<(), TimingError>, + D: FnOnce(T), +{ + if spec.iterations == 0 { + return Err(TimingError::NoIterations { + count: spec.iterations, + }); + } + + // Setup phase - not timed + let input = setup(); + + // Warmup phase + for _ in 0..spec.warmup { + f(&input)?; + } + + // Measurement phase + let mut samples = Vec::with_capacity(spec.iterations as usize); + for _ in 0..spec.iterations { + let start = Instant::now(); + f(&input)?; + samples.push(BenchSample::from_duration(start.elapsed())); + } + + // Teardown phase - not timed + teardown(input); + + Ok(BenchReport { spec, samples }) +} + #[cfg(test)] mod tests { use super::*; @@ -362,7 +558,7 @@ mod tests { #[test] fn rejects_zero_iterations() { let result = BenchSpec::new("test", 0, 10); - assert!(matches!(result, Err(TimingError::NoIterations))); + assert!(matches!(result, Err(TimingError::NoIterations { count: 0 }))); } #[test] @@ -385,4 +581,83 @@ mod tests { assert_eq!(restored.spec.name, "test"); assert_eq!(restored.samples.len(), 10); } + + #[test] + fn run_with_setup_calls_setup_once() { + use std::sync::atomic::{AtomicU32, Ordering}; + + static SETUP_COUNT: AtomicU32 = AtomicU32::new(0); + static RUN_COUNT: AtomicU32 = AtomicU32::new(0); + + let spec = BenchSpec::new("test", 5, 2).unwrap(); + let report = run_closure_with_setup( + spec, + || { + SETUP_COUNT.fetch_add(1, Ordering::SeqCst); + vec![1, 2, 3] + }, + |data| { + RUN_COUNT.fetch_add(1, Ordering::SeqCst); + std::hint::black_box(data.len()); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(SETUP_COUNT.load(Ordering::SeqCst), 1); // Setup called once + assert_eq!(RUN_COUNT.load(Ordering::SeqCst), 7); // 2 warmup + 5 iterations + assert_eq!(report.samples.len(), 5); + } + + #[test] + fn run_with_setup_per_iter_calls_setup_each_time() { + use std::sync::atomic::{AtomicU32, Ordering}; + + static SETUP_COUNT: AtomicU32 = AtomicU32::new(0); + + let spec = BenchSpec::new("test", 3, 1).unwrap(); + let report = run_closure_with_setup_per_iter( + spec, + || { + SETUP_COUNT.fetch_add(1, Ordering::SeqCst); + vec![1, 2, 3] + }, + |data| { + std::hint::black_box(data); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(SETUP_COUNT.load(Ordering::SeqCst), 4); // 1 warmup + 3 iterations + assert_eq!(report.samples.len(), 3); + } + + #[test] + fn run_with_setup_teardown_calls_both() { + use std::sync::atomic::{AtomicU32, Ordering}; + + static SETUP_COUNT: AtomicU32 = AtomicU32::new(0); + static TEARDOWN_COUNT: AtomicU32 = AtomicU32::new(0); + + let spec = BenchSpec::new("test", 3, 1).unwrap(); + let report = run_closure_with_setup_teardown( + spec, + || { + SETUP_COUNT.fetch_add(1, Ordering::SeqCst); + "resource" + }, + |_resource| { + Ok(()) + }, + |_resource| { + TEARDOWN_COUNT.fetch_add(1, Ordering::SeqCst); + }, + ) + .unwrap(); + + assert_eq!(SETUP_COUNT.load(Ordering::SeqCst), 1); + assert_eq!(TEARDOWN_COUNT.load(Ordering::SeqCst), 1); + assert_eq!(report.samples.len(), 3); + } } diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs index f036605..a872fc5 100644 --- a/crates/mobench-sdk/src/types.rs +++ b/crates/mobench-sdk/src/types.rs @@ -60,10 +60,10 @@ pub enum BenchError { /// /// This occurs when calling [`run_benchmark`](crate::run_benchmark) with /// a function name that hasn't been registered via `#[benchmark]`. - #[error( - "unknown benchmark function: {0}. Ensure it is annotated with #[benchmark] and the crate is linked into the bench-mobile build" - )] - UnknownFunction(String), + /// + /// The error includes a list of available benchmarks to help diagnose the issue. + #[error("unknown benchmark function: '{0}'. Available benchmarks: {1:?}\n\nEnsure the function is:\n 1. Annotated with #[benchmark]\n 2. Public (pub fn)\n 3. Takes no parameters and returns ()")] + UnknownFunction(String, Vec), /// An error occurred during benchmark execution. /// diff --git a/crates/mobench-sdk/src/uniffi_types.rs b/crates/mobench-sdk/src/uniffi_types.rs new file mode 100644 index 0000000..40f6afa --- /dev/null +++ b/crates/mobench-sdk/src/uniffi_types.rs @@ -0,0 +1,349 @@ +//! UniFFI integration helpers for generating mobile bindings. +//! +//! This module provides utilities for integrating mobench-sdk types with UniFFI +//! for generating Kotlin/Swift bindings. Since UniFFI requires scaffolding to be +//! set up in the consuming crate, this module provides conversion traits and +//! ready-to-use type definitions that can be easily adapted. +//! +//! ## Quick Start +//! +//! To use mobench-sdk with UniFFI in your crate: +//! +//! 1. Add uniffi to your dependencies: +//! +//! ```toml +//! [dependencies] +//! mobench-sdk = "0.1" +//! uniffi = { version = "0.28", features = ["cli"] } +//! +//! [build-dependencies] +//! uniffi = { version = "0.28", features = ["build"] } +//! ``` +//! +//! 2. Define your FFI types with UniFFI annotations: +//! +//! ```ignore +//! use uniffi; +//! +//! // Set up UniFFI scaffolding +//! uniffi::setup_scaffolding!(); +//! +//! #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +//! pub struct BenchSpec { +//! pub name: String, +//! pub iterations: u32, +//! pub warmup: u32, +//! } +//! +//! #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +//! pub struct BenchSample { +//! pub duration_ns: u64, +//! } +//! +//! #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +//! pub struct BenchReport { +//! pub spec: BenchSpec, +//! pub samples: Vec, +//! } +//! +//! #[derive(Debug, thiserror::Error, uniffi::Error)] +//! #[uniffi(flat_error)] +//! pub enum BenchError { +//! #[error("iterations must be greater than zero")] +//! InvalidIterations, +//! #[error("unknown benchmark function: {name}")] +//! UnknownFunction { name: String }, +//! #[error("benchmark execution failed: {reason}")] +//! ExecutionFailed { reason: String }, +//! } +//! ``` +//! +//! 3. Implement conversions using the traits from this module: +//! +//! ```ignore +//! use mobench_sdk::uniffi_types::{FromSdkSpec, FromSdkSample, FromSdkReport, FromSdkError}; +//! +//! impl FromSdkSpec for BenchSpec { +//! fn from_sdk(spec: mobench_sdk::BenchSpec) -> Self { +//! Self { +//! name: spec.name, +//! iterations: spec.iterations, +//! warmup: spec.warmup, +//! } +//! } +//! +//! fn to_sdk(&self) -> mobench_sdk::BenchSpec { +//! mobench_sdk::BenchSpec { +//! name: self.name.clone(), +//! iterations: self.iterations, +//! warmup: self.warmup, +//! } +//! } +//! } +//! +//! // ... implement other traits similarly +//! ``` +//! +//! 4. Export your benchmark function: +//! +//! ```ignore +//! #[uniffi::export] +//! pub fn run_benchmark(spec: BenchSpec) -> Result { +//! let sdk_spec = spec.to_sdk(); +//! let sdk_report = mobench_sdk::run_benchmark(sdk_spec)?; +//! Ok(BenchReport::from_sdk_report(sdk_report)) +//! } +//! ``` +//! +//! ## Complete Example +//! +//! See the `examples/ffi-benchmark` directory for a complete working example. + +use serde::{Deserialize, Serialize}; + +/// Trait for converting from SDK's BenchSpec type. +/// +/// Implement this trait on your UniFFI-annotated BenchSpec type. +pub trait FromSdkSpec: Sized { + /// Convert from the SDK's BenchSpec type. + fn from_sdk(spec: crate::BenchSpec) -> Self; + + /// Convert to the SDK's BenchSpec type. + fn to_sdk(&self) -> crate::BenchSpec; +} + +/// Trait for converting from SDK's BenchSample type. +/// +/// Implement this trait on your UniFFI-annotated BenchSample type. +pub trait FromSdkSample: Sized { + /// Convert from the SDK's BenchSample type. + fn from_sdk(sample: crate::BenchSample) -> Self; + + /// Convert to the SDK's BenchSample type. + fn to_sdk(&self) -> crate::BenchSample; +} + +/// Trait for converting from SDK's RunnerReport type. +/// +/// Implement this trait on your UniFFI-annotated BenchReport type. +pub trait FromSdkReport: Sized { + /// Convert from the SDK's RunnerReport type. + fn from_sdk_report(report: crate::RunnerReport) -> Self; +} + +/// Trait for converting from SDK's BenchError type. +/// +/// Implement this trait on your UniFFI-annotated error type. +pub trait FromSdkError: Sized { + /// Convert from the SDK's BenchError type. + fn from_sdk(err: crate::types::BenchError) -> Self; +} + +/// Pre-defined BenchSpec structure matching SDK's BenchSpec. +/// +/// This struct can be used as a template for your own UniFFI-annotated type. +/// Copy this definition and add the `#[derive(uniffi::Record)]` attribute. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchSpecTemplate { + /// Name of the benchmark function to run. + pub name: String, + /// Number of measurement iterations. + pub iterations: u32, + /// Number of warmup iterations before measurement. + pub warmup: u32, +} + +impl From for BenchSpecTemplate { + fn from(spec: crate::BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for crate::BenchSpec { + fn from(spec: BenchSpecTemplate) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +/// Pre-defined BenchSample structure matching SDK's BenchSample. +/// +/// This struct can be used as a template for your own UniFFI-annotated type. +/// Copy this definition and add the `#[derive(uniffi::Record)]` attribute. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchSampleTemplate { + /// Duration of the iteration in nanoseconds. + pub duration_ns: u64, +} + +impl From for BenchSampleTemplate { + fn from(sample: crate::BenchSample) -> Self { + Self { + duration_ns: sample.duration_ns, + } + } +} + +impl From for crate::BenchSample { + fn from(sample: BenchSampleTemplate) -> Self { + Self { + duration_ns: sample.duration_ns, + } + } +} + +/// Pre-defined BenchReport structure matching SDK's RunnerReport. +/// +/// This struct can be used as a template for your own UniFFI-annotated type. +/// Copy this definition and add the `#[derive(uniffi::Record)]` attribute. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchReportTemplate { + /// The specification used for this benchmark run. + pub spec: BenchSpecTemplate, + /// All collected timing samples. + pub samples: Vec, +} + +impl From for BenchReportTemplate { + fn from(report: crate::RunnerReport) -> Self { + Self { + spec: report.spec.into(), + samples: report.samples.into_iter().map(Into::into).collect(), + } + } +} + +/// Error variant enum for UniFFI integration. +/// +/// This enum provides the standard error variants. Copy this and add +/// `#[derive(uniffi::Error)]` and `#[uniffi(flat_error)]` attributes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BenchErrorVariant { + /// The iteration count was zero. + InvalidIterations, + /// The requested benchmark function was not found. + UnknownFunction { name: String }, + /// An error occurred during benchmark execution. + ExecutionFailed { reason: String }, + /// Configuration error. + ConfigError { message: String }, + /// I/O error. + IoError { message: String }, +} + +impl From for BenchErrorVariant { + fn from(err: crate::types::BenchError) -> Self { + match err { + crate::types::BenchError::Runner(runner_err) => match runner_err { + crate::timing::TimingError::NoIterations { .. } => { + BenchErrorVariant::InvalidIterations + } + crate::timing::TimingError::Execution(msg) => { + BenchErrorVariant::ExecutionFailed { reason: msg } + } + }, + crate::types::BenchError::UnknownFunction(name, _available) => { + BenchErrorVariant::UnknownFunction { name } + } + crate::types::BenchError::Execution(msg) => { + BenchErrorVariant::ExecutionFailed { reason: msg } + } + crate::types::BenchError::Io(e) => BenchErrorVariant::IoError { + message: e.to_string(), + }, + crate::types::BenchError::Serialization(e) => BenchErrorVariant::ConfigError { + message: e.to_string(), + }, + crate::types::BenchError::Config(msg) => BenchErrorVariant::ConfigError { message: msg }, + crate::types::BenchError::Build(msg) => BenchErrorVariant::ExecutionFailed { + reason: format!("build error: {}", msg), + }, + } + } +} + +impl From for BenchErrorVariant { + fn from(err: crate::timing::TimingError) -> Self { + match err { + crate::timing::TimingError::NoIterations { .. } => BenchErrorVariant::InvalidIterations, + crate::timing::TimingError::Execution(msg) => { + BenchErrorVariant::ExecutionFailed { reason: msg } + } + } + } +} + +/// Helper function to run a benchmark and convert result to template types. +/// +/// This is useful for implementing your own `run_benchmark` FFI function: +/// +/// ```ignore +/// #[uniffi::export] +/// pub fn run_benchmark(spec: BenchSpec) -> Result { +/// let sdk_spec: mobench_sdk::BenchSpec = spec.into(); +/// let template_result = mobench_sdk::uniffi_types::run_benchmark_template(sdk_spec); +/// match template_result { +/// Ok(report) => Ok(BenchReport::from(report)), +/// Err(err) => Err(BenchError::from(err)), +/// } +/// } +/// ``` +#[cfg(feature = "full")] +pub fn run_benchmark_template( + spec: crate::BenchSpec, +) -> Result { + crate::run_benchmark(spec) + .map(Into::into) + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bench_spec_template_conversion() { + let sdk_spec = crate::BenchSpec { + name: "test".to_string(), + iterations: 100, + warmup: 10, + }; + + let template: BenchSpecTemplate = sdk_spec.clone().into(); + assert_eq!(template.name, "test"); + assert_eq!(template.iterations, 100); + assert_eq!(template.warmup, 10); + + let back: crate::BenchSpec = template.into(); + assert_eq!(back.name, sdk_spec.name); + assert_eq!(back.iterations, sdk_spec.iterations); + assert_eq!(back.warmup, sdk_spec.warmup); + } + + #[test] + fn test_bench_sample_template_conversion() { + let sdk_sample = crate::BenchSample { duration_ns: 12345 }; + let template: BenchSampleTemplate = sdk_sample.into(); + assert_eq!(template.duration_ns, 12345); + } + + #[test] + fn test_bench_error_variant_conversion() { + let err = crate::types::BenchError::UnknownFunction( + "test_func".to_string(), + vec!["available_func".to_string()], + ); + let variant: BenchErrorVariant = err.into(); + match variant { + BenchErrorVariant::UnknownFunction { name } => assert_eq!(name, "test_func"), + _ => panic!("Expected UnknownFunction variant"), + } + } +} diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index 670f8fb..83cb30b 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -8,6 +8,65 @@ type BrowserStackResults = ( std::collections::HashMap, ); use std::path::Path; +use std::time::Instant; + +/// Format a file size in human-readable format (MB or KB). +fn format_file_size(bytes: u64) -> String { + if bytes >= 1_000_000 { + format!("{} MB", bytes / 1_000_000) + } else if bytes >= 1_000 { + format!("{} KB", bytes / 1_000) + } else { + format!("{} bytes", bytes) + } +} + +/// Get file size from path, returning 0 if unable to read metadata. +fn get_file_size(path: &Path) -> u64 { + std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) +} + +/// A device available on BrowserStack for testing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserStackDevice { + /// Device name (e.g., "Google Pixel 7", "iPhone 14") + pub device: String, + /// Operating system ("android" or "ios") + pub os: String, + /// OS version (e.g., "13.0", "16") + pub os_version: String, + /// Whether the device is available for testing + #[serde(default)] + pub available: Option, +} + +impl BrowserStackDevice { + /// Returns the device identifier string in BrowserStack format. + /// Format: "Device Name-OS Version" (e.g., "Google Pixel 7-13.0") + pub fn identifier(&self) -> String { + format!("{}-{}", self.device, self.os_version) + } +} + +/// Result of device validation. +#[derive(Debug)] +pub struct DeviceValidationResult { + /// Valid devices that were matched. + pub valid: Vec, + /// Invalid device specs with suggestions. + pub invalid: Vec, +} + +/// Error details for an invalid device specification. +#[derive(Debug)] +pub struct DeviceValidationError { + /// The device spec that was provided. + pub spec: String, + /// Reason it's invalid. + pub reason: String, + /// Suggested alternatives if any match was close. + pub suggestions: Vec, +} const DEFAULT_BASE_URL: &str = "https://api-cloud.browserstack.com"; const USER_AGENT: &str = "mobile-bench-rs/0.1"; @@ -55,6 +114,10 @@ impl BrowserStackClient { return Err(anyhow!("app artifact not found at {:?}", artifact)); } + let file_size = get_file_size(artifact); + println!("Uploading Android APK ({})...", format_file_size(file_size)); + let start = Instant::now(); + let form = Form::new().file("file", artifact)?; let resp = self .http @@ -64,7 +127,11 @@ impl BrowserStackClient { .send() .context("uploading app to BrowserStack")?; - parse_response(resp, "app upload") + let result = parse_response(resp, "app upload")?; + let elapsed = start.elapsed().as_secs(); + println!(" Uploaded Android APK (took {}s)", elapsed); + + Ok(result) } /// Upload an Espresso test-suite APK to BrowserStack. @@ -73,6 +140,10 @@ impl BrowserStackClient { return Err(anyhow!("test suite artifact not found at {:?}", artifact)); } + let file_size = get_file_size(artifact); + println!("Uploading Android test APK ({})...", format_file_size(file_size)); + let start = Instant::now(); + let form = Form::new().file("file", artifact)?; let resp = self .http @@ -82,7 +153,11 @@ impl BrowserStackClient { .send() .context("uploading test suite to BrowserStack")?; - parse_response(resp, "test suite upload") + let result = parse_response(resp, "test suite upload")?; + let elapsed = start.elapsed().as_secs(); + println!(" Uploaded Android test APK (took {}s)", elapsed); + + Ok(result) } pub fn upload_xcuitest_app(&self, artifact: &Path) -> Result { @@ -90,6 +165,10 @@ impl BrowserStackClient { return Err(anyhow!("iOS app artifact not found at {:?}", artifact)); } + let file_size = get_file_size(artifact); + println!("Uploading iOS app IPA ({})...", format_file_size(file_size)); + let start = Instant::now(); + let form = Form::new().file("file", artifact)?; let resp = self .http @@ -99,7 +178,11 @@ impl BrowserStackClient { .send() .context("uploading iOS app to BrowserStack")?; - parse_response(resp, "iOS app upload") + let result = parse_response(resp, "iOS app upload")?; + let elapsed = start.elapsed().as_secs(); + println!(" Uploaded iOS app IPA (took {}s)", elapsed); + + Ok(result) } pub fn upload_xcuitest_test_suite(&self, artifact: &Path) -> Result { @@ -110,6 +193,10 @@ impl BrowserStackClient { )); } + let file_size = get_file_size(artifact); + println!("Uploading iOS XCUITest runner ({})...", format_file_size(file_size)); + let start = Instant::now(); + let form = Form::new().file("file", artifact)?; let resp = self .http @@ -119,7 +206,11 @@ impl BrowserStackClient { .send() .context("uploading iOS XCUITest suite to BrowserStack")?; - parse_response(resp, "iOS XCUITest suite upload") + let result = parse_response(resp, "iOS XCUITest suite upload")?; + let elapsed = start.elapsed().as_secs(); + println!(" Uploaded iOS XCUITest runner (took {}s)", elapsed); + + Ok(result) } pub fn schedule_espresso_run( @@ -245,6 +336,66 @@ impl BrowserStackClient { Ok(()) } + /// List available Android devices for Espresso testing. + pub fn list_espresso_devices(&self) -> Result> { + let json = self.get_json("app-automate/espresso/v2/devices")?; + parse_device_list(json, "espresso") + } + + /// List available iOS devices for XCUITest testing. + pub fn list_xcuitest_devices(&self) -> Result> { + let json = self.get_json("app-automate/xcuitest/v2/devices")?; + parse_device_list(json, "xcuitest") + } + + /// List all available devices (both Android and iOS). + pub fn list_all_devices(&self) -> Result> { + let mut devices = Vec::new(); + + match self.list_espresso_devices() { + Ok(android_devices) => devices.extend(android_devices), + Err(e) => { + eprintln!("Warning: Failed to fetch Android devices: {}", e); + } + } + + match self.list_xcuitest_devices() { + Ok(ios_devices) => devices.extend(ios_devices), + Err(e) => { + eprintln!("Warning: Failed to fetch iOS devices: {}", e); + } + } + + Ok(devices) + } + + /// Validate device specifications against available devices. + /// + /// Returns a validation result with valid devices and any errors for invalid specs. + pub fn validate_devices( + &self, + specs: &[String], + platform: Option<&str>, + ) -> Result { + let available = match platform { + Some("android") | Some("espresso") => self.list_espresso_devices()?, + Some("ios") | Some("xcuitest") => self.list_xcuitest_devices()?, + _ => self.list_all_devices()?, + }; + + let mut valid = Vec::new(); + let mut invalid = Vec::new(); + + for spec in specs { + match validate_device_spec(spec, &available) { + Ok(matched) => valid.push(matched), + Err(error) => invalid.push(error), + } + } + + Ok(DeviceValidationResult { valid, invalid }) + } + /// Get the status of an Espresso build pub fn get_espresso_build_status(&self, build_id: &str) -> Result { let path = format!("app-automate/espresso/v2/builds/{}", build_id); @@ -923,6 +1074,233 @@ fn parse_response(resp: Response, context: &str) -> Result< .with_context(|| format!("parsing BrowserStack API response for {}", context)) } +/// Parse a device list response from BrowserStack API. +fn parse_device_list(json: Value, context: &str) -> Result> { + // BrowserStack returns an array of device objects + let devices = match json { + Value::Array(arr) => arr, + Value::Object(obj) => { + // Some endpoints wrap the list in a "devices" key + obj.get("devices") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + } + _ => { + return Err(anyhow!( + "Unexpected response format from {} devices endpoint", + context + )); + } + }; + + let mut result = Vec::with_capacity(devices.len()); + for device in devices { + // Handle both flat format and nested format + let device_name = device + .get("device") + .or_else(|| device.get("name")) + .or_else(|| device.get("deviceName")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let os = device + .get("os") + .and_then(|v| v.as_str()) + .unwrap_or(if context == "xcuitest" { "ios" } else { "android" }) + .to_string(); + + let os_version = device + .get("os_version") + .or_else(|| device.get("osVersion")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let available = device + .get("available") + .or_else(|| device.get("realMobile")) + .and_then(|v| v.as_bool()); + + result.push(BrowserStackDevice { + device: device_name, + os, + os_version, + available, + }); + } + + Ok(result) +} + +/// Validate a device specification against available devices. +/// +/// The spec can be: +/// - Exact match: "Google Pixel 7-13.0" +/// - Device name only: "Google Pixel 7" (matches any version) +/// - Partial match: "Pixel 7" (fuzzy match) +/// +/// Provides improved suggestions: +/// - If user types "Pixel 7", suggests "Google Pixel 7-13.0", "Google Pixel 7-14.0" +/// - If OS version doesn't match, suggests same device with available versions +/// - Shows top 3 suggestions max +fn validate_device_spec( + spec: &str, + available: &[BrowserStackDevice], +) -> std::result::Result { + let spec_lower = spec.to_lowercase(); + + // First, try exact match on identifier + for device in available { + if device.identifier().to_lowercase() == spec_lower { + return Ok(device.identifier()); + } + } + + // Try matching device name only (for specs without version) + if !spec.contains('-') { + for device in available { + if device.device.to_lowercase() == spec_lower { + // Return the full identifier with version + return Ok(device.identifier()); + } + } + } + + // Parse spec to see if it has a version component + let (spec_device, spec_version) = if let Some(dash_pos) = spec.rfind('-') { + let device_part = &spec[..dash_pos]; + let version_part = &spec[dash_pos + 1..]; + // Only treat as version if it looks like a version number + if version_part.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { + (device_part.to_lowercase(), Some(version_part.to_lowercase())) + } else { + (spec_lower.clone(), None) + } + } else { + (spec_lower.clone(), None) + }; + + // Check if the device name matches but OS version is wrong + if let Some(ref version) = spec_version { + let matching_devices: Vec<&BrowserStackDevice> = available + .iter() + .filter(|d| d.device.to_lowercase() == spec_device) + .collect(); + + if !matching_devices.is_empty() { + // Device exists but with different versions + let available_versions: Vec = matching_devices + .iter() + .map(|d| d.identifier()) + .collect(); + + let mut suggestions = available_versions; + suggestions.sort(); + suggestions.truncate(3); + + return Err(DeviceValidationError { + spec: spec.to_string(), + reason: format!( + "OS version '{}' not available for this device", + version + ), + suggestions, + }); + } + } + + // Try fuzzy matching - prioritize matches that start with the spec + let mut scored_suggestions: Vec<(u32, String)> = Vec::new(); + for device in available { + let id = device.identifier(); + let id_lower = id.to_lowercase(); + let device_lower = device.device.to_lowercase(); + + // Score based on how well the spec matches + let score = if device_lower.starts_with(&spec_device) { + // High priority: device name starts with spec + 100 + } else if device_lower.contains(&spec_device) { + // Medium priority: device name contains spec + 50 + } else if id_lower.contains(&spec_lower) { + // Lower priority: full identifier contains spec + 25 + } else { + // Check for partial word matches (e.g., "Pixel 7" in "Google Pixel 7") + let spec_words: Vec<&str> = spec_lower.split_whitespace().collect(); + let device_words: Vec<&str> = device_lower.split_whitespace().collect(); + + let matches = spec_words.iter().filter(|sw| + device_words.iter().any(|dw| dw.contains(*sw)) + ).count(); + + if matches == spec_words.len() && !spec_words.is_empty() { + // All words from spec found in device name + 75 + } else if matches > 0 { + // Some words match + 10 * matches as u32 + } else { + 0 + } + }; + + if score > 0 { + scored_suggestions.push((score, id)); + } + } + + // Sort by score (descending), then alphabetically + scored_suggestions.sort_by(|a, b| { + b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)) + }); + + // Take top 3 unique suggestions + let suggestions: Vec = scored_suggestions + .into_iter() + .map(|(_, id)| id) + .take(3) + .collect(); + + Err(DeviceValidationError { + spec: spec.to_string(), + reason: if suggestions.is_empty() { + "No matching device found".to_string() + } else { + "Device not found, but similar devices are available".to_string() + }, + suggestions, + }) +} + +/// Format a helpful error message for missing BrowserStack credentials. +pub fn format_credentials_error(_missing_username: bool, _missing_access_key: bool) -> String { + let mut message = String::from("BrowserStack credentials not configured.\n\n"); + + message.push_str("Set credentials using one of these methods:\n\n"); + + message.push_str(" 1. Environment variables:\n"); + message.push_str(" export BROWSERSTACK_USERNAME=your_username\n"); + message.push_str(" export BROWSERSTACK_ACCESS_KEY=your_access_key\n\n"); + + message.push_str(" 2. Config file (bench-config.toml):\n"); + message.push_str(" [browserstack]\n"); + message.push_str(" app_automate_username = \"your_username\"\n"); + message.push_str(" app_automate_access_key = \"your_access_key\"\n\n"); + + message.push_str(" 3. .env.local file in project root:\n"); + message.push_str(" BROWSERSTACK_USERNAME=your_username\n"); + message.push_str(" BROWSERSTACK_ACCESS_KEY=your_access_key\n\n"); + + message.push_str("Get credentials: https://app-automate.browserstack.com/\n"); + message.push_str("(Navigate to Settings -> Access Key)\n"); + + message +} + #[cfg(test)] mod tests { use super::*; @@ -1615,4 +1993,208 @@ BENCH_REPORT_JSON_END let json = result.unwrap(); assert_eq!(json, input); } + + #[test] + fn device_identifier_format() { + let device = BrowserStackDevice { + device: "Google Pixel 7".to_string(), + os: "android".to_string(), + os_version: "13.0".to_string(), + available: Some(true), + }; + assert_eq!(device.identifier(), "Google Pixel 7-13.0"); + } + + #[test] + fn validate_device_spec_exact_match() { + let devices = vec![ + BrowserStackDevice { + device: "Google Pixel 7".to_string(), + os: "android".to_string(), + os_version: "13.0".to_string(), + available: Some(true), + }, + BrowserStackDevice { + device: "iPhone 14".to_string(), + os: "ios".to_string(), + os_version: "16".to_string(), + available: Some(true), + }, + ]; + + // Exact match should work + let result = validate_device_spec("Google Pixel 7-13.0", &devices); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "Google Pixel 7-13.0"); + + // Case-insensitive match + let result = validate_device_spec("google pixel 7-13.0", &devices); + assert!(result.is_ok()); + } + + #[test] + fn validate_device_spec_device_name_only() { + let devices = vec![BrowserStackDevice { + device: "Google Pixel 7".to_string(), + os: "android".to_string(), + os_version: "13.0".to_string(), + available: Some(true), + }]; + + // Device name without version should match and return full identifier + let result = validate_device_spec("Google Pixel 7", &devices); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "Google Pixel 7-13.0"); + } + + #[test] + fn validate_device_spec_suggestions() { + let devices = vec![ + BrowserStackDevice { + device: "Google Pixel 7".to_string(), + os: "android".to_string(), + os_version: "13.0".to_string(), + available: Some(true), + }, + BrowserStackDevice { + device: "Google Pixel 7 Pro".to_string(), + os: "android".to_string(), + os_version: "13.0".to_string(), + available: Some(true), + }, + ]; + + // Partial match should give suggestions + let result = validate_device_spec("Pixel 7", &devices); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(!error.suggestions.is_empty()); + assert!(error.suggestions.iter().any(|s| s.contains("Pixel 7"))); + } + + #[test] + fn validate_device_spec_no_match() { + let devices = vec![BrowserStackDevice { + device: "Google Pixel 7".to_string(), + os: "android".to_string(), + os_version: "13.0".to_string(), + available: Some(true), + }]; + + // No match at all + let result = validate_device_spec("iPhone 14", &devices); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.suggestions.is_empty()); + assert_eq!(error.reason, "No matching device found"); + } + + #[test] + fn validate_device_spec_wrong_os_version() { + let devices = vec![ + BrowserStackDevice { + device: "Google Pixel 7".to_string(), + os: "android".to_string(), + os_version: "13.0".to_string(), + available: Some(true), + }, + BrowserStackDevice { + device: "Google Pixel 7".to_string(), + os: "android".to_string(), + os_version: "14.0".to_string(), + available: Some(true), + }, + ]; + + // Wrong OS version should suggest available versions + let result = validate_device_spec("Google Pixel 7-12.0", &devices); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.reason.contains("OS version")); + assert!(error.suggestions.contains(&"Google Pixel 7-13.0".to_string())); + assert!(error.suggestions.contains(&"Google Pixel 7-14.0".to_string())); + } + + #[test] + fn validate_device_spec_limits_suggestions_to_three() { + let devices = vec![ + BrowserStackDevice { + device: "Google Pixel 6".to_string(), + os: "android".to_string(), + os_version: "12.0".to_string(), + available: Some(true), + }, + BrowserStackDevice { + device: "Google Pixel 7".to_string(), + os: "android".to_string(), + os_version: "13.0".to_string(), + available: Some(true), + }, + BrowserStackDevice { + device: "Google Pixel 7 Pro".to_string(), + os: "android".to_string(), + os_version: "13.0".to_string(), + available: Some(true), + }, + BrowserStackDevice { + device: "Google Pixel 8".to_string(), + os: "android".to_string(), + os_version: "14.0".to_string(), + available: Some(true), + }, + BrowserStackDevice { + device: "Google Pixel 8 Pro".to_string(), + os: "android".to_string(), + os_version: "14.0".to_string(), + available: Some(true), + }, + ]; + + // Should limit to 3 suggestions + let result = validate_device_spec("Pixel", &devices); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.suggestions.len() <= 3, "Should have at most 3 suggestions, got {}", error.suggestions.len()); + } + + #[test] + fn format_credentials_error_both_missing() { + let error = format_credentials_error(true, true); + assert!(error.contains("BrowserStack credentials not configured")); + assert!(error.contains("BROWSERSTACK_USERNAME")); + assert!(error.contains("BROWSERSTACK_ACCESS_KEY")); + assert!(error.contains(".env.local")); + assert!(error.contains("bench-config.toml")); + assert!(error.contains("https://app-automate.browserstack.com/")); + } + + #[test] + fn format_credentials_error_includes_all_methods() { + let error = format_credentials_error(true, false); + // Should always include all three methods regardless of what's missing + assert!(error.contains("Environment variables")); + assert!(error.contains("Config file")); + assert!(error.contains(".env.local")); + } + + #[test] + fn parse_device_list_array_format() { + let json = serde_json::json!([ + { + "device": "Google Pixel 7", + "os": "android", + "os_version": "13.0" + }, + { + "device": "iPhone 14", + "os": "ios", + "os_version": "16" + } + ]); + + let devices = parse_device_list(json, "espresso").unwrap(); + assert_eq!(devices.len(), 2); + assert_eq!(devices[0].device, "Google Pixel 7"); + assert_eq!(devices[1].device, "iPhone 14"); + } } diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 0d75a3d..1cdc20d 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -186,6 +186,8 @@ enum Command { fetch_poll_interval_secs: u64, #[arg(long, default_value_t = 300)] fetch_timeout_secs: u64, + #[arg(long, help = "Show simplified step-by-step progress output")] + progress: bool, }, /// Scaffold a base config file for the CLI. Init { @@ -244,6 +246,8 @@ enum Command { output_dir: Option, #[arg(long, help = "Path to the benchmark crate (default: auto-detect bench-mobile/ or crates/{crate})")] crate_path: Option, + #[arg(long, help = "Show simplified step-by-step progress output")] + progress: bool, }, /// Package iOS app as IPA for distribution or testing. PackageIpa { @@ -267,6 +271,98 @@ enum Command { }, /// List all discovered benchmark functions (Phase 1 MVP). List, + /// Verify benchmark setup: registry, spec, artifacts, and optional smoke test. + /// + /// This command validates: + /// - Registry has benchmark functions registered + /// - Spec file exists and is valid (if --spec-path provided) + /// - Artifacts are present and consistent (if --check-artifacts) + /// - Runs a local smoke test (if --smoke-test and function is specified) + Verify { + #[arg(long, value_enum, help = "Target platform to verify artifacts for")] + target: Option, + #[arg(long, help = "Path to bench_spec.json to validate")] + spec_path: Option, + #[arg(long, help = "Check that build artifacts exist")] + check_artifacts: bool, + #[arg(long, help = "Run a local smoke test with minimal iterations")] + smoke_test: bool, + #[arg(long, help = "Function name to verify/smoke test")] + function: Option, + #[arg(long, help = "Output directory for mobile artifacts (default: target/mobench)")] + output_dir: Option, + }, + /// Display summary statistics from a benchmark report JSON file. + /// + /// Prints avg/min/max/median, sample count, device, and OS version + /// from the specified report file. + Summary { + #[arg(help = "Path to the benchmark report JSON file")] + report: PathBuf, + #[arg(long, help = "Output format: text (default), json, or csv")] + format: Option, + }, + /// List available BrowserStack devices for testing. + /// + /// Fetches and displays the list of available devices from BrowserStack + /// that can be used with the --devices flag in the run command. + /// + /// Examples: + /// mobench devices # List all devices + /// mobench devices --platform android # List Android devices only + /// mobench devices --json # Output as JSON + /// mobench devices --validate "Google Pixel 7-13.0" # Validate a device spec + Devices { + #[arg(long, value_enum, help = "Filter by platform (android or ios)")] + platform: Option, + #[arg(long, help = "Output as JSON")] + json: bool, + #[arg(long, help = "Validate device specs against available devices")] + validate: Vec, + }, + /// Check prerequisites for building mobile artifacts. + /// + /// Validates that all required tools and configurations are in place + /// before attempting a build. This includes checking for: + /// + /// - Android: ANDROID_NDK_HOME, cargo-ndk, Rust targets + /// - iOS: Xcode, xcodegen, Rust targets + /// - Both: cargo, rustup + /// + /// Examples: + /// cargo mobench check --target android + /// cargo mobench check --target ios + /// cargo mobench check --target android --format json + Check { + /// Target platform (android or ios) + #[arg(long, short, value_enum)] + target: SdkTarget, + /// Output format (text or json) + #[arg(long, default_value = "text")] + format: CheckOutputFormat, + }, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +enum DevicePlatform { + Android, + Ios, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +enum SummaryFormat { + Text, + Json, + Csv, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +enum CheckOutputFormat { + Text, + Json, } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] @@ -458,6 +554,7 @@ pub fn run() -> Result<()> { fetch_output_dir, fetch_poll_interval_secs, fetch_timeout_secs, + progress, } => { let spec = resolve_run_spec( target, @@ -472,48 +569,145 @@ pub fn run() -> Result<()> { release, )?; let summary_paths = resolve_summary_paths(output.as_deref())?; - println!( - "Preparing benchmark run for {:?}: {} (iterations={}, warmup={})", - spec.target, spec.function, spec.iterations, spec.warmup - ); - println!("Build profile: {}", if release { "release" } else { "debug" }); - persist_mobile_spec(&spec)?; - if !spec.devices.is_empty() { - println!("Devices: {}", spec.devices.join(", ")); - } - println!("JSON summary will be written to {:?}", summary_paths.json); - println!( - "Markdown summary will be written to {:?}", - summary_paths.markdown - ); - if summary_csv { - println!("CSV summary will be written to {:?}", summary_paths.csv); + let root = repo_root()?; + let output_dir = root.join("target/mobench"); + + // Validate device specs early to catch errors before building (C2: Device validation) + if !spec.devices.is_empty() && !local_only { + if let Ok(creds) = resolve_browserstack_credentials(spec.browserstack.as_ref()) { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + )?; + + let platform_str = match spec.target { + MobileTarget::Android => Some("android"), + MobileTarget::Ios => Some("ios"), + }; + + println!("Validating device specifications..."); + let validation = client.validate_devices(&spec.devices, platform_str)?; + + if !validation.invalid.is_empty() { + println!(); + println!("Invalid device specifications:"); + for error in &validation.invalid { + println!(" [ERROR] {}: {}", error.spec, error.reason); + if !error.suggestions.is_empty() { + println!(" Did you mean:"); + for suggestion in &error.suggestions { + println!(" - {}", suggestion); + } + } + } + println!(); + println!("Use 'cargo mobench devices' to see available devices."); + bail!( + "{} of {} device specs are invalid. Fix them before running.", + validation.invalid.len(), + spec.devices.len() + ); + } + println!(" All {} device(s) validated successfully.", validation.valid.len()); + } + } + + // Print resolved spec summary (A5: Better CLI output) + if !progress { + println!(); + println!("=== Benchmark Run Configuration ==="); + println!(" Target: {:?}", spec.target); + println!(" Function: {}", spec.function); + println!(" Iterations: {}", spec.iterations); + println!(" Warmup: {}", spec.warmup); + println!(" Profile: {}", if release { "release" } else { "debug" }); + if !spec.devices.is_empty() { + println!(" Devices: {}", spec.devices.join(", ")); + } else { + println!(" Devices: (none - local build only)"); + } + println!(); + + // Print artifact locations + println!("=== Output Locations ==="); + println!(" Build output: {}", output_dir.display()); + match spec.target { + MobileTarget::Android => { + println!(" Android APK: {}/android/app/build/outputs/apk/", output_dir.display()); + println!(" bench_spec.json: {}/android/app/src/main/assets/", output_dir.display()); + } + MobileTarget::Ios => { + println!(" iOS xcframework: {}/ios/", output_dir.display()); + println!(" bench_spec.json: {}/ios/BenchRunner/BenchRunner/Resources/", output_dir.display()); + if let Some(ref xcui) = spec.ios_xcuitest { + println!(" iOS App IPA: {}", xcui.app.display()); + println!(" XCUITest Runner: {}", xcui.test_suite.display()); + } + } + } + println!(" JSON summary: {}", summary_paths.json.display()); + println!(" Markdown: {}", summary_paths.markdown.display()); + if summary_csv { + println!(" CSV: {}", summary_paths.csv.display()); + } + println!(); + } + + // A2: Validate that the requested benchmark function exists (if we can detect it) + if !progress { + validate_benchmark_function(&root, &spec.function)?; } + // Persist the spec and metadata to mobile app bundles + if progress { + println!("[1/4] Preparing benchmark spec..."); + } + persist_mobile_spec(&spec, release)?; + // Skip local smoke test - sample-fns uses direct dispatch, not inventory registry // Benchmarks will run on the actual mobile device - println!("Skipping local smoke test - benchmarks will run on mobile device"); + if !progress { + println!("Skipping local smoke test - benchmarks will run on mobile device"); + } let local_report = json!({ "skipped": true, "reason": "Local smoke test disabled - benchmarks run on mobile device only" }); let mut remote_run = None; let artifacts = if local_only { - println!("Skipping mobile build: --local-only set"); + if !progress { + println!("Skipping mobile build: --local-only set"); + } None } else { match spec.target { MobileTarget::Android => { + if progress { + println!("[2/4] Building Android APK..."); + } else { + println!("Building for Android..."); + println!(" Building Rust library for Android targets..."); + } let ndk = std::env::var("ANDROID_NDK_HOME").context( "ANDROID_NDK_HOME must be set for Android builds. Example: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/", )?; let build = run_android_build(&ndk, release)?; let apk = build.app_path; - println!("Built Android APK at {:?}", apk); + if !progress { + println!("\u{2713} Built Android APK at {:?}", apk); + } if spec.devices.is_empty() { - println!("Skipping BrowserStack upload/run: no devices provided"); + if !progress { + println!("Skipping BrowserStack upload/run: no devices provided"); + } Some(MobileArtifacts::Android { apk }) } else { + if progress { + println!("[3/4] Uploading to BrowserStack..."); + } let test_apk = build.test_suite_path.as_ref().context( "Android test suite APK missing. Run `cargo mobench build --target android` or `./gradlew assembleDebugAndroidTest` in target/mobench/android", )?; @@ -523,13 +717,26 @@ pub fn run() -> Result<()> { } } MobileTarget::Ios => { + if progress { + println!("[2/4] Building iOS xcframework..."); + } else { + println!("Building for iOS..."); + println!(" Building Rust library for iOS targets..."); + } let (xcframework, header) = run_ios_build(release)?; - println!("Built iOS xcframework at {:?}", xcframework); + if !progress { + println!("\u{2713} Built iOS xcframework at {:?}", xcframework); + } let ios_xcuitest = spec.ios_xcuitest.clone(); if spec.devices.is_empty() { - println!("Skipping BrowserStack upload/run: no devices provided"); + if !progress { + println!("Skipping BrowserStack upload/run: no devices provided"); + } } else { + if progress { + println!("[3/4] Uploading to BrowserStack..."); + } let xcui = spec.ios_xcuitest.as_ref().context( "iOS XCUITest artifacts required when targeting BrowserStack devices; provide --ios-app and --ios-test-suite or set ios_xcuitest in the config", )?; @@ -668,6 +875,19 @@ pub fn run() -> Result<()> { run_summary.summary = build_summary(&run_summary)?; write_summary(&run_summary, &summary_paths, summary_csv)?; + + // Print clear completion summary + println!(); + println!("\u{2713} Benchmark complete!"); + println!(); + println!("Results saved to:"); + println!(" * {} (machine-readable)", summary_paths.json.display()); + println!(" * {} (human-readable)", summary_paths.markdown.display()); + if summary_csv { + println!(" * {} (spreadsheet)", summary_paths.csv.display()); + } + println!(); + println!("View results: cat {} | jq '.summary'", summary_paths.json.display()); } Command::Init { output, target } => { write_config_template(&output, target)?; @@ -725,8 +945,9 @@ pub fn run() -> Result<()> { release, output_dir, crate_path, + progress, } => { - cmd_build(target, release, output_dir, crate_path, cli.dry_run, cli.verbose)?; + cmd_build(target, release, output_dir, crate_path, cli.dry_run, cli.verbose, progress)?; } Command::PackageIpa { scheme, method, output_dir } => { cmd_package_ipa(&scheme, method, output_dir)?; @@ -737,6 +958,29 @@ pub fn run() -> Result<()> { Command::List => { cmd_list()?; } + Command::Verify { + target, + spec_path, + check_artifacts, + smoke_test, + function, + output_dir, + } => { + cmd_verify(target, spec_path, check_artifacts, smoke_test, function, output_dir)?; + } + Command::Summary { report, format } => { + cmd_summary(&report, format)?; + } + Command::Devices { + platform, + json, + validate, + } => { + cmd_devices(platform, json, validate)?; + } + Command::Check { target, format } => { + cmd_check(target, format)?; + } } Ok(()) @@ -1343,7 +1587,100 @@ struct ResolvedBrowserStack { project: Option, } +/// Represents artifacts validation error details for BrowserStack uploads. +#[derive(Debug)] +struct ArtifactValidationError { + missing_artifacts: Vec<(String, PathBuf)>, + target: MobileTarget, +} + +impl ArtifactValidationError { + fn format_error(&self) -> String { + let mut msg = String::from("Missing required artifacts for BrowserStack run:\n\n"); + + for (name, path) in &self.missing_artifacts { + msg.push_str(&format!(" x {} not found at: {}\n", name, path.display())); + } + + msg.push('\n'); + msg.push_str("To fix, run:\n"); + match self.target { + MobileTarget::Android => { + msg.push_str(" cargo mobench build --target android\n"); + } + MobileTarget::Ios => { + msg.push_str(" cargo mobench build --target ios\n"); + msg.push_str(" cargo mobench package-ipa --method adhoc\n"); + msg.push_str(" cargo mobench package-xcuitest\n"); + } + } + + msg + } +} + +/// Validates that all required artifacts exist before attempting a BrowserStack upload. +/// +/// This function checks for the presence of required files early to provide clear +/// error messages before starting any uploads. +/// +/// # Arguments +/// * `target` - The target platform (Android or iOS) +/// * `apk` - For Android: path to the app APK +/// * `test_apk` - For Android: path to the test APK +/// * `ios_artifacts` - For iOS: the app and test suite paths +/// +/// # Returns +/// * `Ok(())` if all artifacts exist +/// * `Err` with detailed message about missing artifacts and how to fix +fn validate_artifacts_for_browserstack( + target: MobileTarget, + apk: Option<&Path>, + test_apk: Option<&Path>, + ios_artifacts: Option<&IosXcuitestArtifacts>, +) -> Result<()> { + let mut missing = Vec::new(); + + match target { + MobileTarget::Android => { + if let Some(apk_path) = apk { + if !apk_path.exists() { + missing.push(("Android APK".to_string(), apk_path.to_path_buf())); + } + } + if let Some(test_apk_path) = test_apk { + if !test_apk_path.exists() { + missing.push(("Android test APK".to_string(), test_apk_path.to_path_buf())); + } + } + } + MobileTarget::Ios => { + if let Some(artifacts) = ios_artifacts { + if !artifacts.app.exists() { + missing.push(("iOS app IPA".to_string(), artifacts.app.clone())); + } + if !artifacts.test_suite.exists() { + missing.push(("iOS XCUITest runner".to_string(), artifacts.test_suite.clone())); + } + } + } + } + + if !missing.is_empty() { + let error = ArtifactValidationError { + missing_artifacts: missing, + target, + }; + bail!("{}", error.format_error()); + } + + Ok(()) +} + fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path, test_apk: &Path) -> Result { + // Validate artifacts exist before attempting upload + validate_artifacts_for_browserstack(MobileTarget::Android, Some(apk), Some(test_apk), None)?; + let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; let client = BrowserStackClient::new( BrowserStackAuth { @@ -1365,11 +1702,15 @@ fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path, test_apk: &Path) -> &upload.app_url, &test_upload.test_suite_url, )?; - println!( - "Queued BrowserStack Espresso build {} for devices: {}", - run.build_id, - spec.devices.join(", ") - ); + + // Print dashboard link early so users can monitor progress + println!(); + println!("BrowserStack build started!"); + println!(" Build ID: {}", run.build_id); + println!(" Devices: {}", spec.devices.join(", ")); + println!(" Dashboard: https://app-automate.browserstack.com/dashboard/v2/builds/{}", run.build_id); + println!(); + println!("Waiting for results..."); Ok(RemoteRun::Android { app_url: upload.app_url, @@ -1381,6 +1722,9 @@ fn trigger_browserstack_xcuitest( spec: &RunSpec, artifacts: &IosXcuitestArtifacts, ) -> Result { + // Validate artifacts exist before attempting upload + validate_artifacts_for_browserstack(MobileTarget::Ios, None, None, Some(artifacts))?; + let creds = resolve_browserstack_credentials(spec.browserstack.as_ref())?; let client = BrowserStackClient::new( BrowserStackAuth { @@ -1390,19 +1734,6 @@ fn trigger_browserstack_xcuitest( creds.project.clone(), )?; - if !artifacts.app.exists() { - bail!( - "iOS app artifact not found at {:?}; provide a .ipa or zipped .app", - artifacts.app - ); - } - if !artifacts.test_suite.exists() { - bail!( - "iOS XCUITest test suite artifact not found at {:?}; provide the zipped test runner bundle", - artifacts.test_suite - ); - } - let app_upload = client.upload_xcuitest_app(&artifacts.app)?; let test_upload = client.upload_xcuitest_test_suite(&artifacts.test_suite)?; let run = client.schedule_xcuitest_run( @@ -1410,11 +1741,15 @@ fn trigger_browserstack_xcuitest( &app_upload.app_url, &test_upload.test_suite_url, )?; - println!( - "Queued BrowserStack XCUITest build {} for devices: {}", - run.build_id, - spec.devices.join(", ") - ); + + // Print dashboard link early so users can monitor progress + println!(); + println!("BrowserStack build started!"); + println!(" Build ID: {}", run.build_id); + println!(" Devices: {}", spec.devices.join(", ")); + println!(" Dashboard: https://app-automate.browserstack.com/dashboard/v2/builds/{}", run.build_id); + println!(); + println!("Waiting for results..."); Ok(RemoteRun::Ios { app_url: app_upload.app_url, @@ -1459,16 +1794,18 @@ fn resolve_browserstack_credentials( project = Some(val); } - let username = username.filter(|s| !s.is_empty()).ok_or_else(|| { - anyhow!("BrowserStack username missing; set BROWSERSTACK_USERNAME or provide in config") - })?; - let access_key = access_key.filter(|s| !s.is_empty()).ok_or_else(|| { - anyhow!("BrowserStack access key missing; set BROWSERSTACK_ACCESS_KEY or provide in config") - })?; + // Check what's missing and provide helpful error message + let missing_username = username.as_deref().map(str::is_empty).unwrap_or(true); + let missing_access_key = access_key.as_deref().map(str::is_empty).unwrap_or(true); + + if missing_username || missing_access_key { + let error_msg = browserstack::format_credentials_error(missing_username, missing_access_key); + bail!("{}", error_msg); + } Ok(ResolvedBrowserStack { - username, - access_key, + username: username.unwrap(), + access_key: access_key.unwrap(), project, }) } @@ -1498,7 +1835,90 @@ fn run_local_smoke(spec: &RunSpec) -> Result { serde_json::to_value(&report).context("serializing benchmark report") } -fn persist_mobile_spec(spec: &RunSpec) -> Result<()> { +/// Validates that the benchmark function exists in the crate source. +/// +/// This provides early feedback when a function name is misspelled or doesn't exist. +/// If validation fails, it warns but continues (the final validation happens on device). +fn validate_benchmark_function(project_root: &Path, function_name: &str) -> Result<()> { + // Try to find the benchmark crate + let crate_name = detect_bench_mobile_crate_name(project_root).ok(); + + // Check common crate locations + let search_dirs = [ + project_root.join("bench-mobile"), + project_root.join("crates/sample-fns"), + project_root.to_path_buf(), + ]; + + // Extract the crate name from the function (e.g., "sample_fns::fibonacci" -> "sample_fns") + let function_crate = function_name.split("::").next().unwrap_or(""); + + let mut found_any_benchmarks = false; + let mut found_function = false; + + for dir in &search_dirs { + if !dir.join("Cargo.toml").exists() { + continue; + } + + // Determine the crate name for this directory + let dir_crate_name = crate_name.as_deref().unwrap_or(function_crate); + + // Detect all benchmarks in this directory + let benchmarks = mobench_sdk::codegen::detect_all_benchmarks(dir, dir_crate_name); + + if !benchmarks.is_empty() { + found_any_benchmarks = true; + + // Check if our function is in the list + if benchmarks.iter().any(|b| b == function_name) { + found_function = true; + break; + } + + // Also check without crate prefix (in case user specified just the function name) + let simple_name = function_name.split("::").last().unwrap_or(function_name); + if benchmarks.iter().any(|b| b.ends_with(&format!("::{}", simple_name))) { + found_function = true; + break; + } + } + } + + if found_any_benchmarks && !found_function { + // We found benchmarks but not the one requested - this is likely an error + println!("=== Warning ==="); + println!(" Benchmark function '{}' was not found in the source code.", function_name); + println!(" Available benchmarks:"); + for dir in &search_dirs { + if !dir.join("Cargo.toml").exists() { + continue; + } + let dir_crate_name = crate_name.as_deref().unwrap_or(function_crate); + let benchmarks = mobench_sdk::codegen::detect_all_benchmarks(dir, dir_crate_name); + for bench in benchmarks { + println!(" - {}", bench); + } + } + println!(); + println!(" The run will continue, but the benchmark may fail on the device."); + println!(" Tip: Use 'cargo mobench list' to see all available benchmarks."); + println!(); + } else if !found_any_benchmarks { + // No benchmarks found at all - might be using direct dispatch + println!("=== Note ==="); + println!(" Could not validate benchmark function '{}' (no #[benchmark] functions found).", function_name); + println!(" This is normal for projects using direct FFI dispatch (like sample-fns)."); + println!(); + } else { + // Function validated successfully + println!("Benchmark function '{}' validated.", function_name); + } + + Ok(()) +} + +fn persist_mobile_spec(spec: &RunSpec, release: bool) -> Result<()> { let root = repo_root()?; let payload = json!({ "function": spec.function, @@ -1506,20 +1926,75 @@ fn persist_mobile_spec(spec: &RunSpec) -> Result<()> { "warmup": spec.warmup, }); let contents = serde_json::to_string_pretty(&payload)?; - let targets = [ + + // Write to legacy mobile-spec locations for backward compatibility + let legacy_targets = [ root.join("target/mobile-spec/android/bench_spec.json"), root.join("target/mobile-spec/ios/bench_spec.json"), ]; - for path in targets { + for path in legacy_targets { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("creating directory {:?}", parent))?; } write_file(&path, contents.as_bytes())?; } + + // IMPORTANT: Also embed the spec directly into the mobile app bundles + // This ensures the requested benchmark function is always used, even when + // the app is run via BrowserStack where file paths are different. + let mobench_output_dir = root.join("target/mobench"); + let apps_exist = mobench_output_dir.join("android").exists() || mobench_output_dir.join("ios").exists(); + + if let Err(e) = embed_spec_into_apps(&mobench_output_dir, spec) { + // Only warn if the apps don't exist yet - they'll be created during build + if apps_exist { + println!("Warning: Failed to embed bench spec into app bundles: {}", e); + } + } else if apps_exist { + println!("Embedded bench_spec.json in mobile app bundles"); + } + + // B3: Embed build metadata (bench_meta.json) for artifact correlation + let profile = if release { "release" } else { "debug" }; + let target_str = match spec.target { + MobileTarget::Android => "android", + MobileTarget::Ios => "ios", + }; + + if let Err(e) = embed_meta_into_apps(&mobench_output_dir, spec, target_str, profile) { + if apps_exist { + println!("Warning: Failed to embed bench meta into app bundles: {}", e); + } + } else if apps_exist { + println!("Embedded bench_meta.json with build metadata"); + } + Ok(()) } +/// Embeds the benchmark spec into Android assets and iOS bundle resources. +fn embed_spec_into_apps(output_dir: &Path, spec: &RunSpec) -> Result<()> { + let embedded_spec = mobench_sdk::builders::EmbeddedBenchSpec { + function: spec.function.clone(), + iterations: spec.iterations, + warmup: spec.warmup, + }; + mobench_sdk::builders::embed_bench_spec(output_dir, &embedded_spec) + .map_err(|e| anyhow!("Failed to embed bench spec: {}", e)) +} + +/// Embeds build metadata (bench_meta.json) into Android assets and iOS bundle resources. +fn embed_meta_into_apps(output_dir: &Path, spec: &RunSpec, target: &str, profile: &str) -> Result<()> { + let embedded_spec = mobench_sdk::builders::EmbeddedBenchSpec { + function: spec.function.clone(), + iterations: spec.iterations, + warmup: spec.warmup, + }; + mobench_sdk::builders::embed_bench_meta(output_dir, &embedded_spec, target, profile) + .map_err(|e| anyhow!("Failed to embed bench meta: {}", e)) +} + #[derive(Debug)] struct SummaryPaths { json: PathBuf, @@ -1637,6 +2112,125 @@ fn write_summary(summary: &RunSummary, paths: &SummaryPaths, summary_csv: bool) Ok(()) } +/// Print a final summary with all artifact correlation information (C3). +#[allow(dead_code)] +fn print_run_completion_summary( + summary: &RunSummary, + paths: &SummaryPaths, + output_dir: &Path, +) -> Result<()> { + println!(); + println!("=== Run Completion Summary ==="); + println!(); + + // Build ID and platform + if let Some(ref remote) = summary.remote_run { + let (build_id, platform) = match remote { + RemoteRun::Android { build_id, .. } => (build_id, "Android/Espresso"), + RemoteRun::Ios { build_id, .. } => (build_id, "iOS/XCUITest"), + }; + println!("BrowserStack Run:"); + println!(" Build ID: {}", build_id); + println!(" Platform: {}", platform); + println!( + " Dashboard: https://app-automate.browserstack.com/dashboard/v2/builds/{}", + build_id + ); + println!(); + + // Fetch command for later retrieval + let target_str = match summary.spec.target { + MobileTarget::Android => "android", + MobileTarget::Ios => "ios", + }; + println!("Fetch Results Later:"); + println!( + " cargo mobench fetch --target {} --build-id {} --output-dir ./results", + target_str, build_id + ); + println!(); + } + + // Devices tested + if !summary.spec.devices.is_empty() { + println!("Devices Tested ({}):", summary.spec.devices.len()); + for device in &summary.spec.devices { + println!(" - {}", device); + } + println!(); + } + + // Results summary by device + if !summary.summary.device_summaries.is_empty() { + println!("Results Summary:"); + for device_summary in &summary.summary.device_summaries { + println!(" Device: {}", device_summary.device); + for bench in &device_summary.benchmarks { + let median = bench + .median_ns + .map(format_duration_smart) + .unwrap_or_else(|| "-".to_string()); + let samples = bench.samples; + println!( + " {} - median: {}, samples: {}", + bench.function, median, samples + ); + } + } + println!(); + } + + // Artifact locations + println!("Output Artifacts:"); + println!(" JSON Summary: {}", paths.json.display()); + println!(" Markdown Report: {}", paths.markdown.display()); + if paths.csv.exists() { + println!(" CSV Data: {}", paths.csv.display()); + } + + // Build artifacts + match summary.spec.target { + MobileTarget::Android => { + let apk_dir = output_dir.join("android/app/build/outputs/apk"); + if apk_dir.exists() { + println!(" Android APK: {}/", apk_dir.display()); + } + } + MobileTarget::Ios => { + let ios_dir = output_dir.join("ios"); + if ios_dir.exists() { + println!(" iOS Framework: {}/", ios_dir.display()); + } + } + } + + // Bench spec and meta locations + let spec_path = match summary.spec.target { + MobileTarget::Android => output_dir.join("android/app/src/main/assets/bench_spec.json"), + MobileTarget::Ios => { + output_dir.join("ios/BenchRunner/BenchRunner/Resources/bench_spec.json") + } + }; + if spec_path.exists() { + println!(" Bench Spec: {}", spec_path.display()); + } + + let meta_path = match summary.spec.target { + MobileTarget::Android => output_dir.join("android/app/src/main/assets/bench_meta.json"), + MobileTarget::Ios => { + output_dir.join("ios/BenchRunner/BenchRunner/Resources/bench_meta.json") + } + }; + if meta_path.exists() { + println!(" Bench Meta: {}", meta_path.display()); + } + + println!(); + println!("Run completed successfully."); + + Ok(()) +} + fn ensure_parent_dir(path: &Path) -> Result<()> { if let Some(parent) = path.parent() && !parent.as_os_str().is_empty() @@ -2122,9 +2716,107 @@ fn cmd_build( crate_path: Option, dry_run: bool, verbose: bool, + progress: bool, ) -> Result<()> { // Load config file if present (mobench.toml) let config_resolver = config::ConfigResolver::new().unwrap_or_default(); + + // Progress mode: simplified output + if progress { + let project_root = std::env::current_dir().context("Failed to get current directory")?; + let crate_name = detect_bench_mobile_crate_name(&project_root) + .unwrap_or_else(|_| "bench-mobile".to_string()); + let effective_output_dir = output_dir.or_else(|| config_resolver.output_dir().map(|p| p.to_path_buf())); + + let build_config = mobench_sdk::BuildConfig { + target: target.into(), + profile: if release { + mobench_sdk::BuildProfile::Release + } else { + mobench_sdk::BuildProfile::Debug + }, + incremental: true, + }; + + match target { + SdkTarget::Android => { + println!("[1/3] Building Rust library..."); + let mut builder = + mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name) + .verbose(false) + .dry_run(dry_run); + if let Some(ref dir) = effective_output_dir { + builder = builder.output_dir(dir); + } + if let Some(ref path) = crate_path { + builder = builder.crate_dir(path); + } + println!("[2/3] Building Android APK..."); + let result = builder.build(&build_config)?; + println!("[3/3] Done!"); + if !dry_run { + println!("\n\u{2713} APK: {:?}", result.app_path); + } + } + SdkTarget::Ios => { + println!("[1/3] Building Rust library..."); + let mut builder = + mobench_sdk::builders::IosBuilder::new(&project_root, crate_name) + .verbose(false) + .dry_run(dry_run); + if let Some(ref dir) = effective_output_dir { + builder = builder.output_dir(dir); + } + if let Some(ref path) = crate_path { + builder = builder.crate_dir(path); + } + println!("[2/3] Building iOS xcframework..."); + let result = builder.build(&build_config)?; + println!("[3/3] Done!"); + if !dry_run { + println!("\n\u{2713} Framework: {:?}", result.app_path); + } + } + SdkTarget::Both => { + println!("[1/5] Building Rust library for Android..."); + let mut android_builder = + mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) + .verbose(false) + .dry_run(dry_run); + if let Some(ref dir) = effective_output_dir { + android_builder = android_builder.output_dir(dir); + } + if let Some(ref path) = crate_path { + android_builder = android_builder.crate_dir(path); + } + println!("[2/5] Building Android APK..."); + let android_result = android_builder.build(&build_config)?; + + println!("[3/5] Building Rust library for iOS..."); + let mut ios_builder = + mobench_sdk::builders::IosBuilder::new(&project_root, crate_name) + .verbose(false) + .dry_run(dry_run); + if let Some(ref dir) = effective_output_dir { + ios_builder = ios_builder.output_dir(dir); + } + if let Some(ref path) = crate_path { + ios_builder = ios_builder.crate_dir(path); + } + println!("[4/5] Building iOS xcframework..."); + let ios_result = ios_builder.build(&build_config)?; + + println!("[5/5] Done!"); + if !dry_run { + println!("\n\u{2713} APK: {:?}", android_result.app_path); + println!("\u{2713} Framework: {:?}", ios_result.app_path); + } + } + } + return Ok(()); + } + + // Normal (verbose) mode if let Some(config_path) = &config_resolver.config_path { println!("Using config file: {:?}", config_path); } @@ -2173,6 +2865,8 @@ fn cmd_build( match target { SdkTarget::Android => { + println!("\nBuilding for Android..."); + println!(" Building Rust library for Android targets..."); let mut builder = mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) .verbose(verbose) @@ -2185,11 +2879,14 @@ fn cmd_build( } let result = builder.build(&build_config)?; if !dry_run { + println!("\u{2713} Built Android APK"); println!("\n[checkmark] Android build completed!"); println!(" APK: {:?}", result.app_path); } } SdkTarget::Ios => { + println!("\nBuilding for iOS..."); + println!(" Building Rust library for iOS targets..."); let mut builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name.clone()) .verbose(verbose) @@ -2202,12 +2899,15 @@ fn cmd_build( } let result = builder.build(&build_config)?; if !dry_run { + println!("\u{2713} Built iOS xcframework"); println!("\n[checkmark] iOS build completed!"); println!(" Framework: {:?}", result.app_path); } } SdkTarget::Both => { // Build Android + println!("\nBuilding for Android..."); + println!(" Building Rust library for Android targets..."); let mut android_builder = mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) .verbose(verbose) @@ -2220,11 +2920,14 @@ fn cmd_build( } let android_result = android_builder.build(&build_config)?; if !dry_run { + println!("\u{2713} Built Android APK"); println!("\n[checkmark] Android build completed!"); println!(" APK: {:?}", android_result.app_path); } // Build iOS + println!("\nBuilding for iOS..."); + println!(" Building Rust library for iOS targets..."); let mut ios_builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name) .verbose(verbose) @@ -2237,6 +2940,7 @@ fn cmd_build( } let ios_result = ios_builder.build(&build_config)?; if !dry_run { + println!("\u{2713} Built iOS xcframework"); println!("\n[checkmark] iOS build completed!"); println!(" Framework: {:?}", ios_result.app_path); } @@ -2291,28 +2995,93 @@ fn detect_bench_mobile_crate_name(root: &Path) -> Result { ) } -/// List all discovered benchmark functions (Phase 1 MVP) +/// List all discovered benchmark functions +/// +/// This uses source code scanning to find `#[benchmark]` functions, which works +/// without requiring a full build. It also falls back to the inventory registry +/// for any benchmarks that may be registered at runtime. fn cmd_list() -> Result<()> { println!("Discovering benchmark functions...\n"); - let benchmarks = mobench_sdk::discover_benchmarks(); + let project_root = repo_root()?; + let mut all_benchmarks = Vec::new(); + + // Method 1: Source code scanning (works without build) + let search_dirs = [ + ("bench-mobile", project_root.join("bench-mobile")), + ("sample-fns", project_root.join("crates/sample-fns")), + ("ffi-benchmark", project_root.join("crates/ffi-benchmark")), + ("", project_root.clone()), + ]; - if benchmarks.is_empty() { - println!("No benchmarks found."); - println!("\nTo add benchmarks:"); - println!(" 1. Add #[benchmark] attribute to functions"); - println!(" 2. Make sure mobench-sdk is in your dependencies"); - println!(" 3. Rebuild your project"); - } else { - println!("Found {} benchmark(s):", benchmarks.len()); - for bench in benchmarks { - println!(" - {}", bench.name); + for (default_crate_name, dir) in &search_dirs { + if !dir.join("Cargo.toml").exists() { + continue; } + let crate_name = if default_crate_name.is_empty() { + if let Ok(name) = get_crate_name_from_cargo_toml(&dir.join("Cargo.toml")) { + name + } else { + continue; + } + } else { + default_crate_name.to_string() + }; + let benchmarks = mobench_sdk::codegen::detect_all_benchmarks(dir, &crate_name); + for bench in benchmarks { + if !all_benchmarks.contains(&bench) { + all_benchmarks.push(bench); + } + } + } + + // Method 2: Inventory registry (for runtime-registered benchmarks) + let registry_benchmarks = mobench_sdk::discover_benchmarks(); + for bench in registry_benchmarks { + let name = bench.name.to_string(); + if !all_benchmarks.contains(&name) { + all_benchmarks.push(name); + } + } + + all_benchmarks.sort(); + + if all_benchmarks.is_empty() { + println!("No benchmarks found.\n"); + println!("Searched locations:"); + for (name, dir) in &search_dirs { + if !name.is_empty() { + println!(" - {}: {}", name, dir.display()); + } + } + println!("\nTo add benchmarks:"); + println!(" 1. Add #[benchmark] attribute to functions"); + println!(" 2. Make sure mobench-sdk is in your dependencies"); + println!(" 3. Run 'cargo mobench list' again"); + } else { + println!("Found {} benchmark(s):", all_benchmarks.len()); + for bench in &all_benchmarks { + println!(" {}", bench); + } + println!(); + println!("Usage:"); + println!(" cargo mobench run --target android --function {} --iterations 100", all_benchmarks.first().unwrap()); } Ok(()) } +fn get_crate_name_from_cargo_toml(cargo_toml: &Path) -> Result { + let contents = fs::read_to_string(cargo_toml)?; + let value: toml::Value = toml::from_str(&contents)?; + let name = value + .get("package") + .and_then(|pkg| pkg.get("name")) + .and_then(|n| n.as_str()) + .ok_or_else(|| anyhow!("package.name not found in {:?}", cargo_toml))?; + Ok(name.to_string()) +} + /// Package iOS app as IPA for distribution or testing fn cmd_package_ipa(scheme: &str, method: IosSigningMethodArg, output_dir: Option) -> Result<()> { println!("Packaging iOS app as IPA..."); @@ -2380,6 +3149,1061 @@ fn cmd_package_xcuitest(scheme: &str, output_dir: Option) -> Result<()> Ok(()) } +/// Verify benchmark setup: registry, spec, artifacts, and optional smoke test +fn cmd_verify( + target: Option, + spec_path: Option, + check_artifacts: bool, + smoke_test: bool, + function: Option, + output_dir: Option, +) -> Result<()> { + println!("Verifying benchmark setup...\n"); + + let mut checks_passed = 0; + let mut checks_failed = 0; + let mut warnings = 0; + + // 1. Check benchmark registry + print!(" [1/4] Checking benchmark registry... "); + let benchmarks = mobench_sdk::discover_benchmarks(); + if benchmarks.is_empty() { + println!("WARNING"); + println!(" No benchmarks found in registry."); + println!(" This may be expected if benchmarks are in a separate crate."); + println!(" Tip: Add #[benchmark] attribute to functions and ensure mobench-sdk is linked."); + warnings += 1; + } else { + println!("OK ({} benchmark(s) found)", benchmarks.len()); + for bench in &benchmarks { + println!(" - {}", bench.name); + } + checks_passed += 1; + } + + // 2. Validate spec file if provided + print!(" [2/4] Checking spec file... "); + if let Some(ref path) = spec_path { + match validate_spec_file(path) { + Ok(spec) => { + println!("OK"); + println!(" Function: {}", spec.name); + println!(" Iterations: {}", spec.iterations); + println!(" Warmup: {}", spec.warmup); + checks_passed += 1; + } + Err(e) => { + println!("FAILED"); + println!(" Error: {}", e); + checks_failed += 1; + } + } + } else { + // Try default locations + let project_root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); + let output_base = output_dir.clone().unwrap_or_else(|| project_root.join("target/mobench")); + let default_paths = [ + output_base.join("android/app/src/main/assets/bench_spec.json"), + output_base.join("ios/BenchRunner/BenchRunner/bench_spec.json"), + project_root.join("target/mobile-spec/android/bench_spec.json"), + project_root.join("target/mobile-spec/ios/bench_spec.json"), + ]; + + let mut found_any = false; + for path in &default_paths { + if path.exists() { + if !found_any { + println!("OK (found at default locations)"); + found_any = true; + } + match validate_spec_file(path) { + Ok(spec) => { + println!(" {:?}", path); + println!(" Function: {}, Iterations: {}, Warmup: {}", + spec.name, spec.iterations, spec.warmup); + } + Err(e) => { + println!(" {:?} - INVALID: {}", path, e); + } + } + } + } + if found_any { + checks_passed += 1; + } else { + println!("SKIPPED (no spec file found, use --spec-path to specify)"); + warnings += 1; + } + } + + // 3. Check artifacts if requested + print!(" [3/4] Checking build artifacts... "); + if check_artifacts { + let project_root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); + let output_base = output_dir.clone().unwrap_or_else(|| project_root.join("target/mobench")); + + let mut artifacts_ok = true; + let mut artifact_details = Vec::new(); + + if let Some(ref t) = target { + match t { + SdkTarget::Android | SdkTarget::Both => { + let apk_path = output_base.join("android/app/build/outputs/apk/debug/app-debug.apk"); + let apk_release = output_base.join("android/app/build/outputs/apk/release/app-release-unsigned.apk"); + if apk_path.exists() { + artifact_details.push(format!("Android APK (debug): {:?}", apk_path)); + } else if apk_release.exists() { + artifact_details.push(format!("Android APK (release): {:?}", apk_release)); + } else { + artifact_details.push("Android APK: NOT FOUND".to_string()); + artifacts_ok = false; + } + + // Check JNI libs + let jni_base = output_base.join("android/app/src/main/jniLibs"); + let abis = ["arm64-v8a", "armeabi-v7a", "x86_64"]; + for abi in abis { + let lib_path = jni_base.join(abi).join("libsample_fns.so"); + if lib_path.exists() { + artifact_details.push(format!("JNI lib ({}): OK", abi)); + } + } + } + SdkTarget::Ios => {} + } + + match t { + SdkTarget::Ios | SdkTarget::Both => { + let xcframework = output_base.join("ios/sample_fns.xcframework"); + if xcframework.exists() { + artifact_details.push(format!("iOS xcframework: {:?}", xcframework)); + } else { + artifact_details.push("iOS xcframework: NOT FOUND".to_string()); + artifacts_ok = false; + } + + let ipa_path = output_base.join("ios/BenchRunner.ipa"); + if ipa_path.exists() { + artifact_details.push(format!("iOS IPA: {:?}", ipa_path)); + } + + let xcuitest_path = output_base.join("ios/BenchRunnerUITests.zip"); + if xcuitest_path.exists() { + artifact_details.push(format!("XCUITest runner: {:?}", xcuitest_path)); + } + } + SdkTarget::Android => {} + } + } else { + // Check both platforms by default + let android_apk = output_base.join("android/app/build/outputs/apk/debug/app-debug.apk"); + let ios_xcframework = output_base.join("ios/sample_fns.xcframework"); + + if android_apk.exists() { + artifact_details.push(format!("Android APK: {:?}", android_apk)); + } + if ios_xcframework.exists() { + artifact_details.push(format!("iOS xcframework: {:?}", ios_xcframework)); + } + + if artifact_details.is_empty() { + artifacts_ok = false; + artifact_details.push("No artifacts found. Run 'cargo mobench build' first.".to_string()); + } + } + + if artifacts_ok { + println!("OK"); + checks_passed += 1; + } else { + println!("FAILED"); + checks_failed += 1; + } + for detail in &artifact_details { + println!(" {}", detail); + } + } else { + println!("SKIPPED (use --check-artifacts to enable)"); + } + + // 4. Run smoke test if requested + print!(" [4/4] Running smoke test... "); + if smoke_test { + if let Some(ref func) = function { + match run_verify_smoke_test(func) { + Ok(report) => { + println!("OK"); + let samples = report.samples.len(); + let mean_ns = if samples > 0 { + report.samples.iter().map(|s| s.duration_ns).sum::() / samples as u64 + } else { + 0 + }; + println!(" Function: {}", func); + println!(" Samples: {}", samples); + println!(" Mean: {} ns ({:.3} ms)", mean_ns, mean_ns as f64 / 1_000_000.0); + checks_passed += 1; + } + Err(e) => { + println!("FAILED"); + println!(" Error: {}", e); + checks_failed += 1; + } + } + } else if !benchmarks.is_empty() { + // Use first discovered benchmark + let func = &benchmarks[0].name; + match run_verify_smoke_test(func) { + Ok(report) => { + println!("OK"); + let samples = report.samples.len(); + let mean_ns = if samples > 0 { + report.samples.iter().map(|s| s.duration_ns).sum::() / samples as u64 + } else { + 0 + }; + println!(" Function: {} (auto-selected)", func); + println!(" Samples: {}", samples); + println!(" Mean: {} ns ({:.3} ms)", mean_ns, mean_ns as f64 / 1_000_000.0); + checks_passed += 1; + } + Err(e) => { + println!("FAILED"); + println!(" Error: {}", e); + checks_failed += 1; + } + } + } else { + println!("SKIPPED (no benchmark function available)"); + println!(" Tip: Use --function to specify a function, or add benchmarks with #[benchmark]"); + warnings += 1; + } + } else { + println!("SKIPPED (use --smoke-test to enable)"); + } + + // Print summary + println!("\n----------------------------------------"); + println!("Verification Summary:"); + println!(" Passed: {}", checks_passed); + println!(" Failed: {}", checks_failed); + println!(" Warnings: {}", warnings); + + if checks_failed > 0 { + println!("\n[X] Verification failed with {} error(s)", checks_failed); + bail!("Verification failed"); + } else if warnings > 0 { + println!("\n[!] Verification completed with {} warning(s)", warnings); + } else { + println!("\n[checkmark] All checks passed!"); + } + + Ok(()) +} + +/// Validate a bench_spec.json file +/// +/// Handles both "name" and "function" field names for compatibility +/// with different spec file formats. +fn validate_spec_file(path: &Path) -> Result { + let contents = fs::read_to_string(path) + .with_context(|| format!("reading spec file {:?}", path))?; + + // Try parsing directly first (standard BenchSpec format with "name" field) + if let Ok(spec) = serde_json::from_str::(&contents) { + // Validate spec fields + if spec.name.trim().is_empty() { + bail!("spec.name is empty"); + } + if spec.iterations == 0 { + bail!("spec.iterations must be > 0"); + } + return Ok(spec); + } + + // Fall back to generic Value parsing for "function" field format + // (used by persist_mobile_spec and some older formats) + let value: Value = serde_json::from_str(&contents) + .with_context(|| format!("parsing spec file {:?}", path))?; + + // Extract name from either "name" or "function" field + let name = value + .get("name") + .or_else(|| value.get("function")) + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("spec must have 'name' or 'function' field"))? + .to_string(); + + let iterations = value + .get("iterations") + .and_then(|v| v.as_u64()) + .map(|v| v as u32) + .unwrap_or(100); + + let warmup = value + .get("warmup") + .and_then(|v| v.as_u64()) + .map(|v| v as u32) + .unwrap_or(10); + + // Validate + if name.trim().is_empty() { + bail!("spec.name/function is empty"); + } + if iterations == 0 { + bail!("spec.iterations must be > 0"); + } + + Ok(mobench_sdk::BenchSpec { + name, + iterations, + warmup, + }) +} + +/// Run a minimal smoke test for verification +fn run_verify_smoke_test(function: &str) -> Result { + let spec = mobench_sdk::BenchSpec { + name: function.to_string(), + iterations: 3, // Minimal iterations for smoke test + warmup: 1, + }; + + mobench_sdk::run_benchmark(spec) + .map_err(|e| anyhow!("smoke test failed: {}", e)) +} + +/// Display summary statistics from a benchmark report JSON file +fn cmd_summary(report_path: &Path, format: Option) -> Result<()> { + let format = format.unwrap_or(SummaryFormat::Text); + + // Try to load the report in various formats + let contents = fs::read_to_string(report_path) + .with_context(|| format!("reading report file {:?}", report_path))?; + + let value: Value = serde_json::from_str(&contents) + .with_context(|| format!("parsing report file {:?}", report_path))?; + + // Extract summary information + let summary_data = extract_summary_data(&value)?; + + match format { + SummaryFormat::Text => print_summary_text(&summary_data), + SummaryFormat::Json => print_summary_json(&summary_data)?, + SummaryFormat::Csv => print_summary_csv(&summary_data), + } + + Ok(()) +} + +/// Summary data extracted from various report formats +#[derive(Debug, Serialize)] +struct SummaryData { + source_file: String, + function: Option, + device: Option, + os_version: Option, + sample_count: usize, + mean_ns: Option, + median_ns: Option, + min_ns: Option, + max_ns: Option, + p95_ns: Option, + iterations: Option, + warmup: Option, +} + +/// Extract summary data from various report formats +fn extract_summary_data(value: &Value) -> Result> { + let mut results = Vec::new(); + + // Check if this is a RunSummary format (from `mobench run`) + if value.get("summary").is_some() { + let summary = &value["summary"]; + let function = summary.get("function").and_then(|f| f.as_str()).map(String::from); + let iterations = summary.get("iterations").and_then(|i| i.as_u64()).map(|i| i as u32); + let warmup = summary.get("warmup").and_then(|w| w.as_u64()).map(|w| w as u32); + + if let Some(device_summaries) = summary.get("device_summaries").and_then(|d| d.as_array()) { + for device_summary in device_summaries { + let device = device_summary.get("device").and_then(|d| d.as_str()).map(String::from); + + if let Some(benchmarks) = device_summary.get("benchmarks").and_then(|b| b.as_array()) { + for bench in benchmarks { + let bench_function = bench.get("function").and_then(|f| f.as_str()).map(String::from); + results.push(SummaryData { + source_file: "RunSummary".to_string(), + function: bench_function.or_else(|| function.clone()), + device: device.clone(), + os_version: None, // RunSummary doesn't include OS version directly + sample_count: bench.get("samples").and_then(|s| s.as_u64()).unwrap_or(0) as usize, + mean_ns: bench.get("mean_ns").and_then(|m| m.as_u64()), + median_ns: bench.get("median_ns").and_then(|m| m.as_u64()), + min_ns: bench.get("min_ns").and_then(|m| m.as_u64()), + max_ns: bench.get("max_ns").and_then(|m| m.as_u64()), + p95_ns: bench.get("p95_ns").and_then(|p| p.as_u64()), + iterations, + warmup, + }); + } + } + } + } + } + + // Check if this is a BenchReport format (direct timing output) + if let Some(spec) = value.get("spec") { + let samples = extract_samples(value); + let stats = compute_sample_stats(&samples); + + results.push(SummaryData { + source_file: "BenchReport".to_string(), + function: spec.get("name").and_then(|n| n.as_str()).map(String::from), + device: Some("local".to_string()), + os_version: None, + sample_count: samples.len(), + mean_ns: stats.as_ref().map(|s| s.mean_ns), + median_ns: stats.as_ref().map(|s| s.median_ns), + min_ns: stats.as_ref().map(|s| s.min_ns), + max_ns: stats.as_ref().map(|s| s.max_ns), + p95_ns: stats.as_ref().map(|s| s.p95_ns), + iterations: spec.get("iterations").and_then(|i| i.as_u64()).map(|i| i as u32), + warmup: spec.get("warmup").and_then(|w| w.as_u64()).map(|w| w as u32), + }); + } + + // Check if this is benchmark_results format (from BrowserStack fetch) + if let Some(benchmark_results) = value.get("benchmark_results").and_then(|b| b.as_object()) { + for (device, entries) in benchmark_results { + if let Some(entries) = entries.as_array() { + for entry in entries { + let samples = extract_samples(entry); + let stats = compute_sample_stats(&samples); + + results.push(SummaryData { + source_file: "BrowserStack".to_string(), + function: entry.get("function").and_then(|f| f.as_str()).map(String::from), + device: Some(device.clone()), + os_version: entry.get("os_version").and_then(|o| o.as_str()).map(String::from), + sample_count: samples.len(), + mean_ns: entry.get("mean_ns").and_then(|m| m.as_u64()).or_else(|| stats.as_ref().map(|s| s.mean_ns)), + median_ns: stats.as_ref().map(|s| s.median_ns), + min_ns: stats.as_ref().map(|s| s.min_ns), + max_ns: stats.as_ref().map(|s| s.max_ns), + p95_ns: stats.as_ref().map(|s| s.p95_ns), + iterations: None, + warmup: None, + }); + } + } + } + } + + // Check if this is a session bench-report.json format + if value.get("samples").is_some() && value.get("spec").is_none() { + // Direct samples array without spec wrapper + let samples = extract_samples(value); + let stats = compute_sample_stats(&samples); + + results.push(SummaryData { + source_file: "SessionReport".to_string(), + function: value.get("function").and_then(|f| f.as_str()).map(String::from), + device: value.get("device").and_then(|d| d.as_str()).map(String::from), + os_version: value.get("os_version").and_then(|o| o.as_str()).map(String::from), + sample_count: samples.len(), + mean_ns: value.get("mean_ns").and_then(|m| m.as_u64()).or_else(|| stats.as_ref().map(|s| s.mean_ns)), + median_ns: stats.as_ref().map(|s| s.median_ns), + min_ns: stats.as_ref().map(|s| s.min_ns), + max_ns: stats.as_ref().map(|s| s.max_ns), + p95_ns: stats.as_ref().map(|s| s.p95_ns), + iterations: value.get("iterations").and_then(|i| i.as_u64()).map(|i| i as u32), + warmup: value.get("warmup").and_then(|w| w.as_u64()).map(|w| w as u32), + }); + } + + if results.is_empty() { + bail!("Could not extract summary data from report. Unrecognized format."); + } + + Ok(results) +} + +/// Print summary in text format +fn print_summary_text(data: &[SummaryData]) { + println!("Benchmark Summary"); + println!("=================\n"); + + for (idx, entry) in data.iter().enumerate() { + if data.len() > 1 { + println!("--- Entry {} ---", idx + 1); + } + + if let Some(ref func) = entry.function { + println!("Function: {}", func); + } + if let Some(ref device) = entry.device { + println!("Device: {}", device); + } + if let Some(ref os) = entry.os_version { + println!("OS Version: {}", os); + } + println!("Sample Count: {}", entry.sample_count); + println!(); + + println!("Statistics (nanoseconds):"); + println!(" Mean: {}", entry.mean_ns.map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)).unwrap_or_else(|| "-".to_string())); + println!(" Median: {}", entry.median_ns.map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)).unwrap_or_else(|| "-".to_string())); + println!(" Min: {}", entry.min_ns.map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)).unwrap_or_else(|| "-".to_string())); + println!(" Max: {}", entry.max_ns.map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)).unwrap_or_else(|| "-".to_string())); + println!(" P95: {}", entry.p95_ns.map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)).unwrap_or_else(|| "-".to_string())); + + if entry.iterations.is_some() || entry.warmup.is_some() { + println!(); + println!("Configuration:"); + if let Some(iter) = entry.iterations { + println!(" Iterations: {}", iter); + } + if let Some(warm) = entry.warmup { + println!(" Warmup: {}", warm); + } + } + + if idx < data.len() - 1 { + println!(); + } + } +} + +/// Print summary in JSON format +fn print_summary_json(data: &[SummaryData]) -> Result<()> { + let json = serde_json::to_string_pretty(data)?; + println!("{}", json); + Ok(()) +} + +/// Print summary in CSV format +fn print_summary_csv(data: &[SummaryData]) { + println!("function,device,os_version,sample_count,mean_ns,median_ns,min_ns,max_ns,p95_ns,iterations,warmup"); + for entry in data { + println!( + "{},{},{},{},{},{},{},{},{},{},{}", + entry.function.as_deref().unwrap_or(""), + entry.device.as_deref().unwrap_or(""), + entry.os_version.as_deref().unwrap_or(""), + entry.sample_count, + entry.mean_ns.map(|v| v.to_string()).unwrap_or_default(), + entry.median_ns.map(|v| v.to_string()).unwrap_or_default(), + entry.min_ns.map(|v| v.to_string()).unwrap_or_default(), + entry.max_ns.map(|v| v.to_string()).unwrap_or_default(), + entry.p95_ns.map(|v| v.to_string()).unwrap_or_default(), + entry.iterations.map(|v| v.to_string()).unwrap_or_default(), + entry.warmup.map(|v| v.to_string()).unwrap_or_default(), + ); + } +} + +/// List available BrowserStack devices and optionally validate device specs. +fn cmd_devices( + platform: Option, + output_json: bool, + validate: Vec, +) -> Result<()> { + // Try to get credentials, but provide helpful error if missing + let creds = match resolve_browserstack_credentials(None) { + Ok(creds) => creds, + Err(_) => { + // Check what's missing and provide helpful guidance + let username = env::var("BROWSERSTACK_USERNAME").ok(); + let access_key = env::var("BROWSERSTACK_ACCESS_KEY").ok(); + + let missing_username = username.is_none() || username.as_deref() == Some(""); + let missing_access_key = access_key.is_none() || access_key.as_deref() == Some(""); + + let error_msg = browserstack::format_credentials_error(missing_username, missing_access_key); + bail!("{}", error_msg); + } + }; + + let client = BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + )?; + + // If validating devices, do that and exit + if !validate.is_empty() { + let platform_str = platform.map(|p| match p { + DevicePlatform::Android => "android", + DevicePlatform::Ios => "ios", + }); + + let validation = client.validate_devices(&validate, platform_str)?; + + if output_json { + let output = json!({ + "valid": validation.valid, + "invalid": validation.invalid.iter().map(|e| { + json!({ + "spec": e.spec, + "reason": e.reason, + "suggestions": e.suggestions + }) + }).collect::>() + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + if !validation.valid.is_empty() { + println!("Valid devices ({}):", validation.valid.len()); + for device in &validation.valid { + println!(" [OK] {}", device); + } + } + + if !validation.invalid.is_empty() { + if !validation.valid.is_empty() { + println!(); + } + println!("Invalid devices ({}):", validation.invalid.len()); + for error in &validation.invalid { + println!(" [ERROR] {}: {}", error.spec, error.reason); + if !error.suggestions.is_empty() { + println!(" Suggestions:"); + for suggestion in &error.suggestions { + println!(" - {}", suggestion); + } + } + } + } + } + + // Exit with error if any devices were invalid + if !validation.invalid.is_empty() { + bail!( + "{} of {} device specs are invalid", + validation.invalid.len(), + validate.len() + ); + } + + return Ok(()); + } + + // List devices + println!("Fetching available BrowserStack devices...\n"); + + let devices = match platform { + Some(DevicePlatform::Android) => client.list_espresso_devices()?, + Some(DevicePlatform::Ios) => client.list_xcuitest_devices()?, + None => client.list_all_devices()?, + }; + + if devices.is_empty() { + println!("No devices found."); + return Ok(()); + } + + if output_json { + println!("{}", serde_json::to_string_pretty(&devices)?); + return Ok(()); + } + + // Group devices by OS + let mut android_devices: Vec<_> = devices.iter().filter(|d| d.os == "android").collect(); + let mut ios_devices: Vec<_> = devices.iter().filter(|d| d.os == "ios").collect(); + + // Sort by device name, then OS version (descending) + android_devices.sort_by(|a, b| { + a.device.cmp(&b.device).then_with(|| { + // Try to compare versions numerically + let av: f64 = a.os_version.parse().unwrap_or(0.0); + let bv: f64 = b.os_version.parse().unwrap_or(0.0); + bv.partial_cmp(&av).unwrap_or(std::cmp::Ordering::Equal) + }) + }); + ios_devices.sort_by(|a, b| { + a.device.cmp(&b.device).then_with(|| { + let av: f64 = a.os_version.parse().unwrap_or(0.0); + let bv: f64 = b.os_version.parse().unwrap_or(0.0); + bv.partial_cmp(&av).unwrap_or(std::cmp::Ordering::Equal) + }) + }); + + if !android_devices.is_empty() { + println!("Android Devices ({}):", android_devices.len()); + println!("{:-<60}", ""); + for device in &android_devices { + println!(" {:40} OS {}", device.device, device.os_version); + println!(" --devices \"{}\"", device.identifier()); + } + println!(); + } + + if !ios_devices.is_empty() { + println!("iOS Devices ({}):", ios_devices.len()); + println!("{:-<60}", ""); + for device in &ios_devices { + println!(" {:40} iOS {}", device.device, device.os_version); + println!(" --devices \"{}\"", device.identifier()); + } + println!(); + } + + println!("Total: {} devices available", devices.len()); + println!("\nUsage:"); + println!(" cargo mobench run --target android --devices \"Google Pixel 7-13.0\" ..."); + println!(" cargo mobench run --target ios --devices \"iPhone 14-16\" ..."); + + Ok(()) +} + +/// Check prerequisites for building mobile artifacts. +/// +/// This validates that all required tools and configurations are in place +/// before attempting a build. +fn cmd_check(target: SdkTarget, format: CheckOutputFormat) -> Result<()> { + let mut checks: Vec = Vec::new(); + let mut issues: Vec = Vec::new(); + + // Common checks for both platforms + checks.push(check_cargo()); + checks.push(check_rustup()); + + match target { + SdkTarget::Android => { + println!("Checking prerequisites for Android...\n"); + checks.push(check_android_ndk_home()); + checks.push(check_cargo_ndk()); + checks.push(check_rust_target("aarch64-linux-android")); + checks.push(check_rust_target("armv7-linux-androideabi")); + checks.push(check_rust_target("x86_64-linux-android")); + checks.push(check_jdk()); + } + SdkTarget::Ios => { + println!("Checking prerequisites for iOS...\n"); + checks.push(check_xcode()); + checks.push(check_xcodegen()); + checks.push(check_rust_target("aarch64-apple-ios")); + checks.push(check_rust_target("aarch64-apple-ios-sim")); + } + SdkTarget::Both => { + println!("Checking prerequisites for Android and iOS...\n"); + // Android + checks.push(check_android_ndk_home()); + checks.push(check_cargo_ndk()); + checks.push(check_rust_target("aarch64-linux-android")); + checks.push(check_rust_target("armv7-linux-androideabi")); + checks.push(check_rust_target("x86_64-linux-android")); + checks.push(check_jdk()); + // iOS + checks.push(check_xcode()); + checks.push(check_xcodegen()); + checks.push(check_rust_target("aarch64-apple-ios")); + checks.push(check_rust_target("aarch64-apple-ios-sim")); + } + } + + // Collect issues + for check in &checks { + if !check.passed { + if let Some(ref fix) = check.fix_hint { + issues.push(fix.clone()); + } + } + } + + match format { + CheckOutputFormat::Text => print_check_results_text(&checks, &issues), + CheckOutputFormat::Json => print_check_results_json(&checks)?, + } + + if issues.is_empty() { + Ok(()) + } else { + bail!("{} issue(s) found. Fix them and run 'cargo mobench check --target {:?}' again.", issues.len(), target) + } +} + +#[derive(Debug, Clone, Serialize)] +struct PrereqCheck { + name: String, + passed: bool, + detail: Option, + fix_hint: Option, +} + +fn print_check_results_text(checks: &[PrereqCheck], issues: &[String]) { + for check in checks { + let status = if check.passed { "\u{2713}" } else { "\u{2717}" }; + let detail = check.detail.as_deref().unwrap_or(""); + if detail.is_empty() { + println!("{} {}", status, check.name); + } else { + println!("{} {} ({})", status, check.name, detail); + } + } + + if !issues.is_empty() { + println!("\nTo fix:"); + for issue in issues { + println!(" * {}", issue); + } + println!(); + let failed_count = checks.iter().filter(|c| !c.passed).count(); + println!("{} issue(s) found.", failed_count); + } else { + println!("\nAll prerequisites satisfied!"); + } +} + +fn print_check_results_json(checks: &[PrereqCheck]) -> Result<()> { + let output = json!({ + "checks": checks, + "all_passed": checks.iter().all(|c| c.passed), + "passed_count": checks.iter().filter(|c| c.passed).count(), + "failed_count": checks.iter().filter(|c| !c.passed).count(), + }); + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + +fn check_cargo() -> PrereqCheck { + let result = std::process::Command::new("cargo") + .arg("--version") + .output(); + + match result { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); + PrereqCheck { + name: "cargo installed".to_string(), + passed: true, + detail: Some(version), + fix_hint: None, + } + } + _ => PrereqCheck { + name: "cargo installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install Rust: https://rustup.rs".to_string()), + }, + } +} + +fn check_rustup() -> PrereqCheck { + let result = std::process::Command::new("rustup") + .arg("--version") + .output(); + + match result { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + PrereqCheck { + name: "rustup installed".to_string(), + passed: true, + detail: Some(version), + fix_hint: None, + } + } + _ => PrereqCheck { + name: "rustup installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install rustup: https://rustup.rs".to_string()), + }, + } +} + +fn check_android_ndk_home() -> PrereqCheck { + match env::var("ANDROID_NDK_HOME") { + Ok(path) if !path.is_empty() => { + let path_exists = Path::new(&path).exists(); + if path_exists { + PrereqCheck { + name: "ANDROID_NDK_HOME set".to_string(), + passed: true, + detail: Some(path), + fix_hint: None, + } + } else { + PrereqCheck { + name: "ANDROID_NDK_HOME set".to_string(), + passed: false, + detail: Some(format!("path does not exist: {}", path)), + fix_hint: Some("Set ANDROID_NDK_HOME to a valid NDK path: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/".to_string()), + } + } + } + _ => PrereqCheck { + name: "ANDROID_NDK_HOME set".to_string(), + passed: false, + detail: None, + fix_hint: Some("Set ANDROID_NDK_HOME: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/".to_string()), + }, + } +} + +fn check_cargo_ndk() -> PrereqCheck { + let result = std::process::Command::new("cargo") + .args(["ndk", "--version"]) + .output(); + + match result { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); + PrereqCheck { + name: "cargo-ndk installed".to_string(), + passed: true, + detail: Some(version), + fix_hint: None, + } + } + _ => PrereqCheck { + name: "cargo-ndk installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install cargo-ndk: cargo install cargo-ndk".to_string()), + }, + } +} + +fn check_rust_target(target: &str) -> PrereqCheck { + let result = std::process::Command::new("rustup") + .args(["target", "list", "--installed"]) + .output(); + + match result { + Ok(output) if output.status.success() => { + let installed = String::from_utf8_lossy(&output.stdout); + let has_target = installed.lines().any(|line| line.trim() == target); + if has_target { + PrereqCheck { + name: format!("Rust target: {}", target), + passed: true, + detail: None, + fix_hint: None, + } + } else { + PrereqCheck { + name: format!("Rust target: {}", target), + passed: false, + detail: Some("not installed".to_string()), + fix_hint: Some(format!("Install target: rustup target add {}", target)), + } + } + } + _ => PrereqCheck { + name: format!("Rust target: {}", target), + passed: false, + detail: Some("could not check".to_string()), + fix_hint: Some(format!("Install target: rustup target add {}", target)), + }, + } +} + +fn check_jdk() -> PrereqCheck { + // Try java -version + let result = std::process::Command::new("java") + .arg("-version") + .output(); + + match result { + Ok(output) => { + // Java outputs version to stderr + let version_output = String::from_utf8_lossy(&output.stderr); + let version_line = version_output.lines().next().unwrap_or(""); + + if output.status.success() || !version_line.is_empty() { + PrereqCheck { + name: "JDK installed".to_string(), + passed: true, + detail: Some(version_line.trim().to_string()), + fix_hint: None, + } + } else { + PrereqCheck { + name: "JDK installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install JDK 17+: brew install openjdk@17".to_string()), + } + } + } + Err(_) => PrereqCheck { + name: "JDK installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install JDK 17+: brew install openjdk@17".to_string()), + }, + } +} + +fn check_xcode() -> PrereqCheck { + let result = std::process::Command::new("xcodebuild") + .arg("-version") + .output(); + + match result { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + PrereqCheck { + name: "Xcode installed".to_string(), + passed: true, + detail: Some(version), + fix_hint: None, + } + } + _ => PrereqCheck { + name: "Xcode installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install Xcode from the App Store or run: xcode-select --install".to_string()), + }, + } +} + +fn check_xcodegen() -> PrereqCheck { + let result = std::process::Command::new("xcodegen") + .arg("--version") + .output(); + + match result { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); + PrereqCheck { + name: "xcodegen installed".to_string(), + passed: true, + detail: Some(version), + fix_hint: None, + } + } + _ => PrereqCheck { + name: "xcodegen installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install xcodegen: brew install xcodegen".to_string()), + }, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/sample-fns/src/lib.rs b/crates/sample-fns/src/lib.rs index 81cdcbe..ffb83ee 100644 --- a/crates/sample-fns/src/lib.rs +++ b/crates/sample-fns/src/lib.rs @@ -83,7 +83,7 @@ impl From for BenchReport { impl From for BenchError { fn from(err: TimingError) -> Self { match err { - TimingError::NoIterations => BenchError::InvalidIterations, + TimingError::NoIterations { .. } => BenchError::InvalidIterations, TimingError::Execution(msg) => BenchError::ExecutionFailed { reason: msg }, } } diff --git a/docs/SETUP_TEARDOWN_DESIGN.md b/docs/SETUP_TEARDOWN_DESIGN.md new file mode 100644 index 0000000..e06fe26 --- /dev/null +++ b/docs/SETUP_TEARDOWN_DESIGN.md @@ -0,0 +1,497 @@ +# Setup/Teardown Implementation Design + +## Overview + +This document describes the implementation of setup/teardown support for `#[benchmark]` functions, allowing expensive initialization to be excluded from timing measurements. + +## API Design + +### Basic Usage (unchanged) +```rust +#[benchmark] +pub fn simple_benchmark() { + let result = compute_something(); + std::hint::black_box(result); +} +``` + +### With Setup +```rust +fn setup_proof() -> ProofInput { + ProofInput { + proof: generate_complex_proof(), // Expensive, not measured + data: load_test_data(), + } +} + +#[benchmark(setup = setup_proof)] +pub fn verify_proof(input: &ProofInput) { + verify(&input.proof); // Only this is measured +} +``` + +### With Setup and Teardown +```rust +fn setup_db() -> Database { + Database::connect("test.db") +} + +fn cleanup_db(db: Database) { + db.close(); + std::fs::remove_file("test.db").ok(); +} + +#[benchmark(setup = setup_db, teardown = cleanup_db)] +pub fn db_query(db: &Database) { + db.query("SELECT * FROM users"); +} +``` + +### Setup Modes + +Two modes for when setup runs: + +```rust +// Mode 1: Setup once before all iterations (default) +#[benchmark(setup = setup_proof)] +pub fn verify_proof(input: &ProofInput) { ... } + +// Mode 2: Setup before each iteration (for mutations) +#[benchmark(setup = setup_data, per_iteration)] +pub fn sort_data(data: Vec) { // Takes ownership + data.sort(); +} +``` + +--- + +## Implementation Changes + +### 1. `timing.rs` - Add Setup-Aware Run Functions + +```rust +/// Runs a benchmark with setup that executes once before all iterations. +/// +/// The setup function is called once, then the benchmark runs multiple +/// times using a reference to the setup result. +pub fn run_closure_with_setup( + spec: BenchSpec, + setup: S, + mut f: F, +) -> Result +where + S: FnOnce() -> T, + F: FnMut(&T) -> Result<(), TimingError>, +{ + // Validate iterations + if spec.iterations == 0 { + return Err(TimingError::NoIterations { count: spec.iterations }); + } + + // Setup phase - not timed + let input = setup(); + + // Warmup phase - not recorded + for _ in 0..spec.warmup { + f(&input)?; + } + + // Measurement phase + let mut samples = Vec::with_capacity(spec.iterations as usize); + for _ in 0..spec.iterations { + let start = Instant::now(); + f(&input)?; + samples.push(BenchSample::from_duration(start.elapsed())); + } + + Ok(BenchReport { spec, samples }) +} + +/// Runs a benchmark with per-iteration setup. +/// +/// Setup runs before each iteration and is not timed. +/// The benchmark takes ownership of the setup result. +pub fn run_closure_with_setup_per_iter( + spec: BenchSpec, + mut setup: S, + mut f: F, +) -> Result +where + S: FnMut() -> T, + F: FnMut(T) -> Result<(), TimingError>, +{ + if spec.iterations == 0 { + return Err(TimingError::NoIterations { count: spec.iterations }); + } + + // Warmup phase + for _ in 0..spec.warmup { + let input = setup(); + f(input)?; + } + + // Measurement phase + let mut samples = Vec::with_capacity(spec.iterations as usize); + for _ in 0..spec.iterations { + let input = setup(); // Not timed + + let start = Instant::now(); + f(input)?; // Only this is timed + samples.push(BenchSample::from_duration(start.elapsed())); + } + + Ok(BenchReport { spec, samples }) +} + +/// Runs a benchmark with setup and teardown. +pub fn run_closure_with_setup_teardown( + spec: BenchSpec, + setup: S, + mut f: F, + teardown: D, +) -> Result +where + S: FnOnce() -> T, + F: FnMut(&T) -> Result<(), TimingError>, + D: FnOnce(T), +{ + if spec.iterations == 0 { + return Err(TimingError::NoIterations { count: spec.iterations }); + } + + let input = setup(); + + // Warmup + for _ in 0..spec.warmup { + f(&input)?; + } + + // Measurement + let mut samples = Vec::with_capacity(spec.iterations as usize); + for _ in 0..spec.iterations { + let start = Instant::now(); + f(&input)?; + samples.push(BenchSample::from_duration(start.elapsed())); + } + + // Teardown - not timed + teardown(input); + + Ok(BenchReport { spec, samples }) +} +``` + +### 2. `registry.rs` - Change to Store Runner Functions + +```rust +use crate::timing::{BenchReport, BenchSpec, TimingError}; + +/// A registered benchmark function +pub struct BenchFunction { + /// Fully-qualified name of the benchmark function + pub name: &'static str, + + /// Runner function that executes the benchmark with timing + /// + /// Takes a BenchSpec and returns a BenchReport directly. + /// The runner handles setup/teardown internally. + pub runner: fn(BenchSpec) -> Result, +} + +inventory::collect!(BenchFunction); + +// find_benchmark, discover_benchmarks, list_benchmark_names remain the same +``` + +### 3. `runner.rs` - Simplify to Delegate to Registry + +```rust +pub fn run_benchmark(spec: BenchSpec) -> Result { + let bench_fn = find_benchmark(&spec.name).ok_or_else(|| { + let available = list_benchmark_names() + .into_iter() + .map(String::from) + .collect(); + BenchError::UnknownFunction(spec.name.clone(), available) + })?; + + // Simply call the stored runner - it handles setup/teardown + let report = (bench_fn.runner)(spec)?; + Ok(report) +} +``` + +### 4. `mobench-macros/src/lib.rs` - Parse Setup/Teardown Attributes + +```rust +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, ItemFn, Meta, Expr, Ident}; + +struct BenchmarkArgs { + setup: Option, + teardown: Option, + per_iteration: bool, +} + +impl BenchmarkArgs { + fn parse(attr: TokenStream) -> syn::Result { + // Parse: #[benchmark] or #[benchmark(setup = foo, teardown = bar, per_iteration)] + // ... + } +} + +#[proc_macro_attribute] +pub fn benchmark(attr: TokenStream, item: TokenStream) -> TokenStream { + let args = match BenchmarkArgs::parse(attr) { + Ok(args) => args, + Err(e) => return e.to_compile_error().into(), + }; + + let input_fn = parse_macro_input!(item as ItemFn); + let fn_name = &input_fn.sig.ident; + let fn_name_str = fn_name.to_string(); + let vis = &input_fn.vis; + let sig = &input_fn.sig; + let block = &input_fn.block; + let attrs = &input_fn.attrs; + + // Validate based on whether setup is provided + if args.setup.is_some() { + // With setup: must have exactly one parameter (reference or owned) + validate_setup_benchmark(&input_fn)?; + } else { + // No setup: must have no parameters (current behavior) + validate_simple_benchmark(&input_fn)?; + } + + // Generate the runner based on configuration + let runner = generate_runner(&fn_name, &args); + + let expanded = quote! { + #(#attrs)* + #vis #sig { + #block + } + + ::inventory::submit! { + ::mobench_sdk::registry::BenchFunction { + name: ::std::concat!(::std::module_path!(), "::", #fn_name_str), + runner: #runner, + } + } + }; + + TokenStream::from(expanded) +} + +fn generate_runner(fn_name: &Ident, args: &BenchmarkArgs) -> proc_macro2::TokenStream { + match (&args.setup, &args.teardown, args.per_iteration) { + // No setup - current behavior + (None, None, _) => quote! { + |spec| ::mobench_sdk::timing::run_closure(spec, || { + #fn_name(); + Ok(()) + }) + }, + + // Setup only, runs once + (Some(setup), None, false) => quote! { + |spec| ::mobench_sdk::timing::run_closure_with_setup( + spec, + || #setup(), + |input| { + #fn_name(input); + Ok(()) + }, + ) + }, + + // Setup only, per iteration + (Some(setup), None, true) => quote! { + |spec| ::mobench_sdk::timing::run_closure_with_setup_per_iter( + spec, + || #setup(), + |input| { + #fn_name(input); + Ok(()) + }, + ) + }, + + // Setup + teardown + (Some(setup), Some(teardown), false) => quote! { + |spec| ::mobench_sdk::timing::run_closure_with_setup_teardown( + spec, + || #setup(), + |input| { + #fn_name(input); + Ok(()) + }, + |input| #teardown(input), + ) + }, + + // Teardown without setup is invalid + (None, Some(_), _) => { + // This would be caught earlier during validation + quote! { compile_error!("teardown requires setup") } + } + + // Per-iteration with teardown not supported yet + (Some(_), Some(_), true) => { + quote! { compile_error!("per_iteration with teardown is not supported") } + } + } +} +``` + +--- + +## Migration & Compatibility + +### Breaking Change + +The `BenchFunction.invoke` field changes to `BenchFunction.runner`. This affects: +1. Any code directly accessing `BenchFunction.invoke` +2. The FFI examples that use the registry directly + +### Migration Path + +1. Update `BenchFunction` in registry.rs +2. Update macro to generate runner instead of invoke +3. Update `run_benchmark` in runner.rs +4. Update any FFI code that accesses registry directly + +### FFI Considerations + +The mobile apps call `run_benchmark(spec)` which is unchanged. The setup/teardown +all happens within Rust before results cross the FFI boundary. No changes needed +to Kotlin/Swift code. + +--- + +## Example Expansions + +### Simple Benchmark (unchanged behavior) + +```rust +#[benchmark] +pub fn fibonacci() { + std::hint::black_box(fib(20)); +} + +// Expands to: +pub fn fibonacci() { + std::hint::black_box(fib(20)); +} + +inventory::submit! { + mobench_sdk::registry::BenchFunction { + name: "my_crate::fibonacci", + runner: |spec| mobench_sdk::timing::run_closure(spec, || { + fibonacci(); + Ok(()) + }), + } +} +``` + +### With Setup + +```rust +fn setup_proof() -> ProofInput { ... } + +#[benchmark(setup = setup_proof)] +pub fn verify_proof(input: &ProofInput) { + verify(&input.proof); +} + +// Expands to: +pub fn verify_proof(input: &ProofInput) { + verify(&input.proof); +} + +inventory::submit! { + mobench_sdk::registry::BenchFunction { + name: "my_crate::verify_proof", + runner: |spec| mobench_sdk::timing::run_closure_with_setup( + spec, + || setup_proof(), + |input| { + verify_proof(input); + Ok(()) + }, + ), + } +} +``` + +### Per-Iteration Setup + +```rust +fn generate_random_vec() -> Vec { + (0..1000).map(|_| rand::random()).collect() +} + +#[benchmark(setup = generate_random_vec, per_iteration)] +pub fn sort_benchmark(mut data: Vec) { + data.sort(); + std::hint::black_box(data); +} + +// Expands to: +pub fn sort_benchmark(mut data: Vec) { + data.sort(); + std::hint::black_box(data); +} + +inventory::submit! { + mobench_sdk::registry::BenchFunction { + name: "my_crate::sort_benchmark", + runner: |spec| mobench_sdk::timing::run_closure_with_setup_per_iter( + spec, + || generate_random_vec(), + |data| { + sort_benchmark(data); + Ok(()) + }, + ), + } +} +``` + +--- + +## Testing Strategy + +1. **Unit tests for timing functions** + - Test `run_closure_with_setup` verifies setup runs once + - Test `run_closure_with_setup_per_iter` verifies setup runs each iteration + - Test teardown is called after all iterations + +2. **Macro expansion tests** + - Test simple benchmark still works + - Test setup-only benchmark + - Test setup + teardown benchmark + - Test per_iteration mode + +3. **Integration tests** + - End-to-end test with actual setup function + - Verify timing excludes setup + +4. **Compile-fail tests** + - `#[benchmark(teardown = foo)]` without setup should fail + - `#[benchmark(setup = foo)]` with wrong parameter count should fail + +--- + +## Implementation Order + +1. Add new timing functions to `timing.rs` (no breaking changes) +2. Add `BenchFunction.runner` alongside `BenchFunction.invoke` temporarily +3. Update macro to use new runner field +4. Update `run_benchmark` to use runner +5. Remove deprecated `invoke` field +6. Add tests +7. Update documentation diff --git a/examples/ffi-benchmark/src/lib.rs b/examples/ffi-benchmark/src/lib.rs index 84e524d..b2c8372 100644 --- a/examples/ffi-benchmark/src/lib.rs +++ b/examples/ffi-benchmark/src/lib.rs @@ -90,7 +90,9 @@ impl From for BenchError { mobench_sdk::BenchError::Runner(runner_err) => BenchError::ExecutionFailed { reason: runner_err.to_string(), }, - mobench_sdk::BenchError::UnknownFunction(name) => BenchError::UnknownFunction { name }, + mobench_sdk::BenchError::UnknownFunction(name, _available) => { + BenchError::UnknownFunction { name } + } _ => BenchError::ExecutionFailed { reason: err.to_string(), }, diff --git a/mobench-dx-spec.md b/mobench-dx-spec.md new file mode 100644 index 0000000..2e04084 --- /dev/null +++ b/mobench-dx-spec.md @@ -0,0 +1,156 @@ +# mobench DX Improvement Spec + +## Goals + +Primary goals +- Reduce first-time integration to under 30 minutes for a new crate. +- Reduce iteration loop to under 2 minutes for edit -> run on device. +- Make BrowserStack runs predictable, reproducible, and traceable. + +Secondary goals +- Make the system self-documenting: CLI output shows next steps. +- Provide clear error guidance for common failure modes. +- Ensure benchmark configuration always flows into the mobile app. + +## Current Friction Summary + +Integration +- Too much manual boilerplate (UniFFI types, bindgen, error mapping). +- Benchmark name discovery is not surfaced by the CLI. + +Build and run +- Build vs run behavior can be unclear; output depends on cached scaffolding. +- Benchmark choice is not reliably passed into the iOS app; defaults can win. + +BrowserStack +- Requires manual packaging and credential setup; weak feedback on device or test selection errors. +- Artifacts and results are not always correlated to the requested benchmark config. + +Reporting +- Report may not reflect requested benchmark (function mismatch, missing config). +- Report source (local vs device vs fetched artifacts) is not explicit. + +## Design Principles + +- Single source of truth for benchmark configuration. +- Deterministic automation: same CLI args yield same device run. +- Clear orchestration: run always produces complete, consistent artifacts. +- Progressive disclosure: power-user options without overwhelming defaults. + +## Proposed Improvements + +### A. CLI and Configuration + +A1. Unified benchmark spec pipeline +- CLI always writes a bench spec and ensures it is bundled into the app. +- CLI args override defaults and any prior spec. + +A2. Run always uses the requested benchmark +- Validate function exists before running. +- Ensure spec is embedded in iOS bundle and Android assets. + +A3. Standardized config discovery +- Support `mobench.toml` by default with precedence: + 1. CLI args + 2. mobench.toml + 3. .env.local (credentials only) + 4. defaults + +A4. Benchmark name discovery +- `mobench list` always works without a full build. +- Provide clear errors when inventory registry is missing. + +A5. Better CLI output +- Print resolved spec at the start of every run (function, iterations, warmup, devices, profile). +- Print exact locations for bench spec and artifacts. + +### B. Build and Run Artifacts + +B1. Single build+package+run path +- `mobench run` always: + 1. Generates scaffolding if missing. + 2. Builds and generates bindings. + 3. Packages IPA/XCUITest for iOS when BrowserStack is used. + +B2. Deterministic build output paths +- All artifacts live under `target/mobench/{platform}`. +- No cross-repo path leakage; paths are relative to the bench crate. + +B3. Explicit artifact stamping +- Embed `bench_spec.json` in iOS app bundle and Android assets. +- Add `bench_meta.json` with spec, commit hash (if available), build time, target, and profile. + +### C. BrowserStack Workflow + +C1. Credentials onboarding +- Detect missing credentials and suggest exact variables or config paths. + +C2. Device UX +- `mobench devices` lists available device identifiers and OS versions. +- `--devices` supports fuzzy match and validation. + +C3. Artifact correlation +- Every run outputs build ID, device/OS used, and local fetch paths. + +C4. Fetch as default for BrowserStack +- `mobench run` defaults to fetching artifacts. +- Poll with clear progress and timeout. +- If video fetch fails, continue and warn once. + +### D. Benchmark Authoring and Fixtures + +D1. SDK-provided UniFFI types +- Provide `mobench-sdk::uniffi` exports for BenchSpec, BenchSample, BenchReport, BenchError. + +D2. Fixture helper library +- Provide helpers like `deterministic_rng(seed)` and cached input generation. + +D3. Benchmark APIs +- Support setup vs run separation for proof-only vs full pipeline benchmarks. + +### E. Testing and Verification + +E1. `mobench verify` +- Validate registry, spec, and artifacts; run local smoke tests where possible. + +E2. Spec consistency check +- CLI check to verify bundle contains expected spec. + +### F. Reporting and Data Model + +F1. Structured result format +- Standard JSON report with spec, device info, samples, stats, and timestamps. +- Report must reflect spec actually run. + +F2. Summary display +- `mobench summary` prints avg/min/max/median, sample count, device, and OS version. + +### G. Documentation and Onboarding + +G1. Quick start (10 steps max) +- Minimal flow: init -> edit -> run on device. + +G2. Error-first docs +- Link to top 10 errors with fixes and recovery steps. + +## Priority Roadmap + +P0 +- Benchmark spec always embedded in app bundle. +- `mobench run` produces complete artifacts. +- Requested benchmark selection is always honored. + +P1 +- `mobench list` always works. +- `mobench devices` and device validation. +- `mobench verify` and `mobench summary`. + +P2 +- Rich reporting dashboard. +- Spec snapshots and result comparisons across builds. + +## Success Metrics + +- Under 30 minutes to first device run for a new project. +- Under 2 minutes to iterate from change to device run. +- Zero mismatch between requested and measured benchmark function. From 404787f004f8b7b2f97b387b521a9972c7ce39f1 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Feb 2026 10:16:07 -0300 Subject: [PATCH 060/196] Mobench CI Contract v1: CLI orchestration, reports, action + docs (release 0.1.14) (#10) * docs: map existing codebase - STACK.md - Technologies and dependencies - ARCHITECTURE.md - System design and patterns - STRUCTURE.md - Directory layout - CONVENTIONS.md - Code style and patterns - TESTING.md - Test structure - INTEGRATIONS.md - External services - CONCERNS.md - Technical debt and issues * chore: remove obsolete planning documents * docs: document iOS auto-packaging behavior with progress messages Add progress output when mobench auto-packages iOS artifacts for BrowserStack runs, showing the IPA and XCUITest paths that are generated. Update CLAUDE.md with a new "Automatic iOS Packaging" section that explains how users can let mobench automatically package iOS artifacts instead of manually running package-ipa and package-xcuitest commands. * fix: add emoji indicators to iOS auto-packaging progress messages * feat(sdk): add unified ffi module for UniFFI integration Add a new `ffi` module to mobench-sdk that provides a single import point for all FFI-related types and traits needed to create UniFFI bindings for mobile platforms. The module includes: - BenchSpecFfi, BenchSampleFfi, BenchReportFfi, BenchErrorFfi types - IntoFfi and FromFfi conversion traits with blanket implementations - run_benchmark_ffi convenience function for FFI-ready benchmark execution - Re-exports from uniffi_types for backwards compatibility - Comprehensive tests for all type conversions * feat(cli): add extract_benchmark_summary helper for unified result parsing * docs: clarify single-command BrowserStack flow in CLI and CLAUDE.md * test(cli): add init-sdk mobench.toml generation tests Add test module to verify that init-sdk command properly generates mobench.toml configuration file with correct project settings including project name, library name (with hyphens converted to underscores), and all required configuration sections. * docs: update examples and integration guide with ffi module usage - Add comment to ffi-benchmark example about mobench_sdk::ffi module alternative - Add "Using the FFI Module" section to BENCH_SDK_INTEGRATION.md with: - Overview of ffi module types (BenchSpecFfi, BenchReportFfi, etc.) - Option 1: Using FFI types directly for simple cases - Option 2: Defining custom UniFFI types (recommended for full bindings) * chore: gitignore docs/plans directory * feat(sdk): add statistical helpers, black_box and inventory re-exports - Add BenchReport methods: mean_ns(), median_ns(), std_dev_ns(), percentile_ns(), min_ns(), max_ns(), summary() - Add BenchSummary struct for serializable statistics - Re-export `inventory` crate so users don't need separate dependency - Re-export `std::hint::black_box` for preventing compiler optimizations Co-Authored-By: Claude Opus 4.5 * feat(cli): add CI/devex helpers (doctor, ci init, junit, device-matrix) * perf(ci): improve action caching (gradle/target/android sdk root) * Improve CI template and doctor env/flag behavior * feat(ci): add ci run orchestration and local action example * Fix iOS CI target setup and preserve CI outputs on regression exit * feat(mobench): implement CI contract phases, fixture/report commands, and migration docs * fix: address open PR review threads * docs: align guides with latest CI/action behavior * docs+sdk: refresh rustdocs, package metadata, and concerns resolution * chore(release): bump mobench crates to v0.1.14 --------- Co-authored-by: dcbuilder.eth Co-authored-by: Claude Opus 4.5 --- .github/actions/mobench/README.md | 75 + .github/actions/mobench/action.yml | 244 ++ .../workflows/mobile-bench-action-example.yml | 64 + .gitignore | 3 + .planning/codebase/ARCHITECTURE.md | 225 + .planning/codebase/CONVENTIONS.md | 244 ++ .planning/codebase/INTEGRATIONS.md | 140 + .planning/codebase/STACK.md | 115 + .planning/codebase/STRUCTURE.md | 280 ++ .planning/codebase/TESTING.md | 278 ++ BENCH_SDK_INTEGRATION.md | 53 +- BROWSERSTACK_CI_INTEGRATION.md | 15 +- BUILD.md | 5 +- CLAUDE.md | 69 +- Cargo.lock | 369 +- Cargo.toml | 2 +- README.md | 50 +- TESTING.md | 4 +- crates/mobench-macros/Cargo.toml | 2 +- crates/mobench-macros/src/lib.rs | 2 +- crates/mobench-sdk/Cargo.toml | 4 +- crates/mobench-sdk/README.md | 2 +- crates/mobench-sdk/src/builders/android.rs | 188 +- crates/mobench-sdk/src/builders/common.rs | 58 +- crates/mobench-sdk/src/builders/ios.rs | 258 +- crates/mobench-sdk/src/builders/mod.rs | 6 +- crates/mobench-sdk/src/codegen.rs | 190 +- crates/mobench-sdk/src/ffi.rs | 259 ++ crates/mobench-sdk/src/lib.rs | 17 +- crates/mobench-sdk/src/timing.rs | 131 +- crates/mobench-sdk/src/types.rs | 6 +- crates/mobench-sdk/src/uniffi_types.rs | 4 +- crates/mobench/Cargo.toml | 6 +- crates/mobench/README.md | 236 +- crates/mobench/src/browserstack.rs | 71 +- crates/mobench/src/config.rs | 13 +- crates/mobench/src/lib.rs | 3716 +++++++++++++++-- crates/mobench/templates/ci/action.README.md | 75 + crates/mobench/templates/ci/action.yml | 244 ++ crates/mobench/templates/ci/mobile-bench.yml | 62 + docs/CONCERNS_RESOLUTION_2026-02-16.md | 74 + docs/CONTRACT_CI_V1.md | 108 + docs/MIGRATION_GUIDE.md | 84 + docs/adr/0001-mobench-ci-contract-v1.md | 74 + docs/schemas/ci-contract-v1.schema.json | 36 + docs/schemas/summary-v1.schema.json | 61 + examples/ffi-benchmark/src/lib.rs | 8 + mobench-bugs-summary.md | 208 - mobench-local-build-dx-report.md | 244 -- 49 files changed, 7652 insertions(+), 1030 deletions(-) create mode 100644 .github/actions/mobench/README.md create mode 100644 .github/actions/mobench/action.yml create mode 100644 .github/workflows/mobile-bench-action-example.yml create mode 100644 .planning/codebase/ARCHITECTURE.md create mode 100644 .planning/codebase/CONVENTIONS.md create mode 100644 .planning/codebase/INTEGRATIONS.md create mode 100644 .planning/codebase/STACK.md create mode 100644 .planning/codebase/STRUCTURE.md create mode 100644 .planning/codebase/TESTING.md create mode 100644 crates/mobench-sdk/src/ffi.rs create mode 100644 crates/mobench/templates/ci/action.README.md create mode 100644 crates/mobench/templates/ci/action.yml create mode 100644 crates/mobench/templates/ci/mobile-bench.yml create mode 100644 docs/CONCERNS_RESOLUTION_2026-02-16.md create mode 100644 docs/CONTRACT_CI_V1.md create mode 100644 docs/MIGRATION_GUIDE.md create mode 100644 docs/adr/0001-mobench-ci-contract-v1.md create mode 100644 docs/schemas/ci-contract-v1.schema.json create mode 100644 docs/schemas/summary-v1.schema.json delete mode 100644 mobench-bugs-summary.md delete mode 100644 mobench-local-build-dx-report.md diff --git a/.github/actions/mobench/README.md b/.github/actions/mobench/README.md new file mode 100644 index 0000000..b7f0d01 --- /dev/null +++ b/.github/actions/mobench/README.md @@ -0,0 +1,75 @@ +# mobench GitHub Action + +Run `mobench ci run` in GitHub Actions with caching, Android SDK setup, and artifact upload. + +## Usage + +```yaml +- uses: ./.github/actions/mobench + with: + command: cargo mobench ci run + run-args: | + --target android + --function sample_fns::fibonacci + --iterations 30 + --warmup 5 + --devices "Google Pixel 7-13.0" + --release + --fetch + ci: false + ndk-version: "26.1.10909125" + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} +``` + +## Inputs + +- `command`: command to invoke. Supported values are `cargo mobench ci run` (default) and `cargo mobench run`. +- `run-args`: arguments passed to `command`. Use quoted values for arguments containing spaces (for example device names). +- `ci`: append `--ci` only when `command` is exactly `cargo mobench run`; ignored for `cargo mobench ci run`. +- `install-mobench`: install `mobench` with cargo-binstall/cargo install. +- `mobench-version`: optional version to install. +- `install-cargo-ndk`: install `cargo-ndk` for Android builds. +- `setup-android`: install Android SDK/NDK packages. +- `ndk-version`: Android NDK version (used for setup + `ANDROID_NDK_HOME`). +- `android-sdk-root`: Android SDK root directory on the runner. +- `android-packages`: SDK packages list for `setup-android`. +- `cache-cargo`: cache cargo registry/git and `target`. +- `cache-target`: cache `target/` (can be large). +- `cache-gradle`: cache `~/.gradle` wrapper and caches. +- `cache-android`: cache Android SDK/NDK. +- `artifact-name`: artifact name. +- `artifact-path`: paths to upload. +- `pr-comment`: publish sticky PR comment from CI summary (`true|false`). +- `pr-number`: PR number override (optional). +- `pr-comment-marker`: sticky comment marker used for idempotent updates. +- `github-token`: token for PR comment publishing. + +## Notes + +- Inputs are passed through environment variables in shell steps to reduce script-injection risk from workflow inputs. +- `command` is allow-listed in the action implementation; unsupported command values fail the job early. + +## Cache keys + +The action uses deterministic cache keys: +- Cargo cache: `${runner.os}-cargo-${hashFiles('**/Cargo.lock')}` +- Target cache: `${runner.os}-target-${hashFiles('**/Cargo.lock')}` +- Gradle cache: `${runner.os}-gradle-${hashFiles('**/*.gradle*', '**/gradle/wrapper/gradle-wrapper.properties', '**/gradle.properties')}` +- Android SDK cache: `${runner.os}-android-${inputs.ndk-version}` + +## PR comment mode + +To enable sticky PR comments, grant workflow permissions and pass token: + +```yaml +permissions: + contents: read + pull-requests: write + +- uses: ./.github/actions/mobench + with: + pr-comment: true + github-token: ${{ github.token }} +``` diff --git a/.github/actions/mobench/action.yml b/.github/actions/mobench/action.yml new file mode 100644 index 0000000..50dded6 --- /dev/null +++ b/.github/actions/mobench/action.yml @@ -0,0 +1,244 @@ +name: "mobench" +description: "Run mobile benchmarks with mobench" +inputs: + command: + description: "Command to invoke (supported: cargo mobench ci run | cargo mobench run)" + required: false + default: "cargo mobench ci run" + run-args: + description: "Arguments passed to command (quote values containing spaces)" + required: true + ci: + description: "Append --ci only when command is cargo mobench run (ignored for ci run)" + required: false + default: "false" + install-mobench: + description: "Install mobench with cargo-binstall or cargo install" + required: false + default: "true" + mobench-version: + description: "Optional mobench version to install" + required: false + default: "" + install-cargo-ndk: + description: "Install cargo-ndk" + required: false + default: "true" + setup-android: + description: "Setup Android SDK/NDK" + required: false + default: "true" + ndk-version: + description: "Android NDK version" + required: false + default: "26.1.10909125" + android-sdk-root: + description: "Android SDK root directory on the runner" + required: false + default: "/usr/local/lib/android/sdk" + android-packages: + description: "SDK packages to install via setup-android" + required: false + default: | + platform-tools + platforms;android-34 + build-tools;34.0.0 + ndk;26.1.10909125 + cache-cargo: + description: "Cache cargo registry/git and target" + required: false + default: "true" + cache-target: + description: "Cache Cargo target/ directory (can be large)" + required: false + default: "true" + cache-gradle: + description: "Cache Gradle wrapper and caches (useful if mobench triggers Gradle builds)" + required: false + default: "true" + cache-android: + description: "Cache Android SDK/NDK" + required: false + default: "true" + artifact-name: + description: "Artifact name for upload" + required: false + default: "mobench-results" + artifact-path: + description: "Paths to upload as artifacts" + required: false + default: | + target/mobench/ci/summary.json + target/mobench/ci/summary.md + target/mobench/ci/results.csv + target/browserstack + pr-comment: + description: "Publish sticky PR comment from CI summary" + required: false + default: "false" + pr-number: + description: "PR number (optional, auto-detected from GITHUB_REF when omitted)" + required: false + default: "" + pr-comment-marker: + description: "Marker string used to update an existing sticky PR comment" + required: false + default: "" + github-token: + description: "GitHub token used to publish PR comments" + required: false + default: "" +runs: + using: "composite" + steps: + - name: Cache cargo + if: inputs.cache-cargo == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Cache target + if: inputs.cache-target == 'true' + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-target-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-target- + + - name: Cache Gradle + if: inputs.cache-gradle == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle/wrapper/gradle-wrapper.properties', '**/gradle.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Cache Android SDK + if: inputs.cache-android == 'true' && inputs.setup-android == 'true' + uses: actions/cache@v4 + with: + path: | + ${{ inputs.android-sdk-root }}/ndk/${{ inputs.ndk-version }} + ${{ inputs.android-sdk-root }}/platform-tools + ${{ inputs.android-sdk-root }}/platforms + ${{ inputs.android-sdk-root }}/build-tools + key: ${{ runner.os }}-android-${{ inputs.ndk-version }} + restore-keys: | + ${{ runner.os }}-android- + + - name: Setup Android SDK/NDK + if: inputs.setup-android == 'true' + uses: android-actions/setup-android@v3 + with: + packages: ${{ inputs.android-packages }} + + - name: Install cargo-ndk + if: inputs.install-cargo-ndk == 'true' + shell: bash + run: | + if ! command -v cargo-ndk >/dev/null 2>&1; then + cargo install cargo-ndk + fi + + - name: Install mobench + if: inputs.install-mobench == 'true' + shell: bash + env: + MOBENCH_VERSION: ${{ inputs.mobench-version }} + run: | + if command -v cargo-mobench >/dev/null 2>&1 || command -v mobench >/dev/null 2>&1; then + exit 0 + fi + install_args=() + if [ -n "$MOBENCH_VERSION" ]; then + install_args=(--version "$MOBENCH_VERSION") + fi + if command -v cargo-binstall >/dev/null 2>&1; then + cargo binstall -y mobench "${install_args[@]}" + else + cargo install cargo-binstall + cargo binstall -y mobench "${install_args[@]}" + fi + + - name: Run mobench + shell: bash + env: + ANDROID_SDK_ROOT_INPUT: ${{ inputs.android-sdk-root }} + NDK_VERSION_INPUT: ${{ inputs.ndk-version }} + MOBENCH_CI: ${{ inputs.ci }} + MOBENCH_COMMAND: ${{ inputs.command }} + MOBENCH_RUN_ARGS: ${{ inputs.run-args }} + run: | + export ANDROID_SDK_ROOT="$ANDROID_SDK_ROOT_INPUT" + export ANDROID_HOME="$ANDROID_SDK_ROOT_INPUT" + export ANDROID_NDK_HOME="$ANDROID_SDK_ROOT_INPUT/ndk/$NDK_VERSION_INPUT" + extra_args=() + if [ "$MOBENCH_CI" = "true" ] && [ "$MOBENCH_COMMAND" = "cargo mobench run" ]; then + extra_args=(--ci) + fi + case "$MOBENCH_COMMAND" in + "cargo mobench run"|"cargo mobench ci run") + ;; + *) + echo "Unsupported command input: $MOBENCH_COMMAND" + exit 1 + ;; + esac + read -r -a command_parts <<< "$MOBENCH_COMMAND" + run_args=() + if [ -n "$MOBENCH_RUN_ARGS" ]; then + while IFS= read -r line; do + if [ -z "$line" ]; then + continue + fi + read -r -a parts <<< "$line" + run_args+=("${parts[@]}") + done <<< "$MOBENCH_RUN_ARGS" + fi + "${command_parts[@]}" "${extra_args[@]}" "${run_args[@]}" + + - name: Publish sticky PR comment + if: always() && inputs.pr-comment == 'true' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + PR_NUMBER_INPUT: ${{ inputs.pr-number }} + PR_COMMENT_MARKER: ${{ inputs.pr-comment-marker }} + run: | + pr_number="$PR_NUMBER_INPUT" + if [ -z "$pr_number" ] && [ -n "${GITHUB_REF:-}" ]; then + pr_number="$(echo "$GITHUB_REF" | awk -F/ '/^refs\/pull\// { print $3 }')" + fi + if [ -z "$pr_number" ]; then + echo "No PR number detected; skipping sticky comment publish." + exit 0 + fi + if [ ! -f target/mobench/ci/summary.json ]; then + echo "CI summary not found at target/mobench/ci/summary.json; skipping sticky comment publish." + exit 0 + fi + if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "GITHUB_TOKEN is empty; skipping sticky comment publish." + exit 0 + fi + cargo mobench report github \ + --pr "$pr_number" \ + --summary target/mobench/ci/summary.json \ + --marker "$PR_COMMENT_MARKER" \ + --publish + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.artifact-path }} diff --git a/.github/workflows/mobile-bench-action-example.yml b/.github/workflows/mobile-bench-action-example.yml new file mode 100644 index 0000000..4fe3ac3 --- /dev/null +++ b/.github/workflows/mobile-bench-action-example.yml @@ -0,0 +1,64 @@ +name: Mobile Bench Action Example + +on: + workflow_dispatch: + inputs: + platform: + description: "android | ios" + required: false + default: "android" + +permissions: + contents: read + +jobs: + android: + if: ${{ github.event.inputs.platform == 'android' || github.event.inputs.platform == '' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + - uses: ./.github/actions/mobench + with: + command: cargo mobench ci run + run-args: > + --target android + --function sample_fns::fibonacci + --iterations 20 + --warmup 5 + --devices "Google Pixel 7-13.0" + --release + --fetch + ci: false + ndk-version: "26.1.10909125" + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + + ios: + if: ${{ github.event.inputs.platform == 'ios' }} + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios + - uses: ./.github/actions/mobench + with: + command: cargo mobench ci run + run-args: > + --target ios + --function sample_fns::fibonacci + --iterations 20 + --warmup 5 + --devices "iPhone 14-16" + --release + --fetch + ci: false + setup-android: false + install-cargo-ndk: false + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} diff --git a/.gitignore b/.gitignore index 5340037..a84b81f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ run-*.json browserstack-*.log BROWSERSTACK_RUN_*.md bench-mobile + +# Implementation plans (local only) +docs/plans/ diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 0000000..6188495 --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,225 @@ +# Architecture + +**Analysis Date:** 2026-01-21 + +## Pattern Overview + +**Overall:** Layered command-line SDK orchestrator with compile-time registration and FFI abstraction. + +The mobench ecosystem follows a **three-crate architecture** with clear separation of concerns: +1. **CLI orchestration layer** (`mobench`) - Entry point for all operations +2. **SDK core** (`mobench-sdk`) - Core timing, building, and registry infrastructure +3. **Compile-time macro registration** (`mobench-macros`) - `#[benchmark]` attribute implementation + +**Key Characteristics:** +- **Macro-based registration** - Functions marked with `#[benchmark]` auto-register via `inventory` crate at compile time +- **Feature-gated modularity** - `runner-only` feature for minimal mobile binaries vs full SDK with build automation +- **Embedded templates** - Android/iOS app templates compiled into the SDK using `include_dir!` macro +- **UniFFI FFI boundary** - Type-safe bindings generated automatically from Rust proc macros (not UDL) +- **Cross-platform building** - Automated native library builds for Android (NDK) and iOS (Xcode) + +## Layers + +**CLI Orchestrator (mobench):** +- Purpose: Entry point driving the full mobile benchmarking workflow +- Location: `crates/mobench/src/` +- Contains: Command handlers (init, build, run, list, fetch, package-ipa, package-xcuitest, compare) +- Depends on: `mobench-sdk` (builders, codegen, registry), BrowserStack REST API client +- Used by: End users via `cargo mobench` or `mobench` binary + +**SDK Core (mobench-sdk):** +- Purpose: Timing harness, function registry, build automation, template generation +- Location: `crates/mobench-sdk/src/` +- Contains: Timing module, registry, runner, builders (Android/iOS), codegen, types +- Depends on: Standard library, serde, uniffi, include_dir, inventory +- Used by: CLI (`mobench`), user benchmarking projects, mobile apps + +**Macro Registry (mobench-macros):** +- Purpose: Compile-time attribute macro for benchmark function registration +- Location: `crates/mobench-macros/src/` +- Contains: `#[benchmark]` attribute implementation +- Depends on: syn, quote, proc-macro2 +- Used by: User projects and example code + +**Timing Harness (mobench-sdk::timing):** +- Purpose: Minimal, portable benchmarking infrastructure for mobile targets +- Location: `crates/mobench-sdk/src/timing.rs` +- Contains: `run_closure()`, `BenchSpec`, `BenchSample`, `BenchReport`, nanosecond-precision timing +- Depends on: Standard library only (no platform-specific dependencies) +- Used by: All benchmark execution, available even with `runner-only` feature + +**Build Automation (mobench-sdk::builders):** +- Purpose: Cross-compile Rust to mobile targets and package into native apps +- Location: `crates/mobench-sdk/src/builders/` +- Contains: `AndroidBuilder`, `IosBuilder`, common utilities +- Depends on: Rust toolchain, Android NDK, Xcode, `cargo-ndk`, `uniffi-bindgen` +- Used by: CLI `build` and `run` commands + +**Template System (mobench-sdk::codegen):** +- Purpose: Generate mobile app projects from embedded templates +- Location: `crates/mobench-sdk/src/codegen.rs`, `crates/mobench-sdk/templates/` +- Contains: Template files (Android Gradle, iOS Xcode), parameterization +- Depends on: `include_dir!` macro for compile-time embedding +- Used by: `init` command to scaffold new projects + +**Registry (mobench-sdk::registry):** +- Purpose: Runtime discovery of benchmark functions +- Location: `crates/mobench-sdk/src/registry.rs` +- Contains: `discover_benchmarks()`, `find_benchmark()`, `list_benchmark_names()` +- Depends on: `inventory` crate for global collection +- Used by: Runner, CLI list command + +**Runner (mobench-sdk::runner):** +- Purpose: Benchmark execution engine linking registry to timing +- Location: `crates/mobench-sdk/src/runner.rs` +- Contains: `run_benchmark()`, `BenchmarkBuilder` +- Depends on: Registry, timing module +- Used by: CLI run command, mobile apps + +## Data Flow + +**User Project Setup:** +1. User adds `mobench-sdk` to `Cargo.toml` with optional build feature +2. User marks functions with `#[benchmark]` attribute +3. At compile time, macro registers functions via `inventory` crate +4. At runtime, registry discovers benchmarks when library loads + +**Mobile Build Pipeline:** +1. User runs `cargo mobench build --target android` (CLI) +2. CLI instantiates `AndroidBuilder` with project path +3. Builder validates workspace (auto-detects crate location) +4. Builder compiles Rust to `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` targets via `cargo-ndk` +5. Builder generates UniFFI Kotlin bindings from Rust proc macro types +6. Builder syncs `.so` files into `android/app/src/main/jniLibs/{abi}/` +7. Builder runs `gradle assemble` to build APK and test APK +8. Builder outputs APK to `target/mobench/android/app/build/outputs/apk/` + +**Benchmark Execution (Local):** +1. User runs `cargo mobench run --function my_benchmark --local-only` +2. CLI loads user project, builds benchmark binary +3. Registry discovers all `#[benchmark]` functions via `inventory` +4. Runner finds function by name, invokes it via registered closure +5. Timing harness measures iterations + warmup, records nanosecond samples +6. Report generated with statistics (mean, std dev, quantiles) + +**Benchmark Execution (BrowserStack):** +1. User runs `cargo mobench run --target android --function my_benchmark --devices "Pixel 7-13.0"` +2. CLI builds APK with `release` profile (smaller upload) +3. CLI uploads APK + test APK to BrowserStack App Automate +4. Mobile app reads `bench_spec.json` from assets, calls `run_benchmark()` via FFI +5. App measures times via SDK's timing harness, returns JSON results +6. BrowserStack returns session artifacts to CLI +7. Results parsed and report generated + +**State Management:** +- **Compile-time:** Benchmark function metadata embedded via `inventory` collect +- **Runtime:** Registry maintains in-memory collection of function pointers +- **Build artifacts:** Generated templates written to `target/mobench/`, source commits optional +- **Results:** JSON reports with samples, metadata (device, SDK version, timestamp) + +## Key Abstractions + +**BenchSpec:** +- Purpose: Declarative benchmark configuration (name, iterations, warmup) +- Examples: `crates/mobench-sdk/src/timing.rs`, `crates/sample-fns/src/lib.rs:8-13` +- Pattern: Serializable struct passed through entire pipeline (user → CLI → mobile app → timing harness) + +**BenchFunction:** +- Purpose: Runtime-discoverable benchmark function pointer +- Examples: `crates/mobench-sdk/src/registry.rs:12-21` +- Pattern: Generated by `#[benchmark]` macro, collected via `inventory`, invoked by runner + +**Builder Pattern:** +- Purpose: Fluent configuration for cross-compilation workflows +- Examples: `crates/mobench-sdk/src/builders/android.rs:66-110`, `crates/mobench-sdk/src/runner.rs:68-100` +- Pattern: Stateful builder accumulating configuration, `.build()` or `.run()` executes full pipeline + +**FFI Boundary (UniFFI Proc Macros):** +- Purpose: Type-safe mobile binding generation from Rust annotations +- Examples: `crates/sample-fns/src/lib.rs:8, 16, 22, 29, 43, 93` +- Pattern: `#[derive(uniffi::Record)]` on types, `#[uniffi::export]` on functions, `uniffi::setup_scaffolding!()` generates bindings + +**Error Propagation:** +- Purpose: Layered error handling from timing harness through CLI +- Examples: `crates/mobench-sdk/src/types.rs:50-100`, wraps `TimingError` as `BenchError` +- Pattern: Result types at all boundaries, detailed error context preserved + +## Entry Points + +**CLI Binary:** +- Location: `crates/mobench/src/main.rs` +- Triggers: User invokes `cargo mobench` or `mobench` binary +- Responsibilities: Parse CLI args, delegate to subcommands (init, build, run, list, fetch, etc.) + +**SDK Init Command:** +- Location: `crates/mobench/src/lib.rs` (Command::InitSdk handler) +- Triggers: `cargo mobench init --target android --project-name my-project` +- Responsibilities: Generate Android/iOS projects, create config files, scaffold example benchmarks + +**SDK Build Command:** +- Location: `crates/mobench/src/lib.rs` (Command::Build handler) +- Triggers: `cargo mobench build --target android` +- Responsibilities: Instantiate builder, validate workspace, cross-compile, package APK/xcframework + +**SDK Run Command:** +- Location: `crates/mobench/src/lib.rs` (Command::Run handler) +- Triggers: `cargo mobench run --target android --function my_benchmark` +- Responsibilities: Build artifacts (if needed), upload to BrowserStack (if --devices), collect results + +**Registry Discovery:** +- Location: `crates/mobench-sdk/src/registry.rs:41-43` +- Triggers: Binary loads, user calls `discover_benchmarks()` +- Responsibilities: Iterate `inventory` collection, return all registered functions + +**Timing Harness (Mobile):** +- Location: `crates/mobench-sdk/src/timing.rs:run_closure()` +- Triggers: Mobile app calls `run_benchmark()` via UniFFI FFI +- Responsibilities: Execute closure with warmup, record nanosecond samples, return report + +## Error Handling + +**Strategy:** Layered result types with context preservation. + +**Patterns:** +- `mobench-sdk` defines `BenchError` enum covering all operation categories (Runner, UnknownFunction, Execution, Io, Serialization, Config, Build) +- CLI uses `anyhow::Result` with `.context()` for actionable error messages +- Builders return `Result` with detailed build failure diagnostics +- Timing harness returns `TimingError` (minimal: NoIterations, Execution) +- Mobile apps receive `BenchError` via UniFFI and propagate to caller + +**Handling patterns:** +```rust +// SDK error wrapping +match run_benchmark(spec) { + Ok(report) => { ... }, + Err(BenchError::UnknownFunction(name)) => eprintln!("not found: {}", name), + Err(BenchError::Runner(e)) => eprintln!("timing error: {}", e), + Err(e) => eprintln!("error: {}", e), +} + +// CLI error context +builder.build(&config) + .context("failed to build Android APK")?; +``` + +## Cross-Cutting Concerns + +**Logging:** Controlled via `--verbose` / `-v` flag in CLI. Builders and commands print progress only when enabled. No structured logging framework; simple stderr output. + +**Validation:** +- Project workspace validation (Cargo.toml, crate location) in builders +- `BenchSpec` validation (iterations > 0) in timing harness +- Config file validation (required fields) in config module + +**Authentication:** BrowserStack credentials resolved from: +1. Config file (supports `${ENV_VAR}` expansion) +2. Environment variables (`BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`) +3. `.env.local` file (loaded via `dotenvy`) + +**Feature Flags:** +- `full` (default): Builders, codegen, registry, runner enabled +- `runner-only`: Timing module only, no build automation or registry + +--- + +*Architecture analysis: 2026-01-21* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 0000000..2b8575c --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,244 @@ +# Coding Conventions + +**Analysis Date:** 2026-01-21 + +## Naming Patterns + +**Files:** +- Rust source files: `lowercase_with_underscores.rs` +- Module structure: `mod.rs` for module files, `filename.rs` for single-item modules +- Examples: `crates/mobench-sdk/src/timing.rs`, `crates/mobench-sdk/src/builders/android.rs` + +**Functions:** +- Public functions and private helpers: `snake_case` +- Builder methods: `snake_case`, returning `Self` for chaining +- Test functions: `test_()` (e.g., `test_rejects_zero_iterations()`) +- Examples: `run_closure()`, `discover_benchmarks()`, `find_benchmark()`, `get_cargo_target_dir()` + +**Variables:** +- Local variables and parameters: `snake_case` +- Constants: `SCREAMING_SNAKE_CASE` +- Field names in structs: `snake_case` +- Example: `CHECKSUM_INPUT` for const, `project_root` for variables + +**Types:** +- Struct and enum names: `PascalCase` +- Type parameters: Single uppercase letters or `PascalCase` (e.g., `T`, `F`) +- Examples: `BenchSpec`, `BenchSample`, `BenchError`, `AndroidBuilder`, `IosBuilder`, `BenchFunction` + +## Code Style + +**Formatting:** +- Edition: Rust 2021 (specified in workspace `Cargo.toml` as `edition = "2024"`) +- Indentation: 4 spaces (Rust standard) +- Line length: No enforced limit observed; examples use 80-100 character average +- Trailing commas: Used in multi-line collections and match expressions + +**Linting:** +- No explicit linter config found (no `.clippy.toml` or `rust-clippy.toml`) +- Implicitly follows Rust conventions through code style +- Error types use `#[error("...")]` from `thiserror` crate for custom messages + +## Import Organization + +**Order:** +1. Crate imports (`use crate::...`) +2. Standard library imports (`use std::...`) +3. External crate imports (`use external_crate::...`) +4. Re-exports and module declarations (`pub use ...`, `mod ...`) + +**Pattern Examples:** + +From `crates/mobench-sdk/src/timing.rs`: +```rust +use serde::{Deserialize, Serialize}; +use std::time::{Duration, Instant}; +use thiserror::Error; +``` + +From `crates/mobench-sdk/src/builders/android.rs`: +```rust +use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; +use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +``` + +**Path Aliases:** +- No path aliases configured (no `[paths]` in `Cargo.toml`) +- Uses standard module hierarchy + +## Error Handling + +**Patterns:** +- `Result` return types for fallible operations +- Custom error enum `BenchError` with `#[derive(Debug, thiserror::Error)]` +- Error variants use `#[error("message")]` for display formatting +- `#[from]` for automatic conversion from underlying error types +- `Ok(value)?` syntax for error propagation +- `anyhow::Result` and `anyhow::Context` in CLI code (`mobench/src/lib.rs`) + +**Error Handling in Core SDK:** +```rust +#[derive(Debug, thiserror::Error)] +pub enum BenchError { + #[error("benchmark runner error: {0}")] + Runner(#[from] crate::timing::TimingError), + + #[error("unknown benchmark function: {0}...")] + UnknownFunction(String), + + #[error("I/O error: {0}. Check file paths and permissions")] + Io(#[from] std::io::Error), +} +``` + +**Validation Pattern:** +- Early returns for validation failures +- Detailed error messages with fix suggestions +- Example from `crates/mobench-sdk/src/builders/common.rs`: +```rust +if !project_root.exists() { + return Err(BenchError::Build(format!( + "Project root does not exist: {}\n\n\ + Ensure you are running from the correct directory or specify --project-root.", + project_root.display() + ))); +} +``` + +## Logging + +**Framework:** `eprintln!()` macro for error output to stderr + +**Patterns:** +- No centralized logging framework used +- Simple stderr output for errors: `eprintln!("{err:#}")` +- Example from `crates/mobench/src/main.rs`: +```rust +fn main() { + if let Err(err) = mobench::run() { + eprintln!("{err:#}"); + std::process::exit(1); + } +} +``` + +**Verbose Output:** +- Controlled via `--verbose` or `-v` CLI flag +- Verbose mode prints full command execution details + +## Comments + +**When to Comment:** +- Complex algorithms or non-obvious logic +- FFI boundary details and platform-specific behavior +- Workarounds for tooling limitations +- Not required for simple, self-documenting code + +**JSDoc/TSDoc:** +- Uses Rust documentation comments (`//!` for modules, `///` for items) +- Triple-slash `///` comments appear before all public items +- Module-level documentation with `//!` at file start +- Examples provided in doc comments for complex APIs + +**Documentation Comment Pattern:** +```rust +/// Marks a function as a benchmark for mobile execution. +/// +/// This attribute macro registers the function in the global benchmark registry, +/// making it discoverable and executable by the mobench runtime. +/// +/// # Usage +/// +/// ```ignore +/// #[benchmark] +/// fn fibonacci_bench() { ... } +/// ``` +/// +/// # Requirements +/// +/// The annotated function must: +/// - Take no parameters +/// - Return `()` (unit type) +``` + +## Function Design + +**Size:** +- Range from 5-60 lines for typical functions +- Simple builders and utilities: 3-20 lines +- Complex builders with multiple validation steps: 100-200 lines +- Core timing function `run_closure()`: 25 lines + +**Parameters:** +- Builder methods accept `impl Into` for flexibility +- Generic closures with trait bounds +- Result returns for fallible operations +- Example: `pub fn new(name: impl Into, iterations: u32, warmup: u32) -> Result` + +**Return Values:** +- `Result` for fallible operations +- `Option` for optional lookups +- `Self` for builder chaining +- Direct values for infallible operations + +## Module Design + +**Exports:** +- Public types and functions: `pub` keyword explicitly +- Conditional exports with `#[cfg(feature = "...")]` +- Re-exports for convenience at crate root level +- Example from `crates/mobench-sdk/src/lib.rs`: +```rust +#[cfg(feature = "full")] +pub use registry::{BenchFunction, discover_benchmarks, find_benchmark, list_benchmark_names}; +pub use timing::{run_closure, TimingError}; +``` + +**Barrel Files:** +- `mod.rs` files organize module exports +- Example: `crates/mobench-sdk/src/builders/mod.rs` exports all builder types +- Re-export pattern: `pub use self::android::AndroidBuilder; pub use self::ios::IosBuilder;` + +**Feature-Gated Modules:** +- Full feature includes: `builders`, `codegen`, `registry`, `runner`, macros +- Runner-only feature: Minimal `timing` module only +- Conditional compilation: `#[cfg(feature = "full")]` + +## Derive Macros + +**Common Patterns:** +- `#[derive(Debug, Clone)]` for shared types +- `#[derive(Serialize, Deserialize)]` for serializable types +- `#[derive(Error)]` from `thiserror` for error enums +- `#[derive(uniffi::Record)]` for FFI-exposed types (in `sample-fns`) + +**Example:** +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} +``` + +## Builder Pattern + +**Implementation:** +- Builder struct with private fields +- `new()` constructor with required parameters +- Chainable builder methods returning `Self` +- Terminal method (e.g., `build()`) that performs action +- Example: `AndroidBuilder::new(...).verbose(true).output_dir(...).build()` + +**Defaults:** +- Sensible defaults in constructor +- AndroidBuilder: `verbose: false`, `output_dir: "target/mobench"`, `dry_run: false` + +--- + +*Convention analysis: 2026-01-21* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 0000000..3e50836 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,140 @@ +# External Integrations + +**Analysis Date:** 2026-01-21 + +## APIs & External Services + +**BrowserStack App Automate:** +- Service: Cloud-based mobile device testing and automation platform +- What it's used for: Running benchmarks on real Android and iOS devices; uploading APKs, xcframeworks, and test suites; scheduling test runs; collecting results +- SDK/Client: Custom REST client in `crates/mobench/src/browserstack.rs` +- Auth: HTTP Basic Auth with username and access key +- Base URL: `https://api-cloud.browserstack.com` + +**BrowserStack Espresso (Android):** +- Service: Test automation framework for Android apps +- API: `app-automate/espresso/v2/` endpoints +- Operations: + - Upload app APK: `POST /app-automate/espresso/v2/app` (multipart form) + - Upload test suite APK: `POST /app-automate/espresso/v2/test-suite` (multipart form) + - Schedule run: `POST /app-automate/espresso/v2/build` (JSON request body) + - Get build status: `GET /app-automate/espresso/v2/builds/{build_id}` + - Get device logs: `GET /app-automate/espresso/v2/builds/{build_id}/sessions/{session_id}/devicelogs` +- Implementation: `crates/mobench/src/browserstack.rs` - methods: `upload_espresso_app()`, `upload_espresso_test_suite()`, `schedule_espresso_run()`, `get_espresso_build_status()` + +**BrowserStack XCUITest (iOS):** +- Service: Test automation framework for iOS apps +- API: `app-automate/xcuitest/v2/` endpoints +- Operations: + - Upload app IPA: `POST /app-automate/xcuitest/v2/app` (multipart form) + - Upload test suite: `POST /app-automate/xcuitest/v2/test-suite` (multipart form, zip file) + - Schedule run: `POST /app-automate/xcuitest/v2/build` (JSON request body with `only_testing` field) + - Get build status: `GET /app-automate/xcuitest/v2/builds/{build_id}` + - Get device logs: `GET /app-automate/xcuitest/v2/builds/{build_id}/sessions/{session_id}/devicelogs` +- Implementation: `crates/mobench/src/browserstack.rs` - methods: `upload_xcuitest_app()`, `upload_xcuitest_test_suite()`, `schedule_xcuitest_run()`, `get_xcuitest_build_status()` +- Test specification: Hardcoded XCUITest selector `"BenchRunnerUITests/BenchRunnerUITests/testLaunchAndCaptureBenchmarkReport"` passed in `only_testing` field + +## Data Storage + +**Databases:** +- Not used. All data is ephemeral (benchmark specs and results are files on disk or in memory). + +**File Storage:** +- Local filesystem only (no cloud storage integration) +- Artifact locations: + - Build output: `target/mobench/android/` and `target/mobench/ios/` (customizable with `--output-dir`) + - Benchmark specs: `target/mobile-spec/{android,ios}/bench_spec.json` (written at build time, read by mobile apps) + - Results: `run-summary.json`, `run-summary.csv`, `run-summary.md` (written after benchmark execution) + - BrowserStack artifacts downloaded to: `target/mobench/` (device logs, benchmark results) + +**Caching:** +- None. No persistent caching layer. + +## Authentication & Identity + +**Auth Provider:** +- Custom - No OAuth/OIDC provider. Uses static credentials (username + access key). + +**Implementation:** +- Environment variables: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `BROWSERSTACK_PROJECT` (optional) +- Config file: `bench-config.toml` or `mobench.toml` with `${ENV_VAR}` expansion +- `.env.local` file: Loaded automatically via `dotenvy` crate +- HTTP Basic Auth: Credentials passed to all BrowserStack API requests as base64-encoded Authorization header +- Credential resolution order (in `crates/mobench/src/lib.rs` around line 1444-1478): + 1. Attempt to read from config file (with env var expansion) + 2. Fall back to environment variables + 3. Fall back to `.env.local` file + 4. Error if credentials not found + +## Monitoring & Observability + +**Error Tracking:** +- None. No external error tracking service integrated. + +**Logs:** +- Console output (stdout/stderr) +- Verbose flag (`--verbose` / `-v` in CLI) enables detailed output showing all executed commands +- Dry-run flag (`--dry-run`) previews what would be done without making changes +- BrowserStack device logs: Downloaded and saved locally after benchmark execution + - Location: `target/mobench/` (retrieved via `get_device_logs()` in `browserstack.rs`) + - Format: Raw device logs from BrowserStack, optionally filtered by session ID + +## CI/CD & Deployment + +**Hosting:** +- BrowserStack App Automate - No self-hosted deployment. All mobile execution happens on BrowserStack managed devices. +- GitHub Artifacts - Results and summaries uploaded to GitHub Actions workflow artifacts + - Artifacts: `host-run-summary`, `android-apk-artifact`, `ios-xcframework-artifact` + +**CI Pipeline:** +- GitHub Actions (`.github/workflows/mobile-bench.yml`) +- Trigger: Manual dispatch (`workflow_dispatch`) with platform selection (android, ios, or both) +- Steps: + 1. Host tests: `cargo test --all` + 2. Host benchmark summary: `cargo run -p mobench -- run --local-only` (5 iterations, 1 warmup) + 3. Android build: Compiles Rust, runs `cargo mobench build --target android`, uploads APK artifacts + 4. iOS build: Compiles Rust, runs `cargo mobench build --target ios`, creates xcframework and IPA artifacts +- Upload artifacts to GitHub Actions for download + +## Environment Configuration + +**Required env vars:** +- `BROWSERSTACK_USERNAME` - BrowserStack App Automate username +- `BROWSERSTACK_ACCESS_KEY` - BrowserStack App Automate access key + +**Optional env vars:** +- `BROWSERSTACK_PROJECT` - Project name for builds (defaults to config or empty) +- `ANDROID_NDK_HOME` - Path to Android NDK (required for Android builds on non-standard setups) + +**Secrets location:** +- GitHub Actions secrets (for CI): Not configured in this repo (would be added to `.github/` secrets) +- Local development: `.env.local` file (NOT committed to git) +- Config files: `bench-config.toml` or `mobench.toml` with `${ENV_VAR}` expansion + +## Webhooks & Callbacks + +**Incoming:** +- None. No webhook endpoints exposed. + +**Outgoing:** +- None. BrowserStack results are polled via REST API (`get_build_status()` methods), not pushed via webhook. + +## Result Collection & Aggregation + +**Data Flow:** +1. Benchmark parameters written to `bench_spec.json` during build +2. Mobile app reads `bench_spec.json` at runtime +3. Mobile app calls `run_benchmark()` via UniFFI bindings +4. Results serialized to JSON and returned to app +5. App uploads results or writes to local storage +6. CLI polls BrowserStack API for build status and device logs +7. Results parsed and formatted to JSON/CSV/Markdown files + +**Device Communication:** +- Android: Benchmark spec passed via Intent extras or read from `bench_spec.json` asset +- iOS: Benchmark spec read from bundle resource or environment variables +- Both: Results collected through UniFFI FFI boundary in mobile test automation code + +--- + +*Integration audit: 2026-01-21* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 0000000..5c0d8b6 --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,115 @@ +# Technology Stack + +**Analysis Date:** 2026-01-21 + +## Languages + +**Primary:** +- Rust 2024 edition - Core SDK, CLI tool, and proc macros. Published as `mobench`, `mobench-sdk`, `mobench-macros` on crates.io. + +**Secondary:** +- Rust 2021 edition - Example crates (`sample-fns`, `ffi-benchmark`) required for UniFFI-generated binding compatibility (UniFFI v0.28 targets 2021 edition) +- Kotlin - Auto-generated UniFFI FFI bindings for Android apps (via `uniffi-bindgen`) +- Swift - Auto-generated UniFFI FFI bindings for iOS apps (via `uniffi-bindgen`) + +## Runtime + +**Environment:** +- Rust toolchain (stable) + - Android targets: `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` + - iOS targets: `aarch64-apple-ios` (device), `aarch64-apple-ios-sim` (simulator on M1+ Macs) + +**Package Manager:** +- Cargo (Rust package manager) +- Lockfile: `Cargo.lock` (present) +- Workspace resolver: v2 + +## Frameworks + +**Core Benchmarking:** +- `mobench-sdk` - Core SDK library with timing harness (`timing` module), registry system, and build automation +- `mobench-macros` - Proc macro crate providing `#[benchmark]` attribute for function registration +- `mobench` - CLI tool for orchestrating builds, packaging, and execution + +**Code Generation & FFI:** +- UniFFI v0.28 - Foreign function interface (FFI) generation using proc macros (no UDL files) + - Feature: `build` for build script support + - Feature: `cli` for binding generation CLI + - Generates Kotlin and Swift bindings automatically from Rust code +- `inventory` v0.3 - Runtime function discovery via registration macros (used by `#[benchmark]`) +- `include_dir` v0.7 - Embed mobile app templates at compile time (no runtime file I/O) + +**CLI & Configuration:** +- `clap` v4 - Command-line argument parsing with `derive` feature +- `serde` + `serde_json` v1 - Serialization framework +- `serde_yaml` v0.9 - YAML parsing for device matrices +- `toml` v0.8 - TOML parsing for config files +- `dotenvy` v0.15 - Environment variable loading from `.env.local` files + +**Error Handling:** +- `thiserror` v1 - Derive macro for error types +- `anyhow` v1 - Error context and ergonomic error handling + +**HTTP & Networking:** +- `reqwest` v0.12 (blocking client) - HTTP client for BrowserStack API + - Features: `rustls-tls` (TLS via rustls, no OpenSSL), `blocking`, `json`, `multipart` (form-based uploads) + - Used for: app/test suite uploads, build scheduling, result fetching + +**Timing & Dates:** +- `time` v0.3 - Nanosecond-precision timing and RFC3339 timestamp formatting + +**Proc Macro Dependencies:** +- `syn` v2 - Full featured Rust AST parsing (required for `#[benchmark]` macro implementation) +- `quote` v1 - Rust code generation +- `proc-macro2` v1 - Procedural macro utilities + +## Key Dependencies + +**Critical:** +- `uniffi` v0.28 - FFI binding generation. Without it, mobile apps cannot call Rust code. Breaking changes in UniFFI versions require code updates. +- `inventory` v0.3 - Runtime function registry. The `#[benchmark]` macro uses `inventory::collect!()` to auto-register benchmarks at compile time. +- `reqwest` v0.12 (blocking) - BrowserStack API communication. All device upload, scheduling, and result fetching depends on this. + +**Infrastructure:** +- `dotenvy` v0.15 - Supports `.env.local` for credential management (BrowserStack username/access key) +- `include_dir` v0.7 - Android and iOS app templates embedded in the binary. No runtime file I/O needed. +- `time` v0.3 - Precise timing measurement (nanosecond granularity) and RFC3339 formatting for results + +## Configuration + +**Environment:** +- Credentials resolved in order: + 1. Config file (supports `${ENV_VAR}` expansion) + 2. Environment variables: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `BROWSERSTACK_PROJECT` + 3. `.env.local` file (loaded automatically via `dotenvy`) + 4. Android NDK: `ANDROID_NDK_HOME` environment variable + +**Build:** +- `Cargo.toml` - Workspace manifest at `/Users/dcbuilder/Code/world/mobile-bench-rs/Cargo.toml` +- `bench-config.toml` - User-generated project configuration (via `cargo mobench init`) +- `mobench.toml` - Optional CLI configuration in project root +- Mobile app projects created in `target/mobench/` (default, customizable with `--output-dir`) + +**Features:** +- `mobench-sdk` feature flags: + - `full` (default) - Complete SDK with build automation, templates, and registry + - `runner-only` - Minimal timing harness for mobile binaries (low binary size footprint) + +## Platform Requirements + +**Development:** +- Rust stable toolchain +- `cargo-ndk` (for Android cross-compilation) +- Android SDK (API level 34) +- Android NDK (v26.1.10909125 or compatible) +- For iOS: Xcode toolchain with Swift support + +**Production (BrowserStack):** +- No local platform setup required +- Artifacts uploaded to BrowserStack App Automate for execution on real devices +- Espresso framework for Android test automation +- XCUITest framework for iOS test automation + +--- + +*Stack analysis: 2026-01-21* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 0000000..8dde5e7 --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,280 @@ +# Codebase Structure + +**Analysis Date:** 2026-01-21 + +## Directory Layout + +``` +mobile-bench-rs/ +├── crates/ # Cargo workspace members +│ ├── mobench/ # CLI tool (published to crates.io) +│ │ ├── src/ +│ │ │ ├── main.rs # CLI entry point +│ │ │ ├── lib.rs # Command handlers and orchestration +│ │ │ ├── config.rs # TOML config parsing +│ │ │ ├── browserstack.rs # BrowserStack REST API client +│ │ │ └── bin/cargo-mobench.rs # cargo-mobench subcommand wrapper +│ │ └── Cargo.toml +│ │ +│ ├── mobench-sdk/ # Core SDK library (published) +│ │ ├── src/ +│ │ │ ├── lib.rs # Public API surface (timing + full feature) +│ │ │ ├── timing.rs # Lightweight timing harness (always available) +│ │ │ ├── types.rs # Error types, BuildConfig, InitConfig +│ │ │ ├── registry.rs # Benchmark function discovery (inventory-based) +│ │ │ ├── runner.rs # Benchmark execution engine +│ │ │ ├── codegen.rs # Template generation from embedded files +│ │ │ └── builders/ +│ │ │ ├── mod.rs # Builders module exports +│ │ │ ├── android.rs # AndroidBuilder (cargo-ndk, Gradle) +│ │ │ ├── ios.rs # IosBuilder (xcodebuild, xcframework) +│ │ │ └── common.rs # Shared builder utilities +│ │ ├── templates/ # Embedded mobile app templates +│ │ │ ├── android/ # Android Gradle project scaffold +│ │ │ └── ios/ # iOS Xcode project scaffold +│ │ └── Cargo.toml +│ │ +│ ├── mobench-macros/ # Proc macro crate (published) +│ │ ├── src/lib.rs # #[benchmark] attribute macro +│ │ └── Cargo.toml +│ │ +│ ├── sample-fns/ # Sample functions for testing (not published) +│ │ ├── src/lib.rs # UniFFI FFI types and run_benchmark() +│ │ └── Cargo.toml +│ │ +├── examples/ +│ ├── basic-benchmark/ # Minimal #[benchmark] usage example +│ │ ├── src/lib.rs # Two bench_* functions, tests +│ │ └── Cargo.toml +│ │ +│ └── ffi-benchmark/ # Full UniFFI example (see sample-fns) +│ ├── src/lib.rs +│ └── Cargo.toml +│ +├── android/ # Development Android app (not auto-generated) +│ ├── app/ # App module +│ │ ├── src/main/ +│ │ │ ├── java/dev/world/bench/MainActivity.kt # Entry point +│ │ │ ├── java/uniffi/sample_fns/sample_fns.kt # Generated Kotlin bindings +│ │ │ ├── assets/bench_spec.json # Benchmark parameters +│ │ │ └── jniLibs/{abi}/ # Native .so libraries +│ │ ├── build.gradle # Gradle configuration +│ │ └── src/androidTest/ # Espresso tests for BrowserStack +│ ├── build.gradle +│ ├── settings.gradle +│ └── gradle/wrapper/ +│ +├── ios/ # Development iOS app (not auto-generated) +│ └── BenchRunner/ +│ ├── BenchRunner/ # Xcode project source +│ │ ├── BenchRunnerFFI.swift # FFI wrapper calling UniFFI bindings +│ │ ├── BenchRunner-Bridging-Header.h # Objective-C bridging header +│ │ ├── Generated/ # Auto-generated UniFFI code +│ │ │ ├── sample_fns.swift # Swift bindings from UniFFI +│ │ │ └── sample_fnsFFI.h # C header from UniFFI +│ │ └── ... +│ ├── BenchRunnerUITests/ # XCUITest runner for BrowserStack +│ ├── BenchRunner.xcodeproj/ # Xcode project +│ └── project.yml # XcodeGen specification +│ +├── templates/ # Source templates (symlinked to SDK) +│ ├── android/ # Android project template source +│ └── ios/ # iOS project template source +│ +├── .github/workflows/ +│ └── mobile-bench.yml # CI/CD workflow +│ +├── Cargo.toml # Workspace root +├── Cargo.lock +├── BUILD.md # Build reference +├── TESTING.md # Testing guide +├── CLAUDE.md # This project's guidelines for Claude +└── README.md +``` + +## Directory Purposes + +**`crates/mobench/`** - CLI orchestrator +- Purpose: Entry point for all user operations (build, run, list, fetch, init) +- Contains: Command handlers, BrowserStack API integration, config parsing +- Key files: `src/lib.rs` (commands), `src/main.rs` (entry), `src/browserstack.rs` (API) + +**`crates/mobench-sdk/`** - Core SDK library +- Purpose: Reusable SDK with timing harness, builders, registry, codegen +- Contains: Timing infrastructure, cross-platform builders, function discovery +- Key files: `src/lib.rs` (API), `src/timing.rs` (core), `src/registry.rs` (discovery) +- Sections: + - `src/timing.rs` - Always available, used by mobile binaries with `runner-only` feature + - `src/builders/` - Android/iOS build automation, requires full feature + - `src/registry.rs` - Runtime function discovery via `inventory` crate + - `src/codegen.rs` - Template parameterization and file generation + - `templates/` - Embedded Android and iOS project templates + +**`crates/mobench-macros/`** - Proc macro crate +- Purpose: Compile-time registration of benchmark functions +- Contains: `#[benchmark]` attribute implementation +- Key files: `src/lib.rs` (single file) + +**`crates/sample-fns/`** - Sample benchmark functions +- Purpose: Reference implementation for UniFFI FFI usage +- Contains: `BenchSpec`, `BenchReport`, `run_benchmark()` with FFI types +- Used by: Repository's Android/iOS test apps +- Key distinction: Shows how to define FFI-compatible types with `#[derive(uniffi::Record)]` + +**`examples/`** - Public examples +- Purpose: Demonstrate SDK usage patterns +- `basic-benchmark/` - Minimal SDK usage with `#[benchmark]` +- `ffi-benchmark/` - (Link to sample-fns implementation) + +**`android/`, `ios/`** - Development test apps +- Purpose: Test mobile app integration (not auto-generated) +- Contains: Full Gradle/Xcode projects with BrowserStack integration +- Android: Espresso tests in `src/androidTest/` +- iOS: XCUITest runner in `BenchRunnerUITests/` +- These apps are what `cargo mobench build` scaffolds for users + +**`templates/`** - Template sources +- Purpose: Source files compiled into SDK via `include_dir!` +- Structure mirrors `android/`, `ios/` directory layout + +## Key File Locations + +**Entry Points:** +- `crates/mobench/src/main.rs` - CLI binary entry point +- `crates/mobench/src/lib.rs` - Command orchestration and handlers +- `crates/mobench-sdk/src/lib.rs` - SDK public API surface +- `examples/basic-benchmark/src/lib.rs` - User project example + +**Configuration:** +- `crates/mobench/src/config.rs` - TOML parsing for `bench-config.toml` +- `crates/mobench-sdk/src/types.rs` - `BuildConfig`, `InitConfig` definitions +- `android/app/build.gradle` - Android build configuration +- `ios/BenchRunner/project.yml` - XcodeGen specification + +**Core Logic:** +- `crates/mobench-sdk/src/timing.rs` - Timing harness (nanosecond measurement) +- `crates/mobench-sdk/src/registry.rs` - Benchmark discovery via `inventory` +- `crates/mobench-sdk/src/runner.rs` - Execution engine linking registry to timing +- `crates/mobench-sdk/src/builders/android.rs` - NDK compilation, Gradle build +- `crates/mobench-sdk/src/builders/ios.rs` - Xcode compilation, xcframework creation +- `crates/mobench/src/browserstack.rs` - BrowserStack REST API client + +**Testing:** +- `crates/mobench-sdk/src/lib.rs` - SDK unit tests +- `examples/basic-benchmark/src/lib.rs:66-99` - Integration tests +- `android/app/src/androidTest/` - Espresso tests (BrowserStack) +- `ios/BenchRunner/BenchRunnerUITests/` - XCUITest runner (BrowserStack) + +**Mobile Integration:** +- `android/app/src/main/java/dev/world/bench/MainActivity.kt` - Android entry point +- `android/app/src/main/java/uniffi/sample_fns/sample_fns.kt` - Generated Kotlin bindings +- `ios/BenchRunner/BenchRunnerFFI.swift` - iOS entry point +- `ios/BenchRunner/Generated/sample_fns.swift` - Generated Swift bindings +- `crates/sample-fns/src/lib.rs` - UniFFI types and `run_benchmark()` export + +## Naming Conventions + +**Files:** +- Rust source files: `snake_case.rs` (e.g., `timing.rs`, `android.rs`, `main.rs`) +- Module files: `mod.rs` for re-exports, file per implementation +- Macro crates: Single file `src/lib.rs` (e.g., `mobench-macros/src/lib.rs`) +- Test files: Co-located with source as `#[cfg(test)]` mod at bottom + +**Directories:** +- Crate directories: kebab-case (e.g., `mobench-sdk`, `mobench-macros`) +- Module directories: snake_case (e.g., `builders/`, `templates/`) +- Platform packages: lowercase (e.g., `android/`, `ios/`) + +**Types:** +- Public types: PascalCase (e.g., `BenchSpec`, `BenchFunction`, `AndroidBuilder`) +- Error types: PascalCase variant names (e.g., `UnknownFunction`, `BuildError`) +- Enum variants: PascalCase (e.g., `Target::Android`, `BuildProfile::Release`) + +**Functions:** +- Public functions: snake_case (e.g., `run_benchmark()`, `discover_benchmarks()`) +- Attribute macros: snake_case (e.g., `#[benchmark]`) +- Builder methods: snake_case (e.g., `.verbose()`, `.output_dir()`) + +**Variables:** +- Local variables: snake_case (e.g., `builder`, `spec`, `output_dir`) +- Constants: SCREAMING_SNAKE_CASE (e.g., `CHECKSUM_INPUT`, `ANDROID_TEMPLATES`) +- Mutable state: Clear naming with `mut` keyword visible + +## Where to Add New Code + +**New Feature (e.g., new benchmarking strategy):** +- Primary code: Add to `crates/mobench-sdk/src/` as a new module +- Example: `crates/mobench-sdk/src/memory_profile.rs` for memory benchmarking +- Tests: `#[cfg(test)] mod tests { }` at bottom of module +- Public API: Re-export in `crates/mobench-sdk/src/lib.rs` + +**New Builder or Platform Support:** +- Implementation: `crates/mobench-sdk/src/builders/{platform}.rs` +- Example: Adding WASM support → `crates/mobench-sdk/src/builders/wasm.rs` +- Module registration: Add to `crates/mobench-sdk/src/builders/mod.rs` with `pub mod wasm;` +- Common utilities: Extend `crates/mobench-sdk/src/builders/common.rs` + +**New CLI Command:** +- Handler: Add variant to `Command` enum in `crates/mobench/src/lib.rs:150+` +- Implementation: Add to match statement in `run()` function +- Example: `Command::Analyze { ... }` → handler function for comparison logic + +**Utilities and Helpers:** +- Shared across SDK modules: `crates/mobench-sdk/src/builders/common.rs` +- Shared across CLI: Add to `crates/mobench/src/lib.rs` or new module +- Shared across crates: Consider extracting to new shared crate + +**Test Examples:** +- User-facing examples: `examples/` directory with `Cargo.toml` and `src/lib.rs` +- Internal integration tests: `crates/*/tests/` (create if needed) or co-located in source +- Unit tests: `#[cfg(test)] mod tests { }` at bottom of source files + +## Special Directories + +**`crates/mobench-sdk/templates/`:** +- Purpose: Source files embedded via `include_dir!` at compile time +- Generated: No, committed to git +- Committed: Yes, both `android/` and `ios/` templates +- Structure: Mirrors actual Android/iOS projects with placeholder variables +- Variables replaced during generation: `${PROJECT_NAME}`, `${PROJECT_SLUG}`, `${BUNDLE_PREFIX}` + +**`target/mobench/`:** +- Purpose: Output directory for all build artifacts +- Generated: Yes, created by builders during `build` and `run` commands +- Committed: No, in `.gitignore` +- Contents: + - `android/` - Generated Android project + APK + - `ios/` - Generated iOS project + xcframework + IPA + +**`.github/workflows/`:** +- Purpose: CI/CD pipeline definition +- File: `mobile-bench.yml` - Build and test automation + +**`android/app/src/androidTest/`:** +- Purpose: Espresso test suite for BrowserStack execution +- Contains: JUnit tests using Espresso framework +- Execution: Runs as test APK on BrowserStack Espresso + +**`ios/BenchRunner/BenchRunnerUITests/`:** +- Purpose: XCUITest runner for BrowserStack iOS +- Contains: Swift XCUITest classes +- Execution: Runs as XCUITest bundle on BrowserStack + +## Import and Module Organization + +**Workspace structure:** +- Root `Cargo.toml` defines members: `crates/mobench`, `crates/mobench-sdk`, `crates/mobench-macros`, `crates/sample-fns`, `examples/basic-benchmark`, `examples/ffi-benchmark` +- Workspace dependencies defined in `[workspace.dependencies]` + +**Public API exports:** +- SDK: `crates/mobench-sdk/src/lib.rs` re-exports key types at crate root +- CLI: `crates/mobench/src/lib.rs` private, main CLI logic in `main.rs` +- Macros: `crates/mobench-macros/src/lib.rs` exports `#[benchmark]` macro + +**Feature gating:** +- `full` (default): Builders, codegen, registry, runner +- `runner-only`: Timing module only, minimal mobile binary + +--- + +*Structure analysis: 2026-01-21* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 0000000..a9c6bf3 --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,278 @@ +# Testing Patterns + +**Analysis Date:** 2026-01-21 + +## Test Framework + +**Runner:** +- Cargo built-in test runner (no external test framework) +- Run all tests: `cargo test --all` +- Run specific crate: `cargo test -p mobench-sdk` +- Watch mode: Not configured (no test-watching framework) +- Coverage: Not configured (no coverage tool integration) + +**Assertion Library:** +- Standard Rust `assert!()`, `assert_eq!()`, `assert_ne!()` +- Pattern matching: `assert!(matches!(result, Err(TimingError::NoIterations)))` + +**Run Commands:** +```bash +# Run all tests in workspace +cargo test --all + +# Run tests for specific crate +cargo test -p mobench-sdk +cargo test -p mobench + +# Run with output captured +cargo test -- --nocapture + +# List all tests without running +cargo test --all -- --list +``` + +## Test File Organization + +**Location:** +- Co-located with source code, not in separate `tests/` directory +- Test modules at end of each source file +- Conditional compilation: `#[cfg(test)]` wrapping test modules + +**Naming:** +- Test function prefix: `test_` or descriptive name +- Crate test modules: Named `tests` consistently +- Test utilities: Defined within test module using helper functions + +**Structure:** + +``` +src/ +├── timing.rs # Source + inline #[cfg(test)] mod tests +├── registry.rs # Source + inline #[cfg(test)] mod tests +├── builders/ +│ ├── android.rs # Source + inline #[cfg(test)] mod tests +│ └── ios.rs # Source + inline #[cfg(test)] mod tests +``` + +## Test Structure + +**Suite Organization:** + +From `crates/mobench-sdk/src/timing.rs`: +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runs_benchmark() { + let spec = BenchSpec::new("noop", 3, 1).unwrap(); + let report = run_closure(spec, || Ok(())).unwrap(); + + assert_eq!(report.samples.len(), 3); + let non_zero = report.samples.iter().filter(|s| s.duration_ns > 0).count(); + assert!(non_zero >= 1); + } + + #[test] + fn rejects_zero_iterations() { + let result = BenchSpec::new("test", 0, 10); + assert!(matches!(result, Err(TimingError::NoIterations))); + } +} +``` + +**Patterns:** +- `use super::*;` to import all items from parent module +- Setup: Direct instantiation in each test (no shared fixtures) +- Execution: Call the function under test +- Assertion: Use `assert!()`, `assert_eq!()`, or pattern matching +- No teardown required (Rust handles memory cleanup) + +## Mocking + +**Framework:** No external mocking library used + +**Patterns:** +- Manual test doubles and stubs +- Trait-based design for dependency injection in production code +- Example from `crates/mobench-sdk/src/builders/android.rs` - builder pattern with customizable output: +```rust +#[test] +fn test_android_builder_custom_output_dir() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile") + .output_dir("/custom/output"); + assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); +} +``` + +**What to Mock:** +- Not used in this codebase; tests use concrete types with customizable configuration +- Builder pattern preferred over dependency injection for tests + +**What NOT to Mock:** +- Core timing functionality (tested with actual measurements) +- Type conversion logic (uses direct instantiation) +- Registry operations (tested with actual inventory collection) + +## Fixtures and Factories + +**Test Data:** +No factory pattern or fixture framework found. Tests create minimal setup inline: + +```rust +#[test] +fn test_parse_output_metadata_unsigned() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + let metadata = r#"{"version":3,"artifactType":{"type":"APK",...}}"#; + let result = builder.parse_output_metadata(metadata); + assert_eq!(result, Some("app-release-unsigned.apk".to_string())); +} +``` + +**Location:** +- Test-specific data defined inline or as constants in test module +- Constants: `CHECKSUM_INPUT` in `crates/sample-fns/src/lib.rs` + +**Pattern:** +```rust +const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_checksum() { + // Use CHECKSUM_INPUT directly + } +} +``` + +## Coverage + +**Requirements:** Not enforced + +**View Coverage:** No coverage command configured + +**Observed Coverage:** +- `timing` module: 7 unit tests covering core functionality +- `registry` module: 3 tests covering discovery and lookup +- `builders` (android): 6+ tests covering builder construction and metadata parsing +- `builders` (ios): Tests present but not extensively reviewed + +## Test Types + +**Unit Tests:** +- Scope: Individual functions and methods +- Approach: Direct instantiation, no external dependencies +- Examples: + - `test_rejects_zero_iterations()` - validates error handling + - `test_allows_zero_warmup()` - boundary condition + - `test_serializes_to_json()` - serialization correctness + +**Integration Tests:** +- Scope: Multiple components working together +- Approach: Full builder workflow with real file operations +- Examples: + - Builder type construction and method chaining + - Metadata parsing from JSON + - Benchmark specification validation + +**E2E Tests:** +- Framework: None (not applicable; this is a library) +- Device testing: Handled by mobile app frameworks (XCUITest, Espresso) +- Host testing: No end-to-end test suite observed + +## Common Patterns + +**Async Testing:** +No async tests found (Rust blocking I/O used throughout) + +**Error Testing:** + +```rust +#[test] +fn rejects_zero_iterations() { + let result = BenchSpec::new("test", 0, 10); + assert!(matches!(result, Err(TimingError::NoIterations))); +} +``` + +Pattern: Use `matches!()` for pattern matching on Result/Option types + +**Builder Method Testing:** + +```rust +#[test] +fn test_android_builder_verbose() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile") + .verbose(true); + assert!(builder.verbose); +} +``` + +**Successful Operation Testing:** + +```rust +#[test] +fn runs_benchmark() { + let spec = BenchSpec::new("noop", 3, 1).unwrap(); + let report = run_closure(spec, || Ok(())).unwrap(); + assert_eq!(report.samples.len(), 3); +} +``` + +**JSON Serialization Testing:** + +```rust +#[test] +fn serializes_to_json() { + let spec = BenchSpec::new("test", 10, 2).unwrap(); + let report = run_closure(spec, || Ok(())).unwrap(); + + let json = serde_json::to_string(&report).unwrap(); + let restored: BenchReport = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.spec.name, "test"); + assert_eq!(restored.samples.len(), 10); +} +``` + +**File Path Testing:** + +```rust +#[test] +fn test_android_builder_creation() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + assert_eq!( + builder.output_dir, + PathBuf::from("/tmp/test-project/target/mobench") + ); +} +``` + +## Test Characteristics + +**Independence:** +- Each test creates its own test data and configuration +- No shared state between tests +- Tests can run in any order + +**Determinism:** +- All tests are deterministic (no randomness) +- Timing tests accept variance (check `non_zero >= 1`, not exact values) + +**Readability:** +- Clear test names describing what is tested +- Simple, direct assertion patterns +- No test setup boilerplate + +**Performance:** +- Tests run quickly (unit tests < 100ms each) +- No external network calls +- No file I/O except in builder tests (using /tmp paths) + +--- + +*Testing analysis: 2026-01-21* diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index a7503d7..236dda9 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -100,7 +100,7 @@ Install the following tools (per platform): - Note: Android Gradle Plugin (AGP) officially supports Java 17. - iOS (macOS only): - Xcode + Command Line Tools: https://developer.apple.com/xcode/ - - Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim` + - Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios` - https://doc.rust-lang.org/rustup/targets.html - `xcodegen` (optional): https://github.com/yonaskolb/XcodeGen @@ -357,6 +357,57 @@ pub fn db_insert(conn: &DbConnection) { } ``` +## Using the FFI Module + +For UniFFI integration, mobench-sdk provides a unified `ffi` module with ready-to-use types: + +```rust +use mobench_sdk::ffi::{ + BenchSpecFfi, BenchSampleFfi, BenchReportFfi, BenchErrorFfi, + run_benchmark_ffi, +}; + +// These types mirror the SDK types but are designed for FFI +// You can use them directly or as templates for your UniFFI Record types +``` + +### Option 1: Use FFI Types Directly + +If you don't need custom UniFFI annotations, you can use the FFI types directly: + +```rust +use mobench_sdk::ffi::run_benchmark_ffi; + +fn run_bench(name: &str) { + let spec = mobench_sdk::ffi::BenchSpecFfi { + name: name.to_string(), + iterations: 100, + warmup: 10, + }; + let result = run_benchmark_ffi(spec); +} +``` + +### Option 2: Define Your Own UniFFI Types (Recommended) + +For full UniFFI support with generated Kotlin/Swift bindings: + +```rust +use uniffi; + +#[derive(uniffi::Record)] +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} + +// Implement From for mobench_sdk::BenchSpec +// Then call mobench_sdk::run_benchmark(spec.into()) +``` + +See `examples/ffi-benchmark` for a complete working example with UniFFI annotations. + ## 4) Scaffold mobile projects From your repo root, create a mobile harness with the CLI: diff --git a/BROWSERSTACK_CI_INTEGRATION.md b/BROWSERSTACK_CI_INTEGRATION.md index ea48a4c..46f09c3 100644 --- a/BROWSERSTACK_CI_INTEGRATION.md +++ b/BROWSERSTACK_CI_INTEGRATION.md @@ -25,6 +25,12 @@ cargo mobench check --target android # Verify iOS build tools are installed cargo mobench check --target ios +# Validate CI prerequisites + config in one shot +cargo mobench doctor --target both --config bench-config.toml --device-matrix device-matrix.yaml + +# Validate run config contract directly +cargo mobench config validate --config bench-config.toml + # Output as JSON for CI parsing cargo mobench check --target android --format json ``` @@ -48,6 +54,9 @@ cargo mobench devices --platform ios # Output as JSON cargo mobench devices --platform android --json + +# Resolve matrix profile deterministically (CI-friendly) +cargo mobench devices resolve --platform android --profile default --device-matrix device-matrix.yaml ``` Invalid device specs return helpful suggestions: @@ -67,6 +76,9 @@ cargo mobench verify --target android --check-artifacts # Include smoke test cargo mobench verify --target android --smoke-test --function my_benchmark + +# Render markdown summary from standardized CI output +cargo mobench report summarize --summary target/mobench/ci/summary.json ``` ## Quick Example @@ -124,7 +136,8 @@ cargo mobench run \ --release \ --fetch \ --fetch-timeout-secs 600 \ - --output results.json + --ci \ + --output target/mobench/results.json ``` **Note**: Always use the `--release` flag for BrowserStack runs. Debug builds are significantly larger (~544MB vs ~133MB for release) and may cause upload timeouts. diff --git a/BUILD.md b/BUILD.md index 13769e5..7d772d0 100644 --- a/BUILD.md +++ b/BUILD.md @@ -106,7 +106,7 @@ brew install xcodegen ## https://github.com/yonaskolb/XcodeGen # Install required Rust targets -rustup target add aarch64-apple-ios aarch64-apple-ios-sim +rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios ## https://doc.rust-lang.org/rustup/targets.html # Verify installation @@ -237,7 +237,8 @@ cargo mobench build --target ios This build step: 1. Compiles Rust for iOS targets: - `aarch64-apple-ios` (physical devices) - - `aarch64-apple-ios-sim` (M1+ Mac simulators) + - `aarch64-apple-ios-sim` (Apple Silicon simulators) + - `x86_64-apple-ios` (Intel simulators) 2. Creates xcframework with structure: ``` diff --git a/CLAUDE.md b/CLAUDE.md index ca90366..5887562 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.13):** +**Published on crates.io as the mobench ecosystem (v0.1.14):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation @@ -234,9 +234,49 @@ cargo mobench run \ --function sample_fns::fibonacci \ --iterations 100 \ --warmup 10 \ - --output run-summary.json + --output target/mobench/results.json ``` +#### Single-Command BrowserStack Flow + +The `run` command provides a complete single-command workflow for benchmarking on real devices: + +```bash +# Android: Single command does everything +cargo mobench run \ + --target android \ + --function sample_fns::fibonacci \ + --iterations 100 \ + --warmup 10 \ + --devices "Google Pixel 7-13.0" \ + --release \ + --output target/mobench/results.json + +# iOS: Single command also works (auto-packages IPA + XCUITest) +cargo mobench run \ + --target ios \ + --function sample_fns::fibonacci \ + --iterations 100 \ + --warmup 10 \ + --devices "iPhone 14-16" \ + --release \ + --output target/mobench/results.json +``` + +**What happens automatically:** +1. Builds Rust native libraries for all required ABIs +2. Generates UniFFI bindings (Kotlin/Swift) +3. Packages mobile app (APK for Android, IPA for iOS) +4. Packages test runner (androidTest APK or XCUITest zip) +5. Uploads artifacts to BrowserStack +6. Schedules and monitors the test run +7. Fetches and displays results + +**No need to manually call:** +- `cargo mobench build` (done automatically) +- `cargo mobench package-ipa` (done automatically for iOS) +- `cargo mobench package-xcuitest` (done automatically for iOS) + #### BrowserStack Run (Android) ```bash @@ -252,7 +292,7 @@ cargo mobench run \ --warmup 5 \ --devices "Google Pixel 7-13.0" \ --release \ - --output run-summary.json + --output target/mobench/results.json ``` **Note on `--release` flag**: Debug builds can be very large (~544MB) which may cause BrowserStack upload timeouts. The `--release` flag builds in release mode, reducing APK size significantly (~133MB), and is recommended for all BrowserStack runs. @@ -274,9 +314,28 @@ cargo mobench run \ --release \ --ios-app target/mobench/ios/BenchRunner.ipa \ --ios-test-suite target/mobench/ios/BenchRunnerUITests.zip \ - --output run-summary.json + --output target/mobench/results.json +``` + +#### Automatic iOS Packaging + +When running iOS benchmarks on BrowserStack, mobench automatically packages the IPA and XCUITest runner if you don't provide `--ios-app` and `--ios-test-suite` flags: + +```bash +# This auto-packages iOS artifacts: +cargo mobench run --target ios --function my_fn --devices "iPhone 14-16" --release + +# Equivalent to manually running: +cargo mobench build --target ios --release +cargo mobench package-ipa --method adhoc +cargo mobench package-xcuitest +cargo mobench run --target ios --function my_fn --devices "iPhone 14-16" \ + --ios-app target/mobench/ios/BenchRunner.ipa \ + --ios-test-suite target/mobench/ios/BenchRunnerUITests.zip ``` +You can override auto-packaging by providing both `--ios-app` and `--ios-test-suite` together. + #### Using Config Files ```bash @@ -574,7 +633,7 @@ cargo mobench run --target android --function my_function ### Target Architectures - **Android**: `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` (emulator) -- **iOS**: `aarch64-apple-ios` (device), `aarch64-apple-ios-sim` (simulator on M1+ Macs) +- **iOS**: `aarch64-apple-ios` (device), `aarch64-apple-ios-sim` (Apple Silicon simulators), `x86_64-apple-ios` (Intel simulators) ### XCFramework Structure diff --git a/Cargo.lock b/Cargo.lock index 8318d66..1c7b342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,29 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.21" @@ -96,7 +119,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -143,18 +166,48 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.11.0" @@ -261,6 +314,25 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.5.5" @@ -270,6 +342,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -303,6 +385,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -336,6 +429,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f158e3ff0a1b334408dc9fb811cd99b446986f4d8b741bb08f9df1604085ae7" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs-err" version = "2.11.0" @@ -395,6 +498,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -714,6 +827,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom 8.0.0", +] + [[package]] name = "itoa" version = "1.0.15" @@ -730,6 +852,42 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0f4bea31643be4c6a678e9aa4ae44f0db9e5609d5ca9dc9083d06eb3e9a27a" +dependencies = [ + "ahash", + "anyhow", + "base64", + "bytecount", + "clap", + "fancy-regex", + "fraction", + "getrandom 0.2.16", + "iso8601", + "itoa", + "memchr", + "num-cmp", + "once_cell", + "parking_lot", + "percent-encoding", + "regex", + "reqwest", + "serde", + "serde_json", + "time", + "url", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.178" @@ -748,6 +906,15 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -801,18 +968,20 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.13" +version = "0.1.14" dependencies = [ "anyhow", "clap", "dotenvy", "inventory", + "jsonschema", "mobench-sdk", "reqwest", "sample-fns", "serde", "serde_json", "serde_yaml", + "sha2", "tempfile", "time", "toml 0.8.23", @@ -820,7 +989,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.13" +version = "0.1.14" dependencies = [ "proc-macro2", "quote", @@ -829,7 +998,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.13" +version = "0.1.14" dependencies = [ "anyhow", "include_dir", @@ -851,12 +1020,100 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -869,6 +1126,29 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" @@ -1031,6 +1311,44 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + [[package]] name = "reqwest" version = "0.12.26" @@ -1154,7 +1472,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.13" +version = "0.1.14" dependencies = [ "camino", "mobench-sdk", @@ -1165,6 +1483,12 @@ dependencies = [ "uniffi_bindgen", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scroll" version = "0.12.0" @@ -1272,6 +1596,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1629,6 +1964,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicase" version = "2.8.1" @@ -1807,6 +2148,22 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1924,7 +2281,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ec173f6..db2ae9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.13" +version = "0.1.14" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 111b722..b414533 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Mobile benchmarking SDK for Rust. Build and run Rust benchmarks on Android and i mobench provides a Rust API and a CLI for running benchmarks on real mobile devices. You define benchmarks in Rust, generate mobile bindings automatically, and drive execution from the CLI with consistent output formats (JSON, Markdown, CSV). +For programmatic CI integrations, `mobench` exposes typed request/result types (`RunRequest`, `RunResult`, `DeviceSelection`, `Report`) via the crate API. + ## How mobench works - `#[benchmark]` marks functions and registers them via `inventory` @@ -27,13 +29,18 @@ mobench provides a Rust API and a CLI for running benchmarks on real mobile devi ## Quick start ```bash -# Install the CLI +# Install the CLI (fast) +cargo binstall mobench + +# Or build from source cargo install mobench # Add the SDK to your project cargo add mobench-sdk inventory # Check prerequisites before building +cargo mobench doctor --target both +cargo mobench config validate --config bench-config.toml cargo mobench check --target android cargo mobench check --target ios @@ -54,10 +61,30 @@ cargo mobench run --target android --function sample_fns::fibonacci \ # List available BrowserStack devices cargo mobench devices --platform android +# Resolve matrix devices deterministically for CI +cargo mobench devices resolve --platform android --profile default --device-matrix device-matrix.yaml + +# Fixture lifecycle helpers +cargo mobench fixture init +cargo mobench fixture verify +cargo mobench fixture cache-key + # View benchmark results summary -cargo mobench summary results.json +cargo mobench summary target/mobench/results.json + +# CI one-command orchestration with stable outputs +cargo mobench ci run --target android --function sample_fns::fibonacci --local-only + +# Reporting helpers from standardized outputs +cargo mobench report summarize --summary target/mobench/ci/summary.json +cargo mobench report github --pr 123 --summary target/mobench/ci/summary.json ``` +CI contract outputs are written to `target/mobench/ci/`: +- `summary.json` +- `summary.md` +- `results.csv` + ## Configuration mobench supports a `mobench.toml` configuration file for project settings: @@ -82,6 +109,8 @@ default_warmup = 10 ``` CLI flags override config file values when provided. +- In `cargo mobench run --config ` mode, `--device-matrix ` overrides `device_matrix` from the config file. +- For regression comparisons, `--baseline` should point to a previous run summary; if it resolves to the same output path, mobench snapshots the prior file before writing the candidate summary. ## Project docs @@ -89,6 +118,10 @@ CLI flags override config file values when provided. - `BUILD.md`: build prerequisites and troubleshooting - `TESTING.md`: testing guide and device workflows - `BROWSERSTACK_CI_INTEGRATION.md`: BrowserStack CI setup +- `docs/CONTRACT_CI_V1.md`: frozen v1 CI input/output/error contract +- `docs/adr/0001-mobench-ci-contract-v1.md`: CI contract ADR and compatibility policy +- `docs/schemas/`: machine-readable CI/summary schema artifacts +- `docs/MIGRATION_GUIDE.md`: migration guide (placeholder, linked from ADR) - `FETCH_RESULTS_GUIDE.md`: fetching and summarizing results - `PROJECT_PLAN.md`: goals and backlog - `CLAUDE.md`: developer guide @@ -166,6 +199,19 @@ fn db_query(db: &Database) { ## Release Notes +### v0.1.14 + +- Added CI contract-oriented commands and workflows: + - `cargo mobench ci run` + - `cargo mobench config validate` + - `cargo mobench devices resolve` + - `cargo mobench fixture init|build|verify|cache-key` + - `cargo mobench report summarize|github` +- Standardized CI outputs under `target/mobench/ci/` with schema-backed metadata. +- Added baseline comparison source support (`path|url|artifact:`) and regression labels. +- Improved local action safety for workflow input handling and sticky PR comment publishing. +- Fixed iOS CI target setup (`x86_64-apple-ios`) and preserved CI outputs on regression exit. + ### v0.1.13 - **Setup and teardown support**: `#[benchmark]` macro now supports `setup`, `teardown`, and `per_iteration` attributes for excluding expensive initialization from timing measurements diff --git a/TESTING.md b/TESTING.md index 6bff0f3..5e269ef 100644 --- a/TESTING.md +++ b/TESTING.md @@ -39,7 +39,7 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # Install required targets rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android -rustup target add aarch64-apple-ios aarch64-apple-ios-sim +rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios # https://doc.rust-lang.org/rustup/targets.html # Install cargo-ndk for Android builds @@ -214,7 +214,7 @@ cargo mobench build --target ios --progress cargo mobench verify --target ios --check-artifacts # This build step: -# - Compiles Rust for aarch64-apple-ios (device) and aarch64-apple-ios-sim (simulator) +# - Compiles Rust for aarch64-apple-ios (device), aarch64-apple-ios-sim (Apple Silicon simulators), and x86_64-apple-ios (Intel simulators) # - Creates xcframework with proper structure: # target/mobench/ios/sample_fns.xcframework/ # ├── Info.plist diff --git a/crates/mobench-macros/Cargo.toml b/crates/mobench-macros/Cargo.toml index 945b0e8..94b073a 100644 --- a/crates/mobench-macros/Cargo.toml +++ b/crates/mobench-macros/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true authors = ["Dominik Clemente - dcbuilder.eth "] -description = "Proc macros for mobench-sdk - #[benchmark] attribute" +description = "Procedural macros for mobench benchmarks with setup, teardown, and per-iteration support" repository = "https://github.com/worldcoin/mobile-bench-rs" documentation = "https://docs.rs/mobench-macros" readme = "README.md" diff --git a/crates/mobench-macros/src/lib.rs b/crates/mobench-macros/src/lib.rs index 1278c9b..198d923 100644 --- a/crates/mobench-macros/src/lib.rs +++ b/crates/mobench-macros/src/lib.rs @@ -71,10 +71,10 @@ use proc_macro::TokenStream; use quote::quote; use syn::{ + Ident, ItemFn, ReturnType, Token, parse::{Parse, ParseStream}, parse_macro_input, punctuated::Punctuated, - Ident, ItemFn, ReturnType, Token, }; /// Arguments to the benchmark attribute diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 0399d16..296c051 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true authors = ["Dominik Clemente - dcbuilder.eth "] -description = "Mobile benchmarking SDK for Rust - run benchmarks on real devices" +description = "Rust SDK for mobile benchmarking with timing harness and Android/iOS builders" repository = "https://github.com/worldcoin/mobile-bench-rs" documentation = "https://docs.rs/mobench-sdk" readme = "README.md" @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.13", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.14", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index a25acef..f2231fb 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -430,7 +430,7 @@ devices: ### For iOS - macOS with Xcode -- Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim` +- Rust targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios` - `xcodegen`: `brew install xcodegen` ## Part of mobench diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index d5c4a69..eea03bb 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -55,8 +55,8 @@ //! builder.build(&config)?; //! ``` -use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root}; +use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -186,26 +186,69 @@ impl AndroidBuilder { if self.dry_run { println!("\n[dry-run] Android build plan:"); - println!(" Step 0: Check/generate Android project scaffolding at {:?}", android_dir); + println!( + " Step 0: Check/generate Android project scaffolding at {:?}", + android_dir + ); println!(" Step 0.5: Ensure Gradle wrapper exists (run 'gradle wrapper' if needed)"); - println!(" Step 1: Build Rust libraries for Android ABIs (arm64-v8a, armeabi-v7a, x86_64)"); - println!(" Command: cargo ndk --target --platform 24 build {}", - if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); + println!( + " Step 1: Build Rust libraries for Android ABIs (arm64-v8a, armeabi-v7a, x86_64)" + ); + println!( + " Command: cargo ndk --target --platform 24 build {}", + if matches!(config.profile, BuildProfile::Release) { + "--release" + } else { + "" + } + ); println!(" Step 2: Generate UniFFI Kotlin bindings"); - println!(" Output: {:?}", android_dir.join("app/src/main/java/uniffi")); + println!( + " Output: {:?}", + android_dir.join("app/src/main/java/uniffi") + ); println!(" Step 3: Copy .so files to jniLibs directories"); - println!(" Destination: {:?}", android_dir.join("app/src/main/jniLibs")); + println!( + " Destination: {:?}", + android_dir.join("app/src/main/jniLibs") + ); println!(" Step 4: Build Android APK with Gradle"); - println!(" Command: ./gradlew assemble{}", if profile_name == "release" { "Release" } else { "Debug" }); - println!(" Output: {:?}", android_dir.join(format!("app/build/outputs/apk/{}/app-{}.apk", profile_name, profile_name))); + println!( + " Command: ./gradlew assemble{}", + if profile_name == "release" { + "Release" + } else { + "Debug" + } + ); + println!( + " Output: {:?}", + android_dir.join(format!( + "app/build/outputs/apk/{}/app-{}.apk", + profile_name, profile_name + )) + ); println!(" Step 5: Build Android test APK"); - println!(" Command: ./gradlew assemble{}AndroidTest", if profile_name == "release" { "Release" } else { "Debug" }); + println!( + " Command: ./gradlew assemble{}AndroidTest", + if profile_name == "release" { + "Release" + } else { + "Debug" + } + ); // Return a placeholder result for dry-run return Ok(BuildResult { platform: Target::Android, - app_path: android_dir.join(format!("app/build/outputs/apk/{}/app-{}.apk", profile_name, profile_name)), - test_suite_path: Some(android_dir.join(format!("app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk", profile_name, profile_name))), + app_path: android_dir.join(format!( + "app/build/outputs/apk/{}/app-{}.apk", + profile_name, profile_name + )), + test_suite_path: Some(android_dir.join(format!( + "app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk", + profile_name, profile_name + ))), }); } @@ -253,7 +296,11 @@ impl AndroidBuilder { } /// Validates that all expected build artifacts exist after a successful build - fn validate_build_artifacts(&self, result: &BuildResult, config: &BuildConfig) -> Result<(), BenchError> { + fn validate_build_artifacts( + &self, + result: &BuildResult, + config: &BuildConfig, + ) -> Result<(), BenchError> { let mut missing = Vec::new(); let profile_dir = match config.profile { BuildProfile::Debug => "debug", @@ -282,7 +329,12 @@ impl AndroidBuilder { if lib_path.exists() { found_libs += 1; } else { - missing.push(format!("Native library ({} {}): {}", abi, profile_dir, lib_path.display())); + missing.push(format!( + "Native library ({} {}): {}", + abi, + profile_dir, + lib_path.display() + )); } } @@ -292,7 +344,11 @@ impl AndroidBuilder { Expected at least one .so file in jniLibs directories.\n\ Missing artifacts:\n{}\n\n\ This usually means the Rust build step failed. Check the cargo-ndk output above.", - missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + missing + .iter() + .map(|s| format!(" - {}", s)) + .collect::>() + .join("\n") ))); } @@ -300,7 +356,11 @@ impl AndroidBuilder { eprintln!( "Warning: Some build artifacts are missing:\n{}\n\ The build may still work but some features might be unavailable.", - missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + missing + .iter() + .map(|s| format!(" - {}", s)) + .collect::>() + .join("\n") ); } @@ -426,11 +486,13 @@ impl AndroidBuilder { let command_hint = if release_flag.is_empty() { format!("cargo ndk --target {} --platform 24 build", abi) } else { - format!("cargo ndk --target {} --platform 24 build {}", abi, release_flag) + format!( + "cargo ndk --target {} --platform 24 build {}", + abi, release_flag + ) }; - let output = cmd - .output() - .map_err(|e| BenchError::Build(format!( + let output = cmd.output().map_err(|e| { + BenchError::Build(format!( "Failed to start cargo-ndk for {}.\n\n\ Command: {}\n\ Crate directory: {}\n\ @@ -442,7 +504,8 @@ impl AndroidBuilder { command_hint, crate_dir.display(), e - )))?; + )) + })?; if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); @@ -546,7 +609,14 @@ impl AndroidBuilder { // Try cargo run first (works if crate has uniffi-bindgen binary target) let cargo_run_result = Command::new("cargo") - .args(["run", "-p", &self.crate_name, "--bin", "uniffi-bindgen", "--"]) + .args([ + "run", + "-p", + &self.crate_name, + "--bin", + "uniffi-bindgen", + "--", + ]) .arg("generate") .arg("--library") .arg(&lib_path) @@ -715,15 +785,22 @@ impl AndroidBuilder { })?; if self.verbose { - println!(" Generated local.properties with sdk.dir={}", path.display()); + println!( + " Generated local.properties with sdk.dir={}", + path.display() + ); } } None => { // No env var set - skip generating local.properties // Gradle/Android Studio will auto-detect the SDK or prompt the user if self.verbose { - println!(" Skipping local.properties generation (ANDROID_HOME/ANDROID_SDK_ROOT not set)"); - println!(" Gradle will auto-detect SDK or you can create local.properties manually"); + println!( + " Skipping local.properties generation (ANDROID_HOME/ANDROID_SDK_ROOT not set)" + ); + println!( + " Gradle will auto-detect SDK or you can create local.properties manually" + ); } } } @@ -871,9 +948,8 @@ impl AndroidBuilder { cmd.arg("--info"); } - let output = cmd - .output() - .map_err(|e| BenchError::Build(format!( + let output = cmd.output().map_err(|e| { + BenchError::Build(format!( "Failed to run Gradle wrapper.\n\n\ Command: ./gradlew {}\n\ Working directory: {}\n\ @@ -884,7 +960,8 @@ impl AndroidBuilder { gradle_task, android_dir.display(), e - )))?; + )) + })?; if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); @@ -935,7 +1012,12 @@ impl AndroidBuilder { /// /// This method also checks for `output-metadata.json` which contains the actual /// output filename when present. - fn find_apk(&self, apk_dir: &Path, profile_name: &str, gradle_task: &str) -> Result { + fn find_apk( + &self, + apk_dir: &Path, + profile_name: &str, + gradle_task: &str, + ) -> Result { // First, try to read output-metadata.json for the actual APK name let metadata_path = apk_dir.join("output-metadata.json"); if metadata_path.exists() { @@ -946,7 +1028,10 @@ impl AndroidBuilder { let apk_path = apk_dir.join(&apk_name); if apk_path.exists() { if self.verbose { - println!(" Found APK from output-metadata.json: {}", apk_path.display()); + println!( + " Found APK from output-metadata.json: {}", + apk_path.display() + ); } return Ok(apk_path); } @@ -957,12 +1042,12 @@ impl AndroidBuilder { // Define candidates in order of preference let candidates = if profile_name == "release" { vec![ - format!("app-{}.apk", profile_name), // Signed release - format!("app-{}-unsigned.apk", profile_name), // Unsigned release + format!("app-{}.apk", profile_name), // Signed release + format!("app-{}-unsigned.apk", profile_name), // Unsigned release ] } else { vec![ - format!("app-{}.apk", profile_name), // Debug + format!("app-{}.apk", profile_name), // Debug ] }; @@ -985,7 +1070,11 @@ impl AndroidBuilder { Check the build output directory and rerun ./gradlew {} if needed.", apk_dir.display(), gradle_task, - candidates.iter().map(|c| format!(" - {}", c)).collect::>().join("\n"), + candidates + .iter() + .map(|c| format!(" - {}", c)) + .collect::>() + .join("\n"), gradle_task ))) } @@ -1051,9 +1140,8 @@ impl AndroidBuilder { cmd.arg("--info"); } - let output = cmd - .output() - .map_err(|e| BenchError::Build(format!( + let output = cmd.output().map_err(|e| { + BenchError::Build(format!( "Failed to run Gradle wrapper.\n\n\ Command: ./gradlew {}\n\ Working directory: {}\n\ @@ -1064,7 +1152,8 @@ impl AndroidBuilder { gradle_task, android_dir.display(), e - )))?; + )) + })?; if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); @@ -1108,7 +1197,12 @@ impl AndroidBuilder { /// Test APKs can have different naming patterns depending on the build: /// - `app-debug-androidTest.apk` /// - `app-release-androidTest.apk` - fn find_test_apk(&self, apk_dir: &Path, profile_name: &str, gradle_task: &str) -> Result { + fn find_test_apk( + &self, + apk_dir: &Path, + profile_name: &str, + gradle_task: &str, + ) -> Result { // First, try to read output-metadata.json for the actual APK name let metadata_path = apk_dir.join("output-metadata.json"); if metadata_path.exists() { @@ -1117,7 +1211,10 @@ impl AndroidBuilder { let apk_path = apk_dir.join(&apk_name); if apk_path.exists() { if self.verbose { - println!(" Found test APK from output-metadata.json: {}", apk_path.display()); + println!( + " Found test APK from output-metadata.json: {}", + apk_path.display() + ); } return Ok(apk_path); } @@ -1261,7 +1358,10 @@ version = "0.1.0" let builder = AndroidBuilder::new(&temp_dir, "bench-mobile"); let result = builder.find_crate_dir(); - assert!(result.is_ok(), "Should find crate in bench-mobile/ directory"); + assert!( + result.is_ok(), + "Should find crate in bench-mobile/ directory" + ); assert_eq!(result.unwrap(), temp_dir.join("bench-mobile")); std::fs::remove_dir_all(&temp_dir).unwrap(); @@ -1335,8 +1435,8 @@ version = "0.1.0" let _ = std::fs::remove_dir_all(&temp_dir); std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap(); - let builder = AndroidBuilder::new(&temp_dir, "any-name") - .crate_dir(temp_dir.join("custom-location")); + let builder = + AndroidBuilder::new(&temp_dir, "any-name").crate_dir(temp_dir.join("custom-location")); let result = builder.find_crate_dir(); assert!(result.is_ok(), "Should use explicit crate_dir"); assert_eq!(result.unwrap(), temp_dir.join("custom-location")); diff --git a/crates/mobench-sdk/src/builders/common.rs b/crates/mobench-sdk/src/builders/common.rs index a48deda..fd14626 100644 --- a/crates/mobench-sdk/src/builders/common.rs +++ b/crates/mobench-sdk/src/builders/common.rs @@ -21,8 +21,15 @@ use std::env; use std::path::{Path, PathBuf}; use std::process::Command; +use serde::Deserialize; + use crate::types::BenchError; +#[derive(Deserialize)] +struct CargoMetadata { + target_directory: String, +} + /// Validates that the project root is a valid directory for building. /// /// This function checks that: @@ -122,21 +129,15 @@ pub fn get_cargo_target_dir(crate_dir: &Path) -> Result { return Ok(fallback); } - let stdout = String::from_utf8_lossy(&output.stdout); - - // Parse the JSON to extract target_directory - // Using simple string parsing to avoid adding serde_json dependency - if let Some(start) = stdout.find("\"target_directory\":\"") { - let rest = &stdout[start + 20..]; - if let Some(end) = rest.find('"') { - let target_dir = &rest[..end]; - // Handle escaped backslashes in Windows paths - let target_dir = target_dir.replace("\\\\", "\\"); - return Ok(PathBuf::from(target_dir)); - } + match serde_json::from_slice::(&output.stdout) { + Ok(metadata) => return Ok(PathBuf::from(metadata.target_directory)), + Err(err) => eprintln!( + "Warning: Failed to parse cargo metadata JSON ({}). Falling back to crate-local target dir.", + err + ), } - // Fall back to crate_dir/target if parsing fails + // Fall back to crate_dir/target if JSON parsing fails let fallback = crate_dir.join("target"); eprintln!( "Warning: Failed to parse target_directory from cargo metadata output, \ @@ -182,12 +183,7 @@ pub fn host_lib_path(crate_dir: &Path, crate_name: &str) -> Result Option { /// /// embed_bench_spec(Path::new("target/mobench"), &spec)?; /// ``` -pub fn embed_bench_spec(output_dir: &Path, spec: &S) -> Result<(), BenchError> { - let spec_json = serde_json::to_string_pretty(spec).map_err(|e| { - BenchError::Build(format!("Failed to serialize bench spec: {}", e)) - })?; +pub fn embed_bench_spec( + output_dir: &Path, + spec: &S, +) -> Result<(), BenchError> { + let spec_json = serde_json::to_string_pretty(spec) + .map_err(|e| BenchError::Build(format!("Failed to serialize bench spec: {}", e)))?; // Android: Write to assets directory let android_assets_dir = output_dir.join("android/app/src/main/assets"); @@ -472,10 +470,7 @@ pub fn is_git_dirty() -> Option { /// Gets the Rust version. pub fn get_rust_version() -> Option { - let output = Command::new("rustc") - .args(["--version"]) - .output() - .ok()?; + let output = Command::new("rustc").args(["--version"]).output().ok()?; if output.status.success() { let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -589,9 +584,8 @@ pub fn embed_bench_meta( profile: &str, ) -> Result<(), BenchError> { let meta = create_bench_meta(spec, target, profile); - let meta_json = serde_json::to_string_pretty(&meta).map_err(|e| { - BenchError::Build(format!("Failed to serialize bench meta: {}", e)) - })?; + let meta_json = serde_json::to_string_pretty(&meta) + .map_err(|e| BenchError::Build(format!("Failed to serialize bench meta: {}", e)))?; // Android: Write to assets directory let android_assets_dir = output_dir.join("android/app/src/main/assets"); @@ -789,9 +783,9 @@ members = ["crates/*"] #[test] fn test_is_leap_year() { assert!(!is_leap_year(1970)); // Not divisible by 4 - assert!(is_leap_year(2000)); // Divisible by 400 + assert!(is_leap_year(2000)); // Divisible by 400 assert!(!is_leap_year(1900)); // Divisible by 100 but not 400 - assert!(is_leap_year(2024)); // Divisible by 4, not by 100 + assert!(is_leap_year(2024)); // Divisible by 4, not by 100 } #[test] diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 00b5fd8..0648d9e 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -70,8 +70,8 @@ //! let ipa = builder.package_ipa("BenchRunner", SigningMethod::Development)?; //! ``` -use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root}; +use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -131,8 +131,20 @@ impl IosBuilder { /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile") pub fn new(project_root: impl Into, crate_name: impl Into) -> Self { let root_input = project_root.into(); - // Canonicalize the path to handle relative paths correctly, regardless of cwd - let root = root_input.canonicalize().unwrap_or(root_input); + // Canonicalize the path to handle relative paths correctly, regardless of cwd. + // Fall back to the input path with an explicit warning so callers know canonicalization + // did not succeed. + let root = match root_input.canonicalize() { + Ok(path) => path, + Err(err) => { + eprintln!( + "Warning: failed to canonicalize project root `{}`: {}. Using provided path.", + root_input.display(), + err + ); + root_input + } + }; Self { output_dir: root.join("target/mobench"), project_root: root, @@ -207,21 +219,51 @@ impl IosBuilder { if self.dry_run { println!("\n[dry-run] iOS build plan:"); - println!(" Step 0: Check/generate iOS project scaffolding at {:?}", ios_dir.join("BenchRunner")); + println!( + " Step 0: Check/generate iOS project scaffolding at {:?}", + ios_dir.join("BenchRunner") + ); println!(" Step 1: Build Rust libraries for iOS targets"); - println!(" Command: cargo build --target aarch64-apple-ios --lib {}", - if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); - println!(" Command: cargo build --target aarch64-apple-ios-sim --lib {}", - if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); - println!(" Command: cargo build --target x86_64-apple-ios --lib {}", - if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" }); + println!( + " Command: cargo build --target aarch64-apple-ios --lib {}", + if matches!(config.profile, BuildProfile::Release) { + "--release" + } else { + "" + } + ); + println!( + " Command: cargo build --target aarch64-apple-ios-sim --lib {}", + if matches!(config.profile, BuildProfile::Release) { + "--release" + } else { + "" + } + ); + println!( + " Command: cargo build --target x86_64-apple-ios --lib {}", + if matches!(config.profile, BuildProfile::Release) { + "--release" + } else { + "" + } + ); println!(" Step 2: Generate UniFFI Swift bindings"); - println!(" Output: {:?}", ios_dir.join("BenchRunner/BenchRunner/Generated")); + println!( + " Output: {:?}", + ios_dir.join("BenchRunner/BenchRunner/Generated") + ); println!(" Step 3: Create xcframework at {:?}", xcframework_path); println!(" - ios-arm64/{}.framework (device)", framework_name); - println!(" - ios-arm64_x86_64-simulator/{}.framework (simulator - arm64 + x86_64 lipo)", framework_name); + println!( + " - ios-arm64_x86_64-simulator/{}.framework (simulator - arm64 + x86_64 lipo)", + framework_name + ); println!(" Step 4: Code-sign xcframework"); - println!(" Command: codesign --force --deep --sign - {:?}", xcframework_path); + println!( + " Command: codesign --force --deep --sign - {:?}", + xcframework_path + ); println!(" Step 5: Generate Xcode project with xcodegen (if project.yml exists)"); println!(" Command: xcodegen generate"); @@ -298,7 +340,11 @@ impl IosBuilder { } /// Validates that all expected build artifacts exist after a successful build - fn validate_build_artifacts(&self, result: &BuildResult, config: &BuildConfig) -> Result<(), BenchError> { + fn validate_build_artifacts( + &self, + result: &BuildResult, + config: &BuildConfig, + ) -> Result<(), BenchError> { let mut missing = Vec::new(); let framework_name = self.crate_name.replace("-", "_"); let profile_dir = match config.profile { @@ -315,14 +361,23 @@ impl IosBuilder { let xcframework_path = &result.app_path; let device_slice = xcframework_path.join(format!("ios-arm64/{}.framework", framework_name)); // Combined simulator slice with arm64 + x86_64 - let sim_slice = xcframework_path.join(format!("ios-arm64_x86_64-simulator/{}.framework", framework_name)); + let sim_slice = xcframework_path.join(format!( + "ios-arm64_x86_64-simulator/{}.framework", + framework_name + )); if xcframework_path.exists() { if !device_slice.exists() { - missing.push(format!("Device framework slice: {}", device_slice.display())); + missing.push(format!( + "Device framework slice: {}", + device_slice.display() + )); } if !sim_slice.exists() { - missing.push(format!("Simulator framework slice (arm64+x86_64): {}", sim_slice.display())); + missing.push(format!( + "Simulator framework slice (arm64+x86_64): {}", + sim_slice.display() + )); } } @@ -331,22 +386,38 @@ impl IosBuilder { let target_dir = get_cargo_target_dir(&crate_dir)?; let lib_name = format!("lib{}.a", framework_name); - let device_lib = target_dir.join("aarch64-apple-ios").join(profile_dir).join(&lib_name); - let sim_arm64_lib = target_dir.join("aarch64-apple-ios-sim").join(profile_dir).join(&lib_name); - let sim_x86_64_lib = target_dir.join("x86_64-apple-ios").join(profile_dir).join(&lib_name); + let device_lib = target_dir + .join("aarch64-apple-ios") + .join(profile_dir) + .join(&lib_name); + let sim_arm64_lib = target_dir + .join("aarch64-apple-ios-sim") + .join(profile_dir) + .join(&lib_name); + let sim_x86_64_lib = target_dir + .join("x86_64-apple-ios") + .join(profile_dir) + .join(&lib_name); if !device_lib.exists() { missing.push(format!("Device static library: {}", device_lib.display())); } if !sim_arm64_lib.exists() { - missing.push(format!("Simulator (arm64) static library: {}", sim_arm64_lib.display())); + missing.push(format!( + "Simulator (arm64) static library: {}", + sim_arm64_lib.display() + )); } if !sim_x86_64_lib.exists() { - missing.push(format!("Simulator (x86_64) static library: {}", sim_x86_64_lib.display())); + missing.push(format!( + "Simulator (x86_64) static library: {}", + sim_x86_64_lib.display() + )); } // Check Swift bindings - let swift_bindings = self.output_dir + let swift_bindings = self + .output_dir .join("ios/BenchRunner/BenchRunner/Generated") .join(format!("{}.swift", framework_name)); if !swift_bindings.exists() { @@ -354,19 +425,29 @@ impl IosBuilder { } if !missing.is_empty() { - let critical = missing.iter().any(|m| m.contains("XCFramework") || m.contains("static library")); + let critical = missing + .iter() + .any(|m| m.contains("XCFramework") || m.contains("static library")); if critical { return Err(BenchError::Build(format!( "Build validation failed: Critical artifacts are missing.\n\n\ Missing artifacts:\n{}\n\n\ This usually means the Rust build step failed. Check the cargo build output above.", - missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + missing + .iter() + .map(|s| format!(" - {}", s)) + .collect::>() + .join("\n") ))); } else { eprintln!( "Warning: Some build artifacts are missing:\n{}\n\ The build may still work but some features might be unavailable.", - missing.iter().map(|s| format!(" - {}", s)).collect::>().join("\n") + missing + .iter() + .map(|s| format!(" - {}", s)) + .collect::>() + .join("\n") ); } } @@ -494,9 +575,8 @@ impl IosBuilder { } else { format!("cargo build --target {} --lib {}", target, release_flag) }; - let output = cmd - .output() - .map_err(|e| BenchError::Build(format!( + let output = cmd.output().map_err(|e| { + BenchError::Build(format!( "Failed to run cargo for {}.\n\n\ Command: {}\n\ Crate directory: {}\n\ @@ -506,7 +586,8 @@ impl IosBuilder { command_hint, crate_dir.display(), e - )))?; + )) + })?; if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); @@ -614,7 +695,14 @@ impl IosBuilder { // Try cargo run first (works if crate has uniffi-bindgen binary target) let cargo_run_result = Command::new("cargo") - .args(["run", "-p", &self.crate_name, "--bin", "uniffi-bindgen", "--"]) + .args([ + "run", + "-p", + &self.crate_name, + "--bin", + "uniffi-bindgen", + "--", + ]) .arg("generate") .arg("--library") .arg(&lib_path) @@ -1396,19 +1484,17 @@ impl IosBuilder { let build_dir = self.output_dir.join("ios/build"); let build_configuration = "Debug"; let mut cmd = Command::new("xcodebuild"); - cmd.args([ - "-project", - project_path.to_str().unwrap(), - "-scheme", - scheme, - "-destination", - "generic/platform=iOS", - "-configuration", - build_configuration, - "-derivedDataPath", - build_dir.to_str().unwrap(), - "build", - ]); + cmd.arg("-project") + .arg(&project_path) + .arg("-scheme") + .arg(scheme) + .arg("-destination") + .arg("generic/platform=iOS") + .arg("-configuration") + .arg(build_configuration) + .arg("-derivedDataPath") + .arg(&build_dir) + .arg("build"); // Add signing parameters based on method match method { @@ -1552,7 +1638,9 @@ impl IosBuilder { } let mut cmd = Command::new("zip"); - cmd.args(["-qr", ipa_path.to_str().unwrap(), "Payload"]) + cmd.arg("-qr") + .arg(&ipa_path) + .arg("Payload") .current_dir(&export_path); if self.verbose { @@ -1603,31 +1691,29 @@ impl IosBuilder { println!("Building XCUITest runner for {}...", scheme); let mut cmd = Command::new("xcodebuild"); - cmd.args([ - "build-for-testing", - "-project", - project_path.to_str().unwrap(), - "-scheme", - scheme, - "-destination", - "generic/platform=iOS", - "-sdk", - "iphoneos", - "-configuration", - "Release", - "-derivedDataPath", - build_dir.to_str().unwrap(), - "VALIDATE_PRODUCT=NO", - "CODE_SIGN_STYLE=Manual", - "CODE_SIGN_IDENTITY=", - "CODE_SIGNING_ALLOWED=NO", - "CODE_SIGNING_REQUIRED=NO", - "DEVELOPMENT_TEAM=", - "PROVISIONING_PROFILE_SPECIFIER=", - "ENABLE_BITCODE=NO", - "BITCODE_GENERATION_MODE=none", - "STRIP_BITCODE_FROM_COPIED_FILES=NO", - ]); + cmd.arg("build-for-testing") + .arg("-project") + .arg(&project_path) + .arg("-scheme") + .arg(scheme) + .arg("-destination") + .arg("generic/platform=iOS") + .arg("-sdk") + .arg("iphoneos") + .arg("-configuration") + .arg("Release") + .arg("-derivedDataPath") + .arg(&build_dir) + .arg("VALIDATE_PRODUCT=NO") + .arg("CODE_SIGN_STYLE=Manual") + .arg("CODE_SIGN_IDENTITY=") + .arg("CODE_SIGNING_ALLOWED=NO") + .arg("CODE_SIGNING_REQUIRED=NO") + .arg("DEVELOPMENT_TEAM=") + .arg("PROVISIONING_PROFILE_SPECIFIER=") + .arg("ENABLE_BITCODE=NO") + .arg("BITCODE_GENERATION_MODE=none") + .arg("STRIP_BITCODE_FROM_COPIED_FILES=NO"); if self.verbose { println!(" Running: {:?}", cmd); @@ -1721,10 +1807,19 @@ impl IosBuilder { })?; } + let runner_parent = runner_path.parent().ok_or_else(|| { + BenchError::Build(format!( + "Invalid XCUITest runner path with no parent directory: {}", + runner_path.display() + )) + })?; + let mut zip_cmd = Command::new("zip"); zip_cmd - .args(["-qr", zip_path.to_str().unwrap(), runner_name.as_str()]) - .current_dir(runner_path.parent().unwrap()); + .arg("-qr") + .arg(&zip_path) + .arg(&runner_name) + .current_dir(runner_parent); if self.verbose { println!(" Running: {:?}", zip_cmd); @@ -1851,8 +1946,14 @@ version = "0.1.0" let builder = IosBuilder::new(&temp_dir, "bench-mobile"); let result = builder.find_crate_dir(); - assert!(result.is_ok(), "Should find crate in bench-mobile/ directory"); - let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone()).join("bench-mobile"); + assert!( + result.is_ok(), + "Should find crate in bench-mobile/ directory" + ); + let expected = temp_dir + .canonicalize() + .unwrap_or(temp_dir.clone()) + .join("bench-mobile"); assert_eq!(result.unwrap(), expected); std::fs::remove_dir_all(&temp_dir).unwrap(); @@ -1887,7 +1988,10 @@ version = "0.1.0" let builder = IosBuilder::new(&temp_dir, "my-bench"); let result = builder.find_crate_dir(); assert!(result.is_ok(), "Should find crate in crates/ directory"); - let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone()).join("crates/my-bench"); + let expected = temp_dir + .canonicalize() + .unwrap_or(temp_dir.clone()) + .join("crates/my-bench"); assert_eq!(result.unwrap(), expected); std::fs::remove_dir_all(&temp_dir).unwrap(); @@ -1927,8 +2031,8 @@ version = "0.1.0" let _ = std::fs::remove_dir_all(&temp_dir); std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap(); - let builder = IosBuilder::new(&temp_dir, "any-name") - .crate_dir(temp_dir.join("custom-location")); + let builder = + IosBuilder::new(&temp_dir, "any-name").crate_dir(temp_dir.join("custom-location")); let result = builder.find_crate_dir(); assert!(result.is_ok(), "Should use explicit crate_dir"); assert_eq!(result.unwrap(), temp_dir.join("custom-location")); diff --git a/crates/mobench-sdk/src/builders/mod.rs b/crates/mobench-sdk/src/builders/mod.rs index 36e65bb..a9cb961 100644 --- a/crates/mobench-sdk/src/builders/mod.rs +++ b/crates/mobench-sdk/src/builders/mod.rs @@ -62,10 +62,12 @@ //! ``` pub mod android; -pub mod ios; pub mod common; +pub mod ios; // Re-export builders pub use android::AndroidBuilder; +pub use common::{ + BenchMeta, EmbeddedBenchSpec, create_bench_meta, embed_bench_meta, embed_bench_spec, +}; pub use ios::{IosBuilder, SigningMethod}; -pub use common::{embed_bench_spec, embed_bench_meta, EmbeddedBenchSpec, BenchMeta, create_bench_meta}; diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 7ffd619..09ab658 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -60,11 +60,23 @@ pub fn generate_project(config: &InitConfig) -> Result { generate_android_project(output_dir, &project_slug, default_function)?; } Target::Ios => { - generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?; + generate_ios_project( + output_dir, + &project_slug, + &project_pascal, + &bundle_prefix, + default_function, + )?; } Target::Both => { generate_android_project(output_dir, &project_slug, default_function)?; - generate_ios_project(output_dir, &project_slug, &project_pascal, &bundle_prefix, default_function)?; + generate_ios_project( + output_dir, + &project_slug, + &project_pascal, + &bundle_prefix, + default_function, + )?; } } @@ -343,7 +355,10 @@ pub fn generate_android_project( /// This function moves: /// - MainActivity.kt from `app/src/main/java/` to `app/src/main/java/{package_path}/` /// - MainActivityTest.kt from `app/src/androidTest/java/` to `app/src/androidTest/java/{package_path}/` -fn move_kotlin_files_to_package_dir(android_dir: &Path, package_name: &str) -> Result<(), BenchError> { +fn move_kotlin_files_to_package_dir( + android_dir: &Path, + package_name: &str, +) -> Result<(), BenchError> { // Convert package name to directory path (e.g., "dev.world.my_project" -> "dev/world/my_project") let package_path = package_name.replace('.', "/"); @@ -420,7 +435,8 @@ pub fn generate_ios_project( // iOS bundle identifiers should not contain hyphens or underscores let sanitized_bundle_prefix = { let parts: Vec<&str> = bundle_prefix.split('.').collect(); - parts.iter() + parts + .iter() .map(|part| sanitize_bundle_id_component(part)) .collect::>() .join(".") @@ -542,15 +558,28 @@ fn fibonacci(n: u32) -> u64 { /// File extensions that should be processed for template variable substitution const TEMPLATE_EXTENSIONS: &[&str] = &[ - "gradle", "xml", "kt", "java", "swift", "yml", "yaml", "json", "toml", "md", "txt", "h", "m", - "plist", "pbxproj", "xcscheme", "xcworkspacedata", "entitlements", "modulemap", + "gradle", + "xml", + "kt", + "java", + "swift", + "yml", + "yaml", + "json", + "toml", + "md", + "txt", + "h", + "m", + "plist", + "pbxproj", + "xcscheme", + "xcworkspacedata", + "entitlements", + "modulemap", ]; -fn render_dir( - dir: &Dir, - out_root: &Path, - vars: &[TemplateVar], -) -> Result<(), BenchError> { +fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> { for entry in dir.entries() { match entry { DirEntry::Dir(sub) => { @@ -964,13 +993,14 @@ pub fn ensure_android_project_with_options( let project_slug = crate_name.replace('-', "_"); // Resolve the default function by auto-detecting from source - let effective_root = project_root.unwrap_or_else(|| { - output_dir.parent().unwrap_or(output_dir) - }); + let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir)); let default_function = resolve_default_function(effective_root, crate_name, crate_dir); generate_android_project(output_dir, &project_slug, &default_function)?; - println!(" Generated Android project at {:?}", output_dir.join("android")); + println!( + " Generated Android project at {:?}", + output_dir.join("android") + ); println!(" Default benchmark function: {}", default_function); Ok(()) } @@ -1021,12 +1051,16 @@ pub fn ensure_ios_project_with_options( let bundle_prefix = format!("dev.world.{}", bundle_id_component); // Resolve the default function by auto-detecting from source - let effective_root = project_root.unwrap_or_else(|| { - output_dir.parent().unwrap_or(output_dir) - }); + let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir)); let default_function = resolve_default_function(effective_root, crate_name, crate_dir); - generate_ios_project(output_dir, &library_name, project_pascal, &bundle_prefix, &default_function)?; + generate_ios_project( + output_dir, + &library_name, + project_pascal, + &bundle_prefix, + &default_function, + )?; println!(" Generated iOS project at {:?}", output_dir.join("ios")); println!(" Default benchmark function: {}", default_function); Ok(()) @@ -1061,16 +1095,33 @@ mod tests { let _ = fs::remove_dir_all(&temp_dir); fs::create_dir_all(&temp_dir).unwrap(); - let result = generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func"); - assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err()); + let result = + generate_android_project(&temp_dir, "my-bench-project", "my_bench_project::test_func"); + assert!( + result.is_ok(), + "generate_android_project failed: {:?}", + result.err() + ); // Verify key files exist let android_dir = temp_dir.join("android"); assert!(android_dir.join("settings.gradle").exists()); assert!(android_dir.join("app/build.gradle").exists()); - assert!(android_dir.join("app/src/main/AndroidManifest.xml").exists()); - assert!(android_dir.join("app/src/main/res/values/strings.xml").exists()); - assert!(android_dir.join("app/src/main/res/values/themes.xml").exists()); + assert!( + android_dir + .join("app/src/main/AndroidManifest.xml") + .exists() + ); + assert!( + android_dir + .join("app/src/main/res/values/strings.xml") + .exists() + ); + assert!( + android_dir + .join("app/src/main/res/values/themes.xml") + .exists() + ); // Verify no unreplaced placeholders remain in generated files let files_to_check = [ @@ -1090,15 +1141,15 @@ mod tests { assert!( !has_placeholder, "File {} contains unreplaced template placeholders: {}", - file, - contents + file, contents ); } // Verify specific substitutions were made let settings = fs::read_to_string(android_dir.join("settings.gradle")).unwrap(); assert!( - settings.contains("my-bench-project-android") || settings.contains("my_bench_project-android"), + settings.contains("my-bench-project-android") + || settings.contains("my_bench_project-android"), "settings.gradle should contain project name" ); @@ -1109,13 +1160,15 @@ mod tests { "build.gradle should contain sanitized package name 'dev.world.mybenchproject'" ); - let manifest = fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap(); + let manifest = + fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap(); assert!( manifest.contains("Theme.MyBenchProject"), "AndroidManifest.xml should contain PascalCase theme name" ); - let strings = fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap(); + let strings = + fs::read_to_string(android_dir.join("app/src/main/res/values/strings.xml")).unwrap(); assert!( strings.contains("Benchmark"), "strings.xml should contain app name with Benchmark" @@ -1123,14 +1176,16 @@ mod tests { // Verify Kotlin files are in the correct package directory structure // For package "dev.world.mybenchproject", files should be in "dev/world/mybenchproject/" - let main_activity_path = android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt"); + let main_activity_path = + android_dir.join("app/src/main/java/dev/world/mybenchproject/MainActivity.kt"); assert!( main_activity_path.exists(), "MainActivity.kt should be in package directory: {:?}", main_activity_path ); - let test_activity_path = android_dir.join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt"); + let test_activity_path = android_dir + .join("app/src/androidTest/java/dev/world/mybenchproject/MainActivityTest.kt"); assert!( test_activity_path.exists(), "MainActivityTest.kt should be in package directory: {:?}", @@ -1139,11 +1194,15 @@ mod tests { // Verify the files are NOT in the root java directory assert!( - !android_dir.join("app/src/main/java/MainActivity.kt").exists(), + !android_dir + .join("app/src/main/java/MainActivity.kt") + .exists(), "MainActivity.kt should not be in root java directory" ); assert!( - !android_dir.join("app/src/androidTest/java/MainActivityTest.kt").exists(), + !android_dir + .join("app/src/androidTest/java/MainActivityTest.kt") + .exists(), "MainActivityTest.kt should not be in root java directory" ); @@ -1279,7 +1338,10 @@ pub fn public_bench() { // Underscores should be removed assert_eq!(sanitize_bundle_id_component("bench_mobile"), "benchmobile"); // Mixed separators should all be removed - assert_eq!(sanitize_bundle_id_component("my-project_name"), "myprojectname"); + assert_eq!( + sanitize_bundle_id_component("my-project_name"), + "myprojectname" + ); // Already valid should remain unchanged (but lowercase) assert_eq!(sanitize_bundle_id_component("benchmobile"), "benchmobile"); // Numbers should be preserved @@ -1287,7 +1349,10 @@ pub fn public_bench() { // Uppercase should be lowercased assert_eq!(sanitize_bundle_id_component("BenchMobile"), "benchmobile"); // Complex case - assert_eq!(sanitize_bundle_id_component("My-Complex_Project-123"), "mycomplexproject123"); + assert_eq!( + sanitize_bundle_id_component("My-Complex_Project-123"), + "mycomplexproject123" + ); } #[test] @@ -1309,7 +1374,11 @@ pub fn public_bench() { bundle_prefix, "bench_mobile::test_func", ); - assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err()); + assert!( + result.is_ok(), + "generate_ios_project failed: {:?}", + result.err() + ); // Verify project.yml was created let project_yml_path = temp_dir.join("ios/BenchRunner/project.yml"); @@ -1346,7 +1415,11 @@ pub fn public_bench() { // Generate Android project let result = generate_android_project(&temp_dir, project_name, "bench_mobile::test_func"); - assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err()); + assert!( + result.is_ok(), + "generate_android_project failed: {:?}", + result.err() + ); // Generate iOS project (mimicking how ensure_ios_project does it) let bundle_id_component = sanitize_bundle_id_component(project_name); @@ -1358,17 +1431,19 @@ pub fn public_bench() { &bundle_prefix, "bench_mobile::test_func", ); - assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err()); + assert!( + result.is_ok(), + "generate_ios_project failed: {:?}", + result.err() + ); // Read Android build.gradle to extract package name - let android_build_gradle = fs::read_to_string( - temp_dir.join("android/app/build.gradle") - ).expect("Failed to read Android build.gradle"); + let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle")) + .expect("Failed to read Android build.gradle"); // Read iOS project.yml to extract bundle ID prefix - let ios_project_yml = fs::read_to_string( - temp_dir.join("ios/BenchRunner/project.yml") - ).expect("Failed to read iOS project.yml"); + let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml")) + .expect("Failed to read iOS project.yml"); // Both should use "benchmobile" (without hyphens or underscores) // Android: namespace = "dev.world.benchmobile" @@ -1409,7 +1484,11 @@ pub fn public_bench() { // Generate Android project let result = generate_android_project(&temp_dir, project_name, "test_project::test_func"); - assert!(result.is_ok(), "generate_android_project failed: {:?}", result.err()); + assert!( + result.is_ok(), + "generate_android_project failed: {:?}", + result.err() + ); // Generate iOS project let bundle_id_component = sanitize_bundle_id_component(project_name); @@ -1421,17 +1500,19 @@ pub fn public_bench() { &bundle_prefix, "test_project::test_func", ); - assert!(result.is_ok(), "generate_ios_project failed: {:?}", result.err()); + assert!( + result.is_ok(), + "generate_ios_project failed: {:?}", + result.err() + ); // Read Android build.gradle - let android_build_gradle = fs::read_to_string( - temp_dir.join("android/app/build.gradle") - ).expect("Failed to read Android build.gradle"); + let android_build_gradle = fs::read_to_string(temp_dir.join("android/app/build.gradle")) + .expect("Failed to read Android build.gradle"); // Read iOS project.yml - let ios_project_yml = fs::read_to_string( - temp_dir.join("ios/BenchRunner/project.yml") - ).expect("Failed to read iOS project.yml"); + let ios_project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml")) + .expect("Failed to read iOS project.yml"); // Both should use version "1.0.0" assert!( @@ -1457,7 +1538,10 @@ pub fn public_bench() { ("bench_mobile", "dev.world.benchmobile"), ("TestApp", "dev.world.testapp"), ("app-with-many-dashes", "dev.world.appwithmanydashes"), - ("app_with_many_underscores", "dev.world.appwithmanyunderscores"), + ( + "app_with_many_underscores", + "dev.world.appwithmanyunderscores", + ), ]; for (input, expected_prefix) in test_cases { diff --git a/crates/mobench-sdk/src/ffi.rs b/crates/mobench-sdk/src/ffi.rs new file mode 100644 index 0000000..b86882e --- /dev/null +++ b/crates/mobench-sdk/src/ffi.rs @@ -0,0 +1,259 @@ +//! Unified FFI module for UniFFI integration. +//! +//! This module provides a single import point for all FFI-related types and traits +//! needed to create UniFFI bindings for mobile platforms. +//! +//! # Quick Start +//! +//! ```ignore +//! use mobench_sdk::ffi::{BenchSpecFfi, BenchSampleFfi, BenchReportFfi, BenchErrorFfi}; +//! use mobench_sdk::ffi::{IntoFfi, FromFfi}; +//! +//! // Define your UniFFI types using the Ffi suffix types as templates +//! #[derive(uniffi::Record)] +//! pub struct BenchSpec { +//! pub name: String, +//! pub iterations: u32, +//! pub warmup: u32, +//! } +//! +//! // Implement conversions using the traits +//! impl FromFfi for BenchSpec { +//! fn from_ffi(ffi: BenchSpecFfi) -> Self { +//! Self { +//! name: ffi.name, +//! iterations: ffi.iterations, +//! warmup: ffi.warmup, +//! } +//! } +//! } +//! ``` + +use serde::{Deserialize, Serialize}; + +// Re-export from uniffi_types for backwards compatibility +pub use crate::uniffi_types::{ + BenchErrorVariant, BenchReportTemplate, BenchSampleTemplate, BenchSpecTemplate, FromSdkError, + FromSdkReport, FromSdkSample, FromSdkSpec, +}; + +/// FFI-ready benchmark specification. +/// +/// Use this as a template for your UniFFI Record type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchSpecFfi { + /// Name of the benchmark function to run. + pub name: String, + /// Number of measurement iterations. + pub iterations: u32, + /// Number of warmup iterations before measurement. + pub warmup: u32, +} + +impl From for BenchSpecFfi { + fn from(spec: crate::BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for crate::BenchSpec { + fn from(spec: BenchSpecFfi) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +/// FFI-ready benchmark sample. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchSampleFfi { + /// Duration of the iteration in nanoseconds. + pub duration_ns: u64, +} + +impl From for BenchSampleFfi { + fn from(sample: crate::BenchSample) -> Self { + Self { + duration_ns: sample.duration_ns, + } + } +} + +impl From for crate::BenchSample { + fn from(sample: BenchSampleFfi) -> Self { + Self { + duration_ns: sample.duration_ns, + } + } +} + +/// FFI-ready benchmark report. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchReportFfi { + /// The specification used for this benchmark run. + pub spec: BenchSpecFfi, + /// All collected timing samples. + pub samples: Vec, +} + +impl From for BenchReportFfi { + fn from(report: crate::RunnerReport) -> Self { + Self { + spec: report.spec.into(), + samples: report.samples.into_iter().map(Into::into).collect(), + } + } +} + +/// FFI-ready error type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BenchErrorFfi { + /// The iteration count was zero. + InvalidIterations, + /// The requested benchmark function was not found. + UnknownFunction { name: String }, + /// An error occurred during benchmark execution. + ExecutionFailed { reason: String }, + /// Configuration error. + ConfigError { message: String }, + /// I/O error. + IoError { message: String }, +} + +impl From for BenchErrorFfi { + fn from(err: crate::types::BenchError) -> Self { + match err { + crate::types::BenchError::Runner(runner_err) => match runner_err { + crate::timing::TimingError::NoIterations { .. } => BenchErrorFfi::InvalidIterations, + crate::timing::TimingError::Execution(msg) => { + BenchErrorFfi::ExecutionFailed { reason: msg } + } + }, + crate::types::BenchError::UnknownFunction(name, _) => { + BenchErrorFfi::UnknownFunction { name } + } + crate::types::BenchError::Execution(msg) => { + BenchErrorFfi::ExecutionFailed { reason: msg } + } + crate::types::BenchError::Io(e) => BenchErrorFfi::IoError { + message: e.to_string(), + }, + crate::types::BenchError::Serialization(e) => BenchErrorFfi::ConfigError { + message: e.to_string(), + }, + crate::types::BenchError::Config(msg) => BenchErrorFfi::ConfigError { message: msg }, + crate::types::BenchError::Build(msg) => BenchErrorFfi::ExecutionFailed { + reason: format!("build error: {}", msg), + }, + } + } +} + +/// Trait for converting SDK types to FFI types. +pub trait IntoFfi { + /// Convert self into the FFI representation. + fn into_ffi(self) -> T; +} + +/// Trait for converting FFI types to SDK types. +pub trait FromFfi { + /// Convert from FFI representation to SDK type. + fn from_ffi(ffi: T) -> Self; +} + +// Blanket implementations +impl IntoFfi for T +where + U: From, +{ + fn into_ffi(self) -> U { + U::from(self) + } +} + +impl FromFfi for T +where + T: From, +{ + fn from_ffi(ffi: U) -> Self { + T::from(ffi) + } +} + +/// Run a benchmark and return FFI-ready result. +/// +/// This is a convenience function that wraps `run_benchmark` with FFI type conversions. +#[cfg(feature = "full")] +pub fn run_benchmark_ffi(spec: BenchSpecFfi) -> Result { + let sdk_spec: crate::BenchSpec = spec.into(); + crate::run_benchmark(sdk_spec) + .map(Into::into) + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bench_spec_ffi_conversion() { + let sdk_spec = crate::BenchSpec { + name: "test".to_string(), + iterations: 100, + warmup: 10, + }; + + let ffi: BenchSpecFfi = sdk_spec.clone().into(); + assert_eq!(ffi.name, "test"); + assert_eq!(ffi.iterations, 100); + assert_eq!(ffi.warmup, 10); + + let back: crate::BenchSpec = ffi.into(); + assert_eq!(back.name, sdk_spec.name); + } + + #[test] + fn test_bench_sample_ffi_conversion() { + let sdk_sample = crate::BenchSample { duration_ns: 12345 }; + let ffi: BenchSampleFfi = sdk_sample.into(); + assert_eq!(ffi.duration_ns, 12345); + } + + #[test] + fn test_bench_report_ffi_conversion() { + let report = crate::RunnerReport { + spec: crate::BenchSpec { + name: "test".to_string(), + iterations: 2, + warmup: 1, + }, + samples: vec![ + crate::BenchSample { duration_ns: 100 }, + crate::BenchSample { duration_ns: 200 }, + ], + }; + + let ffi: BenchReportFfi = report.into(); + assert_eq!(ffi.spec.name, "test"); + assert_eq!(ffi.samples.len(), 2); + assert_eq!(ffi.samples[0].duration_ns, 100); + } + + #[test] + fn test_into_ffi_trait() { + let spec = crate::BenchSpec { + name: "test".to_string(), + iterations: 50, + warmup: 5, + }; + + let ffi: BenchSpecFfi = spec.into_ffi(); + assert_eq!(ffi.iterations, 50); + } +} diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index b246910..c2f8ea9 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -276,7 +276,7 @@ //! - Xcode with command line tools //! - `uniffi-bindgen` (`cargo install uniffi-bindgen`) //! - `xcodegen` (optional, `brew install xcodegen`) -//! - Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim` +//! - Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios` //! //! ## Best Practices //! @@ -334,6 +334,9 @@ pub mod types; // This module provides template types and conversion traits for UniFFI integration pub mod uniffi_types; +// Unified FFI module for UniFFI integration +pub mod ffi; + // Full SDK modules - only with "full" feature #[cfg(feature = "full")] #[cfg_attr(docsrs, doc(cfg(feature = "full")))] @@ -353,6 +356,11 @@ pub mod runner; #[cfg_attr(docsrs, doc(cfg(feature = "full")))] pub use mobench_macros::benchmark; +// Re-export inventory so users don't need to add it as a separate dependency +#[cfg(feature = "full")] +#[cfg_attr(docsrs, doc(cfg(feature = "full")))] +pub use inventory; + // Re-export key types for convenience (full feature) #[cfg(feature = "full")] #[cfg_attr(docsrs, doc(cfg(feature = "full")))] @@ -370,7 +378,12 @@ pub use types::{BenchError, BenchSample, BenchSpec, RunnerReport}; pub use types::{BuildConfig, BuildProfile, BuildResult, InitConfig, Target}; // Re-export timing types at the crate root for convenience -pub use timing::{run_closure, TimingError}; +pub use timing::{BenchSummary, TimingError, run_closure}; + +/// Re-export of [`std::hint::black_box`] for preventing compiler optimizations. +/// +/// Use this to ensure the compiler doesn't optimize away benchmark computations. +pub use std::hint::black_box; /// Library version, matching `Cargo.toml`. /// diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index a4f8052..2113ad8 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -234,6 +234,128 @@ pub struct BenchReport { pub samples: Vec, } +impl BenchReport { + /// Returns the mean (average) duration in nanoseconds. + #[must_use] + pub fn mean_ns(&self) -> f64 { + if self.samples.is_empty() { + return 0.0; + } + let sum: u64 = self.samples.iter().map(|s| s.duration_ns).sum(); + sum as f64 / self.samples.len() as f64 + } + + /// Returns the median duration in nanoseconds. + #[must_use] + pub fn median_ns(&self) -> f64 { + if self.samples.is_empty() { + return 0.0; + } + let mut sorted: Vec = self.samples.iter().map(|s| s.duration_ns).collect(); + sorted.sort_unstable(); + let len = sorted.len(); + if len % 2 == 0 { + (sorted[len / 2 - 1] + sorted[len / 2]) as f64 / 2.0 + } else { + sorted[len / 2] as f64 + } + } + + /// Returns the standard deviation in nanoseconds (sample std dev, n-1). + #[must_use] + pub fn std_dev_ns(&self) -> f64 { + if self.samples.len() < 2 { + return 0.0; + } + let mean = self.mean_ns(); + let variance: f64 = self + .samples + .iter() + .map(|s| { + let diff = s.duration_ns as f64 - mean; + diff * diff + }) + .sum::() + / (self.samples.len() - 1) as f64; + variance.sqrt() + } + + /// Returns the given percentile (0-100) in nanoseconds. + #[must_use] + pub fn percentile_ns(&self, p: f64) -> f64 { + if self.samples.is_empty() { + return 0.0; + } + let mut sorted: Vec = self.samples.iter().map(|s| s.duration_ns).collect(); + sorted.sort_unstable(); + let p = p.clamp(0.0, 100.0) / 100.0; + let index = (p * (sorted.len() - 1) as f64).round() as usize; + sorted[index.min(sorted.len() - 1)] as f64 + } + + /// Returns the minimum duration in nanoseconds. + #[must_use] + pub fn min_ns(&self) -> u64 { + self.samples + .iter() + .map(|s| s.duration_ns) + .min() + .unwrap_or(0) + } + + /// Returns the maximum duration in nanoseconds. + #[must_use] + pub fn max_ns(&self) -> u64 { + self.samples + .iter() + .map(|s| s.duration_ns) + .max() + .unwrap_or(0) + } + + /// Returns a statistical summary of the benchmark results. + #[must_use] + pub fn summary(&self) -> BenchSummary { + BenchSummary { + name: self.spec.name.clone(), + iterations: self.samples.len() as u32, + warmup: self.spec.warmup, + mean_ns: self.mean_ns(), + median_ns: self.median_ns(), + std_dev_ns: self.std_dev_ns(), + min_ns: self.min_ns(), + max_ns: self.max_ns(), + p95_ns: self.percentile_ns(95.0), + p99_ns: self.percentile_ns(99.0), + } + } +} + +/// Statistical summary of benchmark results. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BenchSummary { + /// Name of the benchmark. + pub name: String, + /// Number of measured iterations. + pub iterations: u32, + /// Number of warmup iterations. + pub warmup: u32, + /// Mean duration in nanoseconds. + pub mean_ns: f64, + /// Median duration in nanoseconds. + pub median_ns: f64, + /// Standard deviation in nanoseconds. + pub std_dev_ns: f64, + /// Minimum duration in nanoseconds. + pub min_ns: u64, + /// Maximum duration in nanoseconds. + pub max_ns: u64, + /// 95th percentile in nanoseconds. + pub p95_ns: f64, + /// 99th percentile in nanoseconds. + pub p99_ns: f64, +} + /// Errors that can occur during benchmark execution. /// /// # Example @@ -558,7 +680,10 @@ mod tests { #[test] fn rejects_zero_iterations() { let result = BenchSpec::new("test", 0, 10); - assert!(matches!(result, Err(TimingError::NoIterations { count: 0 }))); + assert!(matches!( + result, + Err(TimingError::NoIterations { count: 0 }) + )); } #[test] @@ -647,9 +772,7 @@ mod tests { SETUP_COUNT.fetch_add(1, Ordering::SeqCst); "resource" }, - |_resource| { - Ok(()) - }, + |_resource| Ok(()), |_resource| { TEARDOWN_COUNT.fetch_add(1, Ordering::SeqCst); }, diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs index a872fc5..0617416 100644 --- a/crates/mobench-sdk/src/types.rs +++ b/crates/mobench-sdk/src/types.rs @@ -18,7 +18,7 @@ // Re-export timing types for convenience pub use crate::timing::{ - BenchReport as RunnerReport, BenchSample, BenchSpec, TimingError as RunnerError, + BenchReport as RunnerReport, BenchSample, BenchSpec, BenchSummary, TimingError as RunnerError, }; use std::path::PathBuf; @@ -62,7 +62,9 @@ pub enum BenchError { /// a function name that hasn't been registered via `#[benchmark]`. /// /// The error includes a list of available benchmarks to help diagnose the issue. - #[error("unknown benchmark function: '{0}'. Available benchmarks: {1:?}\n\nEnsure the function is:\n 1. Annotated with #[benchmark]\n 2. Public (pub fn)\n 3. Takes no parameters and returns ()")] + #[error( + "unknown benchmark function: '{0}'. Available benchmarks: {1:?}\n\nEnsure the function is:\n 1. Annotated with #[benchmark]\n 2. Public (pub fn)\n 3. Takes no parameters and returns ()" + )] UnknownFunction(String, Vec), /// An error occurred during benchmark execution. diff --git a/crates/mobench-sdk/src/uniffi_types.rs b/crates/mobench-sdk/src/uniffi_types.rs index 40f6afa..bef1246 100644 --- a/crates/mobench-sdk/src/uniffi_types.rs +++ b/crates/mobench-sdk/src/uniffi_types.rs @@ -261,7 +261,9 @@ impl From for BenchErrorVariant { crate::types::BenchError::Serialization(e) => BenchErrorVariant::ConfigError { message: e.to_string(), }, - crate::types::BenchError::Config(msg) => BenchErrorVariant::ConfigError { message: msg }, + crate::types::BenchError::Config(msg) => { + BenchErrorVariant::ConfigError { message: msg } + } crate::types::BenchError::Build(msg) => BenchErrorVariant::ExecutionFailed { reason: format!("build error: {}", msg), }, diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index df47c36..9fdda8e 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true authors = ["Dominik Clemente - dcbuilder.eth "] -description = "Mobile benchmarking CLI for Rust - Run benchmarks on real Android and iOS devices" +description = "Rust mobile benchmark CLI with CI contract outputs and BrowserStack automation" repository = "https://github.com/worldcoin/mobile-bench-rs" documentation = "https://docs.rs/mobench" readme = "README.md" @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.13", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.14", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true @@ -45,8 +45,10 @@ toml.workspace = true reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking", "json", "multipart"] } dotenvy = "0.15" time.workspace = true +sha2 = "0.10" [dev-dependencies] tempfile = "3" inventory = "0.3" sample-fns = { path = "../sample-fns" } +jsonschema = "0.18" diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 9d67869..065a2e9 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -6,11 +6,17 @@ The `mobench` CLI is the easiest way to benchmark your Rust code on mobile devic ## Installation +```bash +cargo binstall mobench +``` + +Or build from source: + ```bash cargo install mobench ``` -Or use as a Cargo subcommand: +Use as a Cargo subcommand: ```bash cargo install mobench @@ -164,16 +170,22 @@ cargo mobench run --target --function [OPTIONS] - `--iterations ` - Number of iterations (default: 100) - `--warmup ` - Warmup iterations (default: 10) - `--devices ` - Comma-separated device list for BrowserStack +- `--device-matrix ` - Load devices from a matrix YAML file (overrides `device_matrix` from `--config` when both are provided) +- `--device-tags ` - Filter device matrix by tag (repeatable / comma-separated) - `--local-only` - Skip mobile builds (no device run) - `--config ` - Load run spec from config file - `--ios-app ` - iOS .ipa or zipped .app for BrowserStack - `--ios-test-suite ` - iOS XCUITest runner (.zip or .ipa) -- `--output ` - Save results to JSON file (default: run-summary.json) +- `--output ` - Save results to JSON file (default: target/mobench/results.json) - `--summary-csv` - Write CSV summary alongside JSON/Markdown - `--fetch` - Fetch BrowserStack results after completion +- `--ci` - CI mode (step summary + regression exit codes) +- `--baseline >` - Compare against baseline summary (non-zero on regressions); if baseline resolves to the output file path, mobench snapshots the previous file first +- `--regression-threshold-pct ` - Regression threshold percentage (default: 5.0) +- `--junit ` - Write JUnit XML report **Outputs:** -- JSON summary (default: `run-summary.json`) +- JSON summary (default: `target/mobench/results.json`) - Markdown summary (same base name, `.md`) - CSV summary (same base name, `.csv`, when `--summary-csv` is set) @@ -200,6 +212,92 @@ cargo mobench run \ --fetch ``` +### `ci run` - One-command CI Orchestration + +Run build/package/run/fetch/report end-to-end with stable CI output files: + +```bash +cargo mobench ci run --target --function [OPTIONS] +``` + +**Contract outputs (default directory: `target/mobench/ci/`):** +- `summary.json` +- `summary.md` +- `results.csv` + +`summary.json` includes a `ci` section with metadata fields: +- `requested_by` +- `pr_number` +- `request_command` +- `mobench_ref` +- `mobench_version` + +Contract references: +- `docs/CONTRACT_CI_V1.md` +- `docs/schemas/summary-v1.schema.json` +- `docs/schemas/ci-contract-v1.schema.json` + +**Example:** +```bash +cargo mobench ci run \ + --target android \ + --function sample_fns::fibonacci \ + --devices "Google Pixel 7-13.0" \ + --release \ + --fetch + +# Combined android + ios contract output +cargo mobench ci run \ + --target both \ + --function sample_fns::fibonacci \ + --local-only +``` + +When `--baseline` is omitted for `ci run`, mobench automatically uses the previous successful summary snapshot in the target output directory when present. + +### `config validate` - Validate Run Config Contract + +Validate `bench-config.toml` and referenced matrix/settings with contract-aligned issue categories: + +```bash +cargo mobench config validate --config bench-config.toml +cargo mobench config validate --config bench-config.toml --format json +``` + +### `devices resolve` - Deterministic Matrix Resolution + +Resolve matrix devices for a platform/profile without custom scripts: + +```bash +cargo mobench devices resolve \ + --platform android \ + --profile default \ + --device-matrix device-matrix.yaml + +cargo mobench devices resolve \ + --platform ios \ + --config bench-config.toml \ + --format json +``` + +### `fixture` - Fixture Lifecycle Commands + +Manage reproducible fixture setup for CI: + +```bash +# Create starter fixture files +cargo mobench fixture init + +# Build fixture artifacts +cargo mobench fixture build --target both --release + +# Verify fixture config + matrix resolution +cargo mobench fixture verify --config bench-config.toml + +# Generate deterministic cache key +cargo mobench fixture cache-key --config bench-config.toml --format json +``` + ### `package-ipa` - Package iOS IPA Create a signed IPA for BrowserStack: @@ -306,6 +404,31 @@ cargo mobench compare \ --output comparison.md ``` +### `report summarize` - Render CI Summary Markdown + +Generate natural-language markdown from standardized output JSON: + +```bash +cargo mobench report summarize --summary target/mobench/ci/summary.json +cargo mobench report summarize --summary target/mobench/ci/summary.json --output report.md +``` + +### `report github` - Sticky PR Comment Payload/Publish + +Create or update sticky PR comments from standardized outputs: + +```bash +# Print comment body +cargo mobench report github --pr 123 --summary target/mobench/ci/summary.json + +# Publish/update comment (requires GITHUB_TOKEN + GITHUB_REPOSITORY) +cargo mobench report github \ + --pr 123 \ + --summary target/mobench/ci/summary.json \ + --publish \ + --marker "" +``` + ## Configuration ### Project Configuration (`mobench.toml`) @@ -432,6 +555,7 @@ BrowserStack credentials can be provided via: ```bash rustup target add aarch64-apple-ios rustup target add aarch64-apple-ios-sim + rustup target add x86_64-apple-ios ``` - **XcodeGen** - Install with `brew install xcodegen` @@ -490,48 +614,72 @@ cargo mobench run \ ### CI Integration +Generate a ready-to-edit workflow + action wrapper: + +```bash +cargo mobench ci init +``` + +This writes `.github/workflows/mobile-bench.yml` plus a local action in +`.github/actions/mobench/` that handles caching, Android setup, and artifact upload. + +Example workflow excerpt: + ```yaml -# .github/workflows/mobile-bench.yml -name: Mobile Benchmarks - -on: [push] - -jobs: - benchmark: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Install mobench - run: cargo install mobench - - - name: Setup Android NDK - uses: nttld/setup-ndk@v1 - with: - ndk-version: r25c - - - name: Build - run: cargo mobench build --target android --release - - - name: Run benchmarks - env: - BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} - BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - run: | - cargo mobench run \ - --target android \ - --function my_benchmark \ - --devices "Google Pixel 7-13.0" \ - --iterations 50 \ - --release \ - --output results.json \ - --fetch - - - name: Upload results - uses: actions/upload-artifact@v3 - with: - name: benchmark-results - path: results.json +- uses: ./.github/actions/mobench + with: + command: cargo mobench ci run + run-args: | + --target android + --function my_benchmark + --devices "Google Pixel 7-13.0" + --iterations 50 + --release + --fetch + ci: false + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} +``` + +The local action currently supports `command` values `cargo mobench ci run` and `cargo mobench run`. + +For CI dashboards, add `--junit path/to/results.junit.xml`. + +### Typed Rust API + +`mobench` also exposes a typed request/result surface for integrations: + +```rust +use mobench::{DeviceSelection, MobileTarget, RunRequest, run_request}; +use std::path::PathBuf; + +let result = run_request(&RunRequest { + target: MobileTarget::Android, + function: "sample_fns::fibonacci".to_string(), + iterations: 20, + warmup: 5, + device_selection: DeviceSelection { + devices: vec!["Google Pixel 7-13.0".to_string()], + device_matrix: None, + device_tags: vec![], + }, + config: None, + baseline: None, + regression_threshold_pct: 5.0, + junit: None, + local_only: true, + release: false, + ios_app: None, + ios_test_suite: None, + fetch: false, + fetch_output_dir: PathBuf::from("target/browserstack"), + fetch_poll_interval_secs: 5, + fetch_timeout_secs: 300, + progress: false, + output_dir: PathBuf::from("target/mobench/ci"), +})?; +println!("summary: {}", result.report.summary_json.display()); ``` ## Workflow diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index 83cb30b..5cfb668 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -141,7 +141,10 @@ impl BrowserStackClient { } let file_size = get_file_size(artifact); - println!("Uploading Android test APK ({})...", format_file_size(file_size)); + println!( + "Uploading Android test APK ({})...", + format_file_size(file_size) + ); let start = Instant::now(); let form = Form::new().file("file", artifact)?; @@ -194,7 +197,10 @@ impl BrowserStackClient { } let file_size = get_file_size(artifact); - println!("Uploading iOS XCUITest runner ({})...", format_file_size(file_size)); + println!( + "Uploading iOS XCUITest runner ({})...", + format_file_size(file_size) + ); let start = Instant::now(); let form = Form::new().file("file", artifact)?; @@ -276,7 +282,8 @@ impl BrowserStackClient { build_name: self.project.clone(), // Specify the test method to run (required by BrowserStack for XCUITest) only_testing: Some(vec![ - "BenchRunnerUITests/BenchRunnerUITests/testLaunchAndCaptureBenchmarkReport".to_string(), + "BenchRunnerUITests/BenchRunnerUITests/testLaunchAndCaptureBenchmarkReport" + .to_string(), ]), }; @@ -1108,7 +1115,11 @@ fn parse_device_list(json: Value, context: &str) -> Result = matching_devices - .iter() - .map(|d| d.identifier()) - .collect(); + let available_versions: Vec = + matching_devices.iter().map(|d| d.identifier()).collect(); let mut suggestions = available_versions; suggestions.sort(); @@ -1202,10 +1219,7 @@ fn validate_device_spec( return Err(DeviceValidationError { spec: spec.to_string(), - reason: format!( - "OS version '{}' not available for this device", - version - ), + reason: format!("OS version '{}' not available for this device", version), suggestions, }); } @@ -1233,9 +1247,10 @@ fn validate_device_spec( let spec_words: Vec<&str> = spec_lower.split_whitespace().collect(); let device_words: Vec<&str> = device_lower.split_whitespace().collect(); - let matches = spec_words.iter().filter(|sw| - device_words.iter().any(|dw| dw.contains(*sw)) - ).count(); + let matches = spec_words + .iter() + .filter(|sw| device_words.iter().any(|dw| dw.contains(*sw))) + .count(); if matches == spec_words.len() && !spec_words.is_empty() { // All words from spec found in device name @@ -1254,9 +1269,7 @@ fn validate_device_spec( } // Sort by score (descending), then alphabetically - scored_suggestions.sort_by(|a, b| { - b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)) - }); + scored_suggestions.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); // Take top 3 unique suggestions let suggestions: Vec = scored_suggestions @@ -2111,8 +2124,16 @@ BENCH_REPORT_JSON_END assert!(result.is_err()); let error = result.unwrap_err(); assert!(error.reason.contains("OS version")); - assert!(error.suggestions.contains(&"Google Pixel 7-13.0".to_string())); - assert!(error.suggestions.contains(&"Google Pixel 7-14.0".to_string())); + assert!( + error + .suggestions + .contains(&"Google Pixel 7-13.0".to_string()) + ); + assert!( + error + .suggestions + .contains(&"Google Pixel 7-14.0".to_string()) + ); } #[test] @@ -2154,7 +2175,11 @@ BENCH_REPORT_JSON_END let result = validate_device_spec("Pixel", &devices); assert!(result.is_err()); let error = result.unwrap_err(); - assert!(error.suggestions.len() <= 3, "Should have at most 3 suggestions, got {}", error.suggestions.len()); + assert!( + error.suggestions.len() <= 3, + "Should have at most 3 suggestions, got {}", + error.suggestions.len() + ); } #[test] diff --git a/crates/mobench/src/config.rs b/crates/mobench/src/config.rs index 4edb71b..9901c36 100644 --- a/crates/mobench/src/config.rs +++ b/crates/mobench/src/config.rs @@ -262,8 +262,7 @@ impl MobenchConfig { /// * `Ok(())` - Successfully saved configuration /// * `Err` - If the file cannot be written pub fn save_to_file(&self, path: &Path) -> Result<()> { - let contents = - toml::to_string_pretty(self).context("Failed to serialize configuration")?; + let contents = toml::to_string_pretty(self).context("Failed to serialize configuration")?; std::fs::write(path, contents) .with_context(|| format!("Failed to write config file: {:?}", path))?; @@ -273,10 +272,12 @@ impl MobenchConfig { /// Returns the library name, either from config or derived from crate name. pub fn library_name(&self) -> Option { - self.project - .library_name - .clone() - .or_else(|| self.project.crate_name.as_ref().map(|c| c.replace('-', "_"))) + self.project.library_name.clone().or_else(|| { + self.project + .crate_name + .as_ref() + .map(|c| c.replace('-', "_")) + }) } /// Generates a starter configuration with sensible defaults. diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 1cdc20d..fa2f230 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -47,6 +47,12 @@ //! | `init` | Initialize a new benchmark project | //! | `build` | Build mobile artifacts (APK/xcframework) | //! | `run` | Execute benchmarks locally or on devices | +//! | `ci run` | Run standardized CI orchestration (`summary.json`, `summary.md`, `results.csv`) | +//! | `doctor` | Validate local/CI prerequisites and configuration | +//! | `config validate` | Validate run config + matrix contract | +//! | `devices resolve` | Resolve deterministic device sets from matrix/profile | +//! | `fixture ...` | Fixture lifecycle helpers (`init`, `build`, `verify`, `cache-key`) | +//! | `report ...` | Render markdown and publish sticky PR comments | //! | `list` | List discovered benchmark functions | //! | `fetch` | Retrieve results from BrowserStack | //! | `package-ipa` | Package iOS app as IPA | @@ -114,13 +120,15 @@ #![cfg_attr(docsrs, feature(doc_cfg))] use anyhow::{Context, Result, anyhow, bail}; -use clap::{Parser, Subcommand, ValueEnum}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use std::collections::{BTreeMap, BTreeSet}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::env; use std::fmt::Write; use std::fs; +use std::io::Write as IoWrite; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use time::OffsetDateTime; @@ -143,13 +151,31 @@ struct Cli { #[arg(long, short = 'v', global = true)] verbose: bool, + /// Assume yes to prompts and allow overwriting files + #[arg(long, global = true)] + yes: bool, + + /// Disable interactive prompts (fail instead) + #[arg(long, global = true)] + non_interactive: bool, + #[command(subcommand)] command: Command, } #[derive(Subcommand, Debug)] enum Command { - /// Run a benchmark against a target platform (mobile integration stub for now). + /// Run benchmarks on real devices via BrowserStack. + /// + /// This is a single-command flow that: + /// 1. Builds Rust libraries for the target platform + /// 2. Packages mobile apps (APK/IPA) automatically + /// 3. Uploads to BrowserStack + /// 4. Schedules the benchmark run + /// 5. Fetches results when complete + /// + /// For iOS, IPA and XCUITest packages are created automatically unless + /// you provide --ios-app and --ios-test-suite to override. Run { #[arg(long, value_enum)] target: MobileTarget, @@ -161,15 +187,41 @@ enum Command { warmup: u32, #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] devices: Vec, + #[arg(long, help = "Device matrix YAML file to load device names from")] + device_matrix: Option, + #[arg( + long, + value_delimiter = ',', + help = "Device tags to select from the device matrix (comma-separated or repeatable)" + )] + device_tags: Vec, #[arg(long, help = "Optional path to config file")] config: Option, #[arg(long, help = "Optional output path for JSON report")] output: Option, #[arg(long, help = "Write CSV summary alongside JSON")] summary_csv: bool, + #[arg( + long, + help = "Enable CI mode (job summary, optional JUnit, regression exit codes)" + )] + ci: bool, + #[arg(long, help = "Baseline summary source (path|url|artifact:)")] + baseline: Option, + #[arg( + long, + default_value_t = 5.0, + help = "Regression threshold percentage when comparing to baseline" + )] + regression_threshold_pct: f64, + #[arg(long, help = "Write JUnit XML report to the given path")] + junit: Option, #[arg(long, help = "Skip mobile builds and only run the host harness")] local_only: bool, - #[arg(long, help = "Build in release mode (recommended for BrowserStack to reduce APK size and upload time)")] + #[arg( + long, + help = "Build in release mode (recommended for BrowserStack to reduce APK size and upload time)" + )] release: bool, #[arg( long, @@ -201,6 +253,42 @@ enum Command { #[arg(long, default_value = "device-matrix.yaml")] output: PathBuf, }, + /// Validate run configuration and associated files. + Config { + #[command(subcommand)] + command: ConfigCommand, + }, + /// Validate local + CI prerequisites and configuration. + Doctor { + #[arg(long, value_enum, default_value_t = SdkTarget::Both)] + target: SdkTarget, + #[arg(long, help = "Optional path to run config file to validate")] + config: Option, + #[arg(long, help = "Optional path to device matrix YAML file to validate")] + device_matrix: Option, + #[arg( + long, + value_delimiter = ',', + help = "Device tags to select from the device matrix (comma-separated or repeatable)" + )] + device_tags: Vec, + #[arg( + long, + default_value_t = true, + action = clap::ArgAction::Set, + num_args = 0..=1, + default_missing_value = "true", + help = "Validate BrowserStack credentials" + )] + browserstack: bool, + #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] + format: CheckOutputFormat, + }, + /// CI helpers (workflow and action scaffolding). + Ci { + #[command(subcommand)] + command: CiCommand, + }, /// Fetch BrowserStack build artifacts (logs, session JSON) for CI. Fetch { #[arg(long, value_enum)] @@ -242,9 +330,15 @@ enum Command { target: SdkTarget, #[arg(long, help = "Build in release mode")] release: bool, - #[arg(long, help = "Output directory for mobile artifacts (default: target/mobench)")] + #[arg( + long, + help = "Output directory for mobile artifacts (default: target/mobench)" + )] output_dir: Option, - #[arg(long, help = "Path to the benchmark crate (default: auto-detect bench-mobile/ or crates/{crate})")] + #[arg( + long, + help = "Path to the benchmark crate (default: auto-detect bench-mobile/ or crates/{crate})" + )] crate_path: Option, #[arg(long, help = "Show simplified step-by-step progress output")] progress: bool, @@ -255,7 +349,10 @@ enum Command { scheme: String, #[arg(long, value_enum, default_value = "adhoc", help = "Signing method")] method: IosSigningMethodArg, - #[arg(long, help = "Output directory for mobile artifacts (default: target/mobench)")] + #[arg( + long, + help = "Output directory for mobile artifacts (default: target/mobench)" + )] output_dir: Option, }, /// Package XCUITest runner for BrowserStack testing. @@ -266,7 +363,10 @@ enum Command { PackageXcuitest { #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] scheme: String, - #[arg(long, help = "Output directory for mobile artifacts (default: target/mobench)")] + #[arg( + long, + help = "Output directory for mobile artifacts (default: target/mobench)" + )] output_dir: Option, }, /// List all discovered benchmark functions (Phase 1 MVP). @@ -289,7 +389,10 @@ enum Command { smoke_test: bool, #[arg(long, help = "Function name to verify/smoke test")] function: Option, - #[arg(long, help = "Output directory for mobile artifacts (default: target/mobench)")] + #[arg( + long, + help = "Output directory for mobile artifacts (default: target/mobench)" + )] output_dir: Option, }, /// Display summary statistics from a benchmark report JSON file. @@ -313,6 +416,8 @@ enum Command { /// mobench devices --json # Output as JSON /// mobench devices --validate "Google Pixel 7-13.0" # Validate a device spec Devices { + #[command(subcommand)] + command: Option, #[arg(long, value_enum, help = "Filter by platform (android or ios)")] platform: Option, #[arg(long, help = "Output as JSON")] @@ -320,6 +425,16 @@ enum Command { #[arg(long, help = "Validate device specs against available devices")] validate: Vec, }, + /// Fixture lifecycle helpers for reproducible CI setup. + Fixture { + #[command(subcommand)] + command: FixtureCommand, + }, + /// Reporting helpers for CI summaries and PR comments. + Report { + #[command(subcommand)] + command: ReportCommand, + }, /// Check prerequisites for building mobile artifacts. /// /// Validates that all required tools and configurations are in place @@ -343,6 +458,227 @@ enum Command { }, } +#[derive(Subcommand, Debug)] +enum CiCommand { + /// Generate GitHub Actions workflow + local action wrapper. + Init { + #[arg( + long, + default_value = ".github/workflows/mobile-bench.yml", + help = "Path to write the workflow file" + )] + workflow: PathBuf, + #[arg( + long, + default_value = ".github/actions/mobench", + help = "Directory to write the local GitHub Action" + )] + action_dir: PathBuf, + }, + /// Run a full CI benchmark flow with stable output contract. + Run(CiRunArgs), +} + +#[derive(Subcommand, Debug)] +enum DevicesCommand { + /// Resolve devices from a matrix deterministically for CI usage. + Resolve { + #[arg(long, value_enum)] + platform: DevicePlatform, + #[arg(long, help = "Device profile/tag to resolve (defaults to `default`)")] + profile: Option, + #[arg( + long, + help = "Path to run config file (optional source for matrix/tags)" + )] + config: Option, + #[arg(long, help = "Path to device matrix YAML file")] + device_matrix: Option, + #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] + format: CheckOutputFormat, + }, +} + +#[derive(Subcommand, Debug)] +enum ConfigCommand { + /// Validate bench-config.toml and referenced matrix/settings. + Validate { + #[arg(long, default_value = "bench-config.toml")] + config: PathBuf, + #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] + format: CheckOutputFormat, + }, +} + +#[derive(Subcommand, Debug)] +enum FixtureCommand { + /// Create starter fixture files for CI runs. + Init { + #[arg(long, default_value = "bench-config.toml")] + config: PathBuf, + #[arg(long, default_value = "device-matrix.yaml")] + device_matrix: PathBuf, + #[arg(long, help = "Overwrite existing fixture files")] + force: bool, + }, + /// Build fixture artifacts using existing build commands. + Build { + #[arg(long, value_enum, default_value_t = SdkTarget::Both)] + target: SdkTarget, + #[arg(long, help = "Build in release mode")] + release: bool, + #[arg(long, help = "Output directory for mobile artifacts")] + output_dir: Option, + #[arg(long, help = "Path to benchmark crate")] + crate_path: Option, + #[arg(long, help = "Show simplified step-by-step progress output")] + progress: bool, + }, + /// Verify fixture files and optional profile filtering. + Verify { + #[arg(long, default_value = "bench-config.toml")] + config: PathBuf, + #[arg(long)] + device_matrix: Option, + #[arg(long, value_enum, default_value_t = SdkTarget::Both)] + target: SdkTarget, + #[arg(long, help = "Device profile/tag to verify")] + profile: Option, + #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] + format: CheckOutputFormat, + }, + /// Compute deterministic fixture cache key from config/toolchain inputs. + CacheKey { + #[arg(long, default_value = "bench-config.toml")] + config: PathBuf, + #[arg(long)] + device_matrix: Option, + #[arg(long, value_enum, default_value_t = SdkTarget::Both)] + target: SdkTarget, + #[arg(long, help = "Device profile/tag for keying")] + profile: Option, + #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] + format: CheckOutputFormat, + }, +} + +#[derive(Subcommand, Debug)] +enum ReportCommand { + /// Generate markdown summary from standardized output JSON. + Summarize { + #[arg(long, default_value = "target/mobench/ci/summary.json")] + summary: PathBuf, + #[arg(long, help = "Write markdown output to file")] + output: Option, + }, + /// Generate/publish sticky GitHub PR comment from summary output. + Github { + #[arg( + long, + help = "Pull request number (auto-detected from GITHUB_REF if omitted)" + )] + pr: Option, + #[arg(long, default_value = "target/mobench/ci/summary.json")] + summary: PathBuf, + #[arg(long, default_value = "")] + marker: String, + #[arg(long, help = "Publish via GitHub API using GITHUB_TOKEN")] + publish: bool, + #[arg(long, help = "Write generated comment body to file")] + output: Option, + }, +} + +#[derive(Args, Debug, Clone)] +struct CiRunArgs { + #[arg(long, value_enum)] + target: CiTarget, + #[arg(long, help = "Fully-qualified Rust function to benchmark")] + function: String, + #[arg(long, default_value_t = 100)] + iterations: u32, + #[arg(long, default_value_t = 10)] + warmup: u32, + #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] + devices: Vec, + #[arg(long, help = "Device matrix YAML file to load device names from")] + device_matrix: Option, + #[arg( + long, + value_delimiter = ',', + help = "Device tags to select from the device matrix (comma-separated or repeatable)" + )] + device_tags: Vec, + #[arg(long, help = "Optional path to config file")] + config: Option, + #[arg(long, help = "Baseline summary source (path|url|artifact:)")] + baseline: Option, + #[arg( + long, + default_value_t = 5.0, + help = "Regression threshold percentage when comparing to baseline" + )] + regression_threshold_pct: f64, + #[arg(long, help = "Write JUnit XML report to the given path")] + junit: Option, + #[arg(long, help = "Skip mobile builds and only run the host harness")] + local_only: bool, + #[arg( + long, + help = "Build in release mode (recommended for BrowserStack to reduce APK size and upload time)" + )] + release: bool, + #[arg( + long, + help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" + )] + ios_app: Option, + #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] + ios_test_suite: Option, + #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] + fetch: bool, + #[arg(long, default_value = "target/browserstack")] + fetch_output_dir: PathBuf, + #[arg(long, default_value_t = 5)] + fetch_poll_interval_secs: u64, + #[arg(long, default_value_t = 300)] + fetch_timeout_secs: u64, + #[arg(long, help = "Show simplified step-by-step progress output")] + progress: bool, + #[arg( + long, + default_value = "target/mobench/ci", + help = "Output directory for CI contract files" + )] + output_dir: PathBuf, + #[arg(long, help = "Metadata: user or actor that requested the run")] + requested_by: Option, + #[arg(long, help = "Metadata: pull request number")] + pr_number: Option, + #[arg(long, help = "Metadata: original command requested by the caller")] + request_command: Option, + #[arg(long, help = "Metadata: git ref/sha for this mobench invocation")] + mobench_ref: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum CiTarget { + Android, + Ios, + Both, +} + +impl CiTarget { + fn targets(self) -> &'static [MobileTarget] { + match self { + CiTarget::Android => &[MobileTarget::Android], + CiTarget::Ios => &[MobileTarget::Ios], + CiTarget::Both => &[MobileTarget::Android, MobileTarget::Ios], + } + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] #[clap(rename_all = "lowercase")] enum DevicePlatform { @@ -365,13 +701,35 @@ enum CheckOutputFormat { Json, } +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ContractErrorCategory { + Config, + Preflight, + Provider, + Build, + Benchmark, +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] -enum MobileTarget { +/// Mobile platform target for build/run operations. +pub enum MobileTarget { + /// Android platform. Android, + /// iOS platform. Ios, } +impl MobileTarget { + fn as_str(self) -> &'static str { + match self { + Self::Android => "android", + Self::Ios => "ios", + } + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] #[clap(rename_all = "lowercase")] enum SdkTarget { @@ -543,9 +901,15 @@ pub fn run() -> Result<()> { iterations, warmup, devices, + device_matrix, + device_tags, config, output, summary_csv, + ci, + baseline, + regression_threshold_pct, + junit, local_only, release, ios_app, @@ -563,6 +927,8 @@ pub fn run() -> Result<()> { warmup, devices, config.as_deref(), + device_matrix.as_deref(), + device_tags, ios_app, ios_test_suite, local_only, @@ -611,7 +977,10 @@ pub fn run() -> Result<()> { spec.devices.len() ); } - println!(" All {} device(s) validated successfully.", validation.valid.len()); + println!( + " All {} device(s) validated successfully.", + validation.valid.len() + ); } } @@ -623,7 +992,10 @@ pub fn run() -> Result<()> { println!(" Function: {}", spec.function); println!(" Iterations: {}", spec.iterations); println!(" Warmup: {}", spec.warmup); - println!(" Profile: {}", if release { "release" } else { "debug" }); + println!( + " Profile: {}", + if release { "release" } else { "debug" } + ); if !spec.devices.is_empty() { println!(" Devices: {}", spec.devices.join(", ")); } else { @@ -636,12 +1008,21 @@ pub fn run() -> Result<()> { println!(" Build output: {}", output_dir.display()); match spec.target { MobileTarget::Android => { - println!(" Android APK: {}/android/app/build/outputs/apk/", output_dir.display()); - println!(" bench_spec.json: {}/android/app/src/main/assets/", output_dir.display()); + println!( + " Android APK: {}/android/app/build/outputs/apk/", + output_dir.display() + ); + println!( + " bench_spec.json: {}/android/app/src/main/assets/", + output_dir.display() + ); } MobileTarget::Ios => { println!(" iOS xcframework: {}/ios/", output_dir.display()); - println!(" bench_spec.json: {}/ios/BenchRunner/BenchRunner/Resources/", output_dir.display()); + println!( + " bench_spec.json: {}/ios/BenchRunner/BenchRunner/Resources/", + output_dir.display() + ); if let Some(ref xcui) = spec.ios_xcuitest { println!(" iOS App IPA: {}", xcui.app.display()); println!(" XCUITest Runner: {}", xcui.test_suite.display()); @@ -873,9 +1254,78 @@ pub fn run() -> Result<()> { println!("No BrowserStack run to fetch (devices not provided?)"); } + let mut baseline_compare_path = None; + let mut baseline_snapshot_path = None; + if let Some(baseline_source) = baseline.as_deref() { + let resolved_baseline = resolve_baseline_source(baseline_source)?; + if paths_point_to_same_file(&resolved_baseline, &summary_paths.json)? { + if !resolved_baseline.exists() { + bail!( + "config_error: baseline source `{}` resolves to output path {}; provide an existing baseline file or a different path", + baseline_source, + summary_paths.json.display() + ); + } + let snapshot = snapshot_baseline_for_compare(&resolved_baseline)?; + baseline_snapshot_path = Some(snapshot.clone()); + baseline_compare_path = Some(snapshot); + } else { + baseline_compare_path = Some(resolved_baseline); + } + } + run_summary.summary = build_summary(&run_summary)?; write_summary(&run_summary, &summary_paths, summary_csv)?; + let mut compare_report = None; + let mut regression_findings: Vec = Vec::new(); + if let Some(baseline_path) = baseline_compare_path.as_deref() { + let report = compare_summaries(&baseline_path, &summary_paths.json)?; + regression_findings = detect_regressions(&report, regression_threshold_pct); + compare_report = Some(report); + } + if let Some(snapshot_path) = baseline_snapshot_path { + if let Err(err) = fs::remove_file(&snapshot_path) { + eprintln!( + "Warning: failed to remove baseline snapshot {}: {err}", + snapshot_path.display() + ); + } + } + if let Some(report) = &compare_report { + inject_compare_into_summary( + &summary_paths.json, + report, + regression_threshold_pct, + baseline.as_deref(), + )?; + } + + if ci { + if let Err(err) = append_github_step_summary_from_path(&summary_paths.markdown) { + eprintln!("Warning: failed to publish job summary: {err}"); + } + if let Some(report) = &compare_report { + let compare_markdown = render_compare_markdown(report); + if let Ok(summary_path) = env::var("GITHUB_STEP_SUMMARY") { + if let Err(err) = + append_github_step_summary(&compare_markdown, &summary_path) + { + eprintln!("Warning: failed to append comparison report: {err}"); + } + } + } + } else if let Some(report) = &compare_report { + println!( + "{compare_markdown}", + compare_markdown = render_compare_markdown(report) + ); + } + + if let Some(junit_path) = junit.as_deref() { + write_junit_report(junit_path, &run_summary.summary, ®ression_findings)?; + } + // Print clear completion summary println!(); println!("\u{2713} Benchmark complete!"); @@ -887,16 +1337,68 @@ pub fn run() -> Result<()> { println!(" * {} (spreadsheet)", summary_paths.csv.display()); } println!(); - println!("View results: cat {} | jq '.summary'", summary_paths.json.display()); + println!( + "View results: cat {} | jq '.summary'", + summary_paths.json.display() + ); + + if !regression_findings.is_empty() { + eprintln!(); + eprintln!( + "Detected {} performance regression(s) above {:.2}% threshold.", + regression_findings.len(), + regression_threshold_pct + ); + for finding in ®ression_findings { + eprintln!( + " - {} :: {} ({}) {:+.2}%", + finding.device, finding.function, finding.metric, finding.delta_pct + ); + } + std::process::exit(EXIT_REGRESSION); + } } Command::Init { output, target } => { - write_config_template(&output, target)?; + write_config_template(&output, target, cli.yes)?; println!("Wrote starter config to {:?}", output); } Command::Plan { output } => { - write_device_matrix_template(&output)?; + write_device_matrix_template(&output, cli.yes)?; println!("Wrote sample device matrix to {:?}", output); } + Command::Config { command } => match command { + ConfigCommand::Validate { config, format } => { + cmd_config_validate(&config, format)?; + } + }, + Command::Doctor { + target, + config, + device_matrix, + device_tags, + browserstack, + format, + } => { + cmd_doctor( + target, + config.as_deref(), + device_matrix.as_deref(), + device_tags, + browserstack, + format, + )?; + } + Command::Ci { command } => match command { + CiCommand::Init { + workflow, + action_dir, + } => { + cmd_ci_init(&workflow, &action_dir, cli.yes)?; + } + CiCommand::Run(args) => { + cmd_ci_run(args)?; + } + }, Command::Fetch { target, build_id, @@ -947,9 +1449,21 @@ pub fn run() -> Result<()> { crate_path, progress, } => { - cmd_build(target, release, output_dir, crate_path, cli.dry_run, cli.verbose, progress)?; + cmd_build( + target, + release, + output_dir, + crate_path, + cli.dry_run, + cli.verbose, + progress, + )?; } - Command::PackageIpa { scheme, method, output_dir } => { + Command::PackageIpa { + scheme, + method, + output_dir, + } => { cmd_package_ipa(&scheme, method, output_dir)?; } Command::PackageXcuitest { scheme, output_dir } => { @@ -966,18 +1480,93 @@ pub fn run() -> Result<()> { function, output_dir, } => { - cmd_verify(target, spec_path, check_artifacts, smoke_test, function, output_dir)?; + cmd_verify( + target, + spec_path, + check_artifacts, + smoke_test, + function, + output_dir, + )?; } Command::Summary { report, format } => { cmd_summary(&report, format)?; } Command::Devices { + command, platform, json, validate, - } => { - cmd_devices(platform, json, validate)?; - } + } => match command { + Some(DevicesCommand::Resolve { + platform, + profile, + config, + device_matrix, + format, + }) => { + cmd_devices_resolve( + platform, + profile, + config.as_deref(), + device_matrix.as_deref(), + format, + )?; + } + None => { + cmd_devices(platform, json, validate)?; + } + }, + Command::Fixture { command } => match command { + FixtureCommand::Init { + config, + device_matrix, + force, + } => { + cmd_fixture_init(&config, &device_matrix, force)?; + } + FixtureCommand::Build { + target, + release, + output_dir, + crate_path, + progress, + } => { + cmd_fixture_build(target, release, output_dir, crate_path, progress)?; + } + FixtureCommand::Verify { + config, + device_matrix, + target, + profile, + format, + } => { + cmd_fixture_verify(&config, device_matrix.as_deref(), target, profile, format)?; + } + FixtureCommand::CacheKey { + config, + device_matrix, + target, + profile, + format, + } => { + cmd_fixture_cache_key(&config, device_matrix.as_deref(), target, profile, format)?; + } + }, + Command::Report { command } => match command { + ReportCommand::Summarize { summary, output } => { + cmd_report_summarize(&summary, output.as_deref())?; + } + ReportCommand::Github { + pr, + summary, + marker, + publish, + output, + } => { + cmd_report_github(pr, &summary, &marker, publish, output.as_deref())?; + } + }, Command::Check { target, format } => { cmd_check(target, format)?; } @@ -986,8 +1575,8 @@ pub fn run() -> Result<()> { Ok(()) } -fn write_config_template(path: &Path, target: MobileTarget) -> Result<()> { - ensure_can_write(path)?; +fn write_config_template(path: &Path, target: MobileTarget, overwrite: bool) -> Result<()> { + ensure_can_write(path, overwrite)?; let ios_xcuitest = if target == MobileTarget::Ios { Some(IosXcuitestArtifacts { @@ -1017,8 +1606,8 @@ fn write_config_template(path: &Path, target: MobileTarget) -> Result<()> { write_file(path, contents.as_bytes()) } -fn write_device_matrix_template(path: &Path) -> Result<()> { - ensure_can_write(path)?; +fn write_device_matrix_template(path: &Path, overwrite: bool) -> Result<()> { + ensure_can_write(path, overwrite)?; let matrix = DeviceMatrix { devices: vec![ @@ -1041,42 +1630,559 @@ fn write_device_matrix_template(path: &Path) -> Result<()> { write_file(path, contents.as_bytes()) } -fn fetch_browserstack_artifacts( - client: &BrowserStackClient, - target: MobileTarget, - build_id: &str, - output_root: &Path, - wait: bool, - poll_interval_secs: u64, - timeout_secs: u64, -) -> Result<()> { - fs::create_dir_all(output_root) - .with_context(|| format!("creating output dir {:?}", output_root))?; +const CI_WORKFLOW_TEMPLATE: &str = include_str!("../templates/ci/mobile-bench.yml"); +const CI_ACTION_TEMPLATE: &str = include_str!("../templates/ci/action.yml"); +const CI_ACTION_README_TEMPLATE: &str = include_str!("../templates/ci/action.README.md"); - let base = browserstack_base_path(target); - let build_path = format!("{base}/builds/{build_id}"); - let sessions_path = format!("{base}/builds/{build_id}/sessions"); +#[derive(Debug, Serialize)] +struct CiContractMetadata { + requested_by: String, + #[serde(skip_serializing_if = "Option::is_none")] + pr_number: Option, + request_command: String, + #[serde(skip_serializing_if = "Option::is_none")] + mobench_ref: Option, + mobench_version: String, +} - if wait { - wait_for_build(client, &build_path, poll_interval_secs, timeout_secs)?; - } +#[derive(Debug, Clone, Serialize, Deserialize)] +/// Device input sources used by [`RunRequest`]. +pub struct DeviceSelection { + /// Explicit device names/specs to run against. + pub devices: Vec, + /// Optional path to a device matrix YAML file. + pub device_matrix: Option, + /// Optional tag filters applied to the device matrix. + pub device_tags: Vec, +} - let build_json = client.get_json(&build_path)?; - write_json(output_root.join("build.json"), &build_json)?; +#[derive(Debug, Clone, Serialize, Deserialize)] +/// Programmatic request payload for running a mobench benchmark flow. +pub struct RunRequest { + /// Mobile platform target (`android` or `ios`). + pub target: MobileTarget, + /// Fully-qualified benchmark function name. + pub function: String, + /// Number of benchmark iterations. + pub iterations: u32, + /// Number of warmup iterations. + pub warmup: u32, + /// Device selection inputs. + pub device_selection: DeviceSelection, + /// Optional run configuration file (`bench-config.toml`). + pub config: Option, + /// Optional baseline source (`path|url|artifact:`). + pub baseline: Option, + /// Regression threshold percentage used for baseline comparison. + pub regression_threshold_pct: f64, + /// Optional JUnit XML output path. + pub junit: Option, + /// When true, skip mobile builds and run local harness only. + pub local_only: bool, + /// Build in release mode. + pub release: bool, + /// Optional iOS app bundle for BrowserStack XCUITest. + pub ios_app: Option, + /// Optional iOS XCUITest suite package for BrowserStack. + pub ios_test_suite: Option, + /// Fetch BrowserStack artifacts after completion. + pub fetch: bool, + /// Output directory for fetched BrowserStack artifacts. + pub fetch_output_dir: PathBuf, + /// Poll interval (seconds) when fetching BrowserStack artifacts. + pub fetch_poll_interval_secs: u64, + /// Timeout (seconds) when fetching BrowserStack artifacts. + pub fetch_timeout_secs: u64, + /// Enable progress-oriented CLI output. + pub progress: bool, + /// Output directory for CI contract files. + pub output_dir: PathBuf, +} - let mut session_ids = extract_session_ids(&build_json); - if session_ids.is_empty() { - match client.get_json(&sessions_path) { - Ok(value) => { - write_json(output_root.join("sessions.json"), &value)?; - session_ids = extract_session_ids(&value); - } - Err(err) => { - let msg = shorten_html_error(&err.to_string()); - println!("Sessions endpoint unavailable; falling back to build.json: {msg}"); - } - } - } +#[derive(Debug, Clone, Serialize, Deserialize)] +/// Standardized output file locations produced by a run. +pub struct Report { + /// Path to JSON summary output. + pub summary_json: PathBuf, + /// Path to Markdown summary output. + pub summary_md: PathBuf, + /// Path to CSV summary output. + pub results_csv: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +/// Result of a programmatic mobench run request. +pub struct RunResult { + /// Platform target executed for this run. + pub target: MobileTarget, + /// Generated report file paths. + pub report: Report, + /// Exit code from underlying `mobench run` command. + pub exit_code: i32, + /// True when regression threshold was exceeded (exit code 2). + pub regression_detected: bool, +} + +/// Executes a [`RunRequest`] by invoking the current `mobench` binary and normalizing outputs. +/// +/// This function always writes/normalizes CI output file names in `request.output_dir`: +/// - `summary.json` +/// - `summary.md` +/// - `results.csv` +/// +/// Returns [`RunResult`] containing file paths and process exit semantics. +pub fn run_request(request: &RunRequest) -> Result { + fs::create_dir_all(&request.output_dir) + .with_context(|| format!("creating output dir {}", request.output_dir.display()))?; + + let summary_json = request.output_dir.join("summary.json"); + let summary_md = request.output_dir.join("summary.md"); + let summary_csv = request.output_dir.join("summary.csv"); + let results_csv = request.output_dir.join("results.csv"); + + let mut cmd = std::process::Command::new( + env::current_exe().context("resolving current mobench executable")?, + ); + cmd.arg("run") + .arg("--target") + .arg(request.target.as_str()) + .arg("--function") + .arg(&request.function) + .arg("--iterations") + .arg(request.iterations.to_string()) + .arg("--warmup") + .arg(request.warmup.to_string()) + .arg("--ci") + .arg("--summary-csv") + .arg("--output") + .arg(&summary_json) + .arg("--fetch-output-dir") + .arg(&request.fetch_output_dir) + .arg("--fetch-poll-interval-secs") + .arg(request.fetch_poll_interval_secs.to_string()) + .arg("--fetch-timeout-secs") + .arg(request.fetch_timeout_secs.to_string()) + .arg("--regression-threshold-pct") + .arg(request.regression_threshold_pct.to_string()); + + for device in &request.device_selection.devices { + cmd.arg("--devices").arg(device); + } + if let Some(path) = &request.device_selection.device_matrix { + cmd.arg("--device-matrix").arg(path); + } + for tag in &request.device_selection.device_tags { + cmd.arg("--device-tags").arg(tag); + } + if let Some(path) = &request.config { + cmd.arg("--config").arg(path); + } + if let Some(path) = &request.baseline { + cmd.arg("--baseline").arg(path); + } + if let Some(path) = &request.junit { + cmd.arg("--junit").arg(path); + } + if request.local_only { + cmd.arg("--local-only"); + } + if request.release { + cmd.arg("--release"); + } + if let Some(path) = &request.ios_app { + cmd.arg("--ios-app").arg(path); + } + if let Some(path) = &request.ios_test_suite { + cmd.arg("--ios-test-suite").arg(path); + } + if request.fetch { + cmd.arg("--fetch"); + } + if request.progress { + cmd.arg("--progress"); + } + + let status = cmd.status().with_context(|| { + format!( + "running `cargo mobench run` for target {}", + request.target.as_str() + ) + })?; + let exit_code = status.code().unwrap_or(1); + if !status.success() && status.code().is_none() { + bail!("`cargo mobench run` terminated unexpectedly"); + } + + if !summary_json.exists() { + bail!( + "expected CI JSON output at {}", + summary_json.to_string_lossy() + ); + } + if !summary_md.exists() { + bail!( + "expected CI markdown output at {}", + summary_md.to_string_lossy() + ); + } + if !summary_csv.exists() { + bail!( + "expected CI CSV output at {}", + summary_csv.to_string_lossy() + ); + } + if results_csv.exists() { + fs::remove_file(&results_csv) + .with_context(|| format!("removing existing {}", results_csv.display()))?; + } + fs::rename(&summary_csv, &results_csv).with_context(|| { + format!( + "renaming {} to {}", + summary_csv.display(), + results_csv.display() + ) + })?; + + Ok(RunResult { + target: request.target, + report: Report { + summary_json, + summary_md, + results_csv, + }, + exit_code, + regression_detected: exit_code == EXIT_REGRESSION, + }) +} + +fn cmd_ci_run(args: CiRunArgs) -> Result<()> { + fs::create_dir_all(&args.output_dir) + .with_context(|| format!("creating ci output dir {}", args.output_dir.display()))?; + let metadata = ci_metadata_from_args(&args); + let targets = args.target.targets(); + + if targets.len() == 1 { + let target = targets[0]; + let exit_code = cmd_ci_run_single(&args, target, &args.output_dir, &metadata)?; + + let summary_json = args.output_dir.join("summary.json"); + let summary_md = args.output_dir.join("summary.md"); + let results_csv = args.output_dir.join("results.csv"); + println!("CI outputs ready:"); + println!(" - {}", summary_json.display()); + println!(" - {}", summary_md.display()); + println!(" - {}", results_csv.display()); + + if exit_code == EXIT_REGRESSION { + std::process::exit(EXIT_REGRESSION); + } + if exit_code != 0 { + std::process::exit(exit_code); + } + return Ok(()); + } + + let mut regression_detected = false; + let mut merged_summaries: BTreeMap = BTreeMap::new(); + let mut merged_markdown_sections = Vec::new(); + let mut merged_csv_rows = Vec::new(); + let mut merged_header: Option = None; + let mut target_outputs = BTreeMap::new(); + + for target in targets { + let target_value = *target; + let target_dir = args.output_dir.join(target_value.as_str()); + fs::create_dir_all(&target_dir) + .with_context(|| format!("creating target output dir {}", target_dir.display()))?; + let exit_code = cmd_ci_run_single(&args, target_value, &target_dir, &metadata)?; + if exit_code == EXIT_REGRESSION { + regression_detected = true; + } else if exit_code != 0 { + std::process::exit(exit_code); + } + + let summary_json = target_dir.join("summary.json"); + let summary_md = target_dir.join("summary.md"); + let results_csv = target_dir.join("results.csv"); + + let summary_text = fs::read_to_string(&summary_json) + .with_context(|| format!("reading {}", summary_json.display()))?; + let summary_value: Value = serde_json::from_str(&summary_text) + .with_context(|| format!("parsing {}", summary_json.display()))?; + merged_summaries.insert(target_value.as_str().to_string(), summary_value); + target_outputs.insert( + target_value.as_str().to_string(), + json!({ + "summary_json": summary_json.display().to_string(), + "summary_md": summary_md.display().to_string(), + "results_csv": results_csv.display().to_string(), + }), + ); + + let markdown = fs::read_to_string(&summary_md) + .with_context(|| format!("reading {}", summary_md.display()))?; + merged_markdown_sections.push(format!("## {}\n\n{}", target_value.as_str(), markdown)); + + let csv = fs::read_to_string(&results_csv) + .with_context(|| format!("reading {}", results_csv.display()))?; + let mut lines = csv.lines(); + if let Some(header) = lines.next() + && merged_header.is_none() + { + merged_header = Some(format!("target,{header}")); + } + for line in lines { + if line.trim().is_empty() { + continue; + } + merged_csv_rows.push(format!("{},{}", target_value.as_str(), line)); + } + } + + let root_summary_json = args.output_dir.join("summary.json"); + let root_summary_md = args.output_dir.join("summary.md"); + let root_results_csv = args.output_dir.join("results.csv"); + + let merged_markdown = merged_markdown_sections.join("\n\n"); + write_file(&root_summary_md, merged_markdown.as_bytes())?; + + let mut merged_csv = String::new(); + if let Some(header) = merged_header { + merged_csv.push_str(&header); + merged_csv.push('\n'); + } + for row in merged_csv_rows { + merged_csv.push_str(&row); + merged_csv.push('\n'); + } + write_file(&root_results_csv, merged_csv.as_bytes())?; + + let root_ci_value = json!({ + "metadata": metadata, + "outputs": { + "summary_json": root_summary_json.display().to_string(), + "summary_md": root_summary_md.display().to_string(), + "results_csv": root_results_csv.display().to_string(), + }, + "targets": target_outputs + }); + let merged_summary = json!({ + "targets": merged_summaries, + "ci": root_ci_value + }); + write_file( + &root_summary_json, + serde_json::to_string_pretty(&merged_summary)?.as_bytes(), + )?; + + println!("CI outputs ready:"); + println!(" - {}", root_summary_json.display()); + println!(" - {}", root_summary_md.display()); + println!(" - {}", root_results_csv.display()); + + if regression_detected { + std::process::exit(EXIT_REGRESSION); + } + Ok(()) +} + +fn ci_metadata_from_args(args: &CiRunArgs) -> CiContractMetadata { + CiContractMetadata { + requested_by: args + .requested_by + .clone() + .or_else(|| ci_env(&["MOBENCH_REQUESTED_BY", "GITHUB_ACTOR"])) + .unwrap_or_else(|| "unknown".to_string()), + pr_number: args.pr_number.clone().or_else(|| { + ci_env(&[ + "MOBENCH_PR_NUMBER", + "PR_NUMBER", + "GITHUB_PR_NUMBER", + "GITHUB_PULL_REQUEST_NUMBER", + ]) + .or_else(infer_pr_number_from_github_ref) + }), + request_command: args.request_command.clone().unwrap_or_else(|| { + let argv: Vec = env::args().collect(); + if argv.is_empty() { + "cargo mobench ci run".to_string() + } else { + argv.join(" ") + } + }), + mobench_ref: args + .mobench_ref + .clone() + .or_else(|| ci_env(&["MOBENCH_REF", "GITHUB_SHA", "GITHUB_REF"])), + mobench_version: env!("CARGO_PKG_VERSION").to_string(), + } +} + +fn cmd_ci_run_single( + args: &CiRunArgs, + target: MobileTarget, + output_dir: &Path, + metadata: &CiContractMetadata, +) -> Result { + let default_baseline_path = previous_baseline_path(output_dir); + let baseline_source = args.baseline.clone().or_else(|| { + if default_baseline_path.exists() { + Some(default_baseline_path.display().to_string()) + } else { + None + } + }); + + let result = run_request(&RunRequest { + target, + function: args.function.clone(), + iterations: args.iterations, + warmup: args.warmup, + device_selection: DeviceSelection { + devices: args.devices.clone(), + device_matrix: args.device_matrix.clone(), + device_tags: args.device_tags.clone(), + }, + config: args.config.clone(), + baseline: baseline_source, + regression_threshold_pct: args.regression_threshold_pct, + junit: args.junit.clone(), + local_only: args.local_only, + release: args.release, + ios_app: args.ios_app.clone(), + ios_test_suite: args.ios_test_suite.clone(), + fetch: args.fetch, + fetch_output_dir: args.fetch_output_dir.clone(), + fetch_poll_interval_secs: args.fetch_poll_interval_secs, + fetch_timeout_secs: args.fetch_timeout_secs, + progress: args.progress, + output_dir: output_dir.to_path_buf(), + })?; + + let summary_json = result.report.summary_json; + let summary_md = result.report.summary_md; + let results_csv = result.report.results_csv; + + let summary_text = fs::read_to_string(&summary_json) + .with_context(|| format!("reading {}", summary_json.display()))?; + let mut summary_value: Value = serde_json::from_str(&summary_text) + .with_context(|| format!("parsing {}", summary_json.display()))?; + let ci_value = json!({ + "metadata": metadata, + "outputs": { + "summary_json": summary_json.display().to_string(), + "summary_md": summary_md.display().to_string(), + "results_csv": results_csv.display().to_string(), + }, + "target": target.as_str() + }); + if let Some(obj) = summary_value.as_object_mut() { + obj.insert("ci".to_string(), ci_value); + } else { + summary_value = json!({ + "run_summary": summary_value, + "ci": ci_value + }); + } + let rendered = serde_json::to_string_pretty(&summary_value)?; + write_file(&summary_json, rendered.as_bytes())?; + fs::copy(&summary_json, &default_baseline_path).with_context(|| { + format!( + "writing previous baseline snapshot to {}", + default_baseline_path.display() + ) + })?; + + Ok(result.exit_code) +} + +fn previous_baseline_path(output_dir: &Path) -> PathBuf { + output_dir.join(".previous-summary.json") +} + +fn ci_env(keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + env::var(key).ok().and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) + }) +} + +fn infer_pr_number_from_github_ref() -> Option { + let github_ref = env::var("GITHUB_REF").ok()?; + parse_pr_number_from_ref(&github_ref) +} + +fn parse_pr_number_from_ref(github_ref: &str) -> Option { + let parts: Vec<&str> = github_ref.split('/').collect(); + if parts.len() >= 4 && parts[0] == "refs" && parts[1] == "pull" { + let pr = parts[2].trim(); + if !pr.is_empty() { + return Some(pr.to_string()); + } + } + None +} + +fn cmd_ci_init(workflow_path: &Path, action_dir: &Path, overwrite: bool) -> Result<()> { + let action_yaml = action_dir.join("action.yml"); + let action_readme = action_dir.join("README.md"); + + ensure_can_write(workflow_path, overwrite)?; + ensure_can_write(&action_yaml, overwrite)?; + ensure_can_write(&action_readme, overwrite)?; + + write_file(workflow_path, CI_WORKFLOW_TEMPLATE.as_bytes())?; + write_file(&action_yaml, CI_ACTION_TEMPLATE.as_bytes())?; + write_file(&action_readme, CI_ACTION_README_TEMPLATE.as_bytes())?; + + println!("Wrote workflow to {}", workflow_path.display()); + println!("Wrote GitHub Action to {}", action_yaml.display()); + println!("Wrote GitHub Action README to {}", action_readme.display()); + Ok(()) +} + +fn fetch_browserstack_artifacts( + client: &BrowserStackClient, + target: MobileTarget, + build_id: &str, + output_root: &Path, + wait: bool, + poll_interval_secs: u64, + timeout_secs: u64, +) -> Result<()> { + fs::create_dir_all(output_root) + .with_context(|| format!("creating output dir {:?}", output_root))?; + + let base = browserstack_base_path(target); + let build_path = format!("{base}/builds/{build_id}"); + let sessions_path = format!("{base}/builds/{build_id}/sessions"); + + if wait { + wait_for_build(client, &build_path, poll_interval_secs, timeout_secs)?; + } + + let build_json = client.get_json(&build_path)?; + write_json(output_root.join("build.json"), &build_json)?; + + let mut session_ids = extract_session_ids(&build_json); + if session_ids.is_empty() { + match client.get_json(&sessions_path) { + Ok(value) => { + write_json(output_root.join("sessions.json"), &value)?; + session_ids = extract_session_ids(&value); + } + Err(err) => { + let msg = shorten_html_error(&err.to_string()); + println!("Sessions endpoint unavailable; falling back to build.json: {msg}"); + } + } + } if session_ids.is_empty() { println!("No sessions found for build {}", build_id); @@ -1412,6 +2518,8 @@ fn resolve_run_spec( warmup: u32, devices: Vec, config: Option<&Path>, + device_matrix: Option<&Path>, + device_tags: Vec, ios_app: Option, ios_test_suite: Option, local_only: bool, @@ -1419,8 +2527,14 @@ fn resolve_run_spec( ) -> Result { if let Some(cfg_path) = config { let cfg = load_config(cfg_path)?; - let matrix = load_device_matrix(&cfg.device_matrix)?; - let device_names = match &cfg.device_tags { + let matrix_path = device_matrix.unwrap_or(cfg.device_matrix.as_path()); + let matrix = load_device_matrix(matrix_path)?; + let resolved_tags = if !device_tags.is_empty() { + Some(device_tags) + } else { + cfg.device_tags.clone() + }; + let device_names = match resolved_tags.as_ref() { Some(tags) if !tags.is_empty() => filter_devices_by_tags(matrix.devices, tags)?, _ => matrix.devices.into_iter().map(|d| d.name).collect(), }; @@ -1436,21 +2550,49 @@ fn resolve_run_spec( } if function.trim().is_empty() { - bail!("function must not be empty; pass --function or set function in the config file"); + bail!( + "function must not be empty; pass --function or set function in the config file" + ); } + if device_matrix.is_some() && !devices.is_empty() { + bail!("--device-matrix cannot be combined with --devices; choose one source for devices"); + } + if device_matrix.is_none() && !device_tags.is_empty() { + bail!("--device-tags requires --device-matrix or a config file with device tags"); + } + + let resolved_devices = if !devices.is_empty() { + devices + } else if let Some(matrix_path) = device_matrix { + let matrix = load_device_matrix(matrix_path)?; + if device_tags.is_empty() { + matrix.devices.into_iter().map(|d| d.name).collect() + } else { + filter_devices_by_tags(matrix.devices, &device_tags)? + } + } else { + Vec::new() + }; + let ios_xcuitest = match (ios_app, ios_test_suite) { (Some(app), Some(test_suite)) => Some(IosXcuitestArtifacts { app, test_suite }), (None, None) => None, - _ => bail!("both --ios-app and --ios-test-suite must be provided together; omit both to let mobench package iOS artifacts when running against devices"), + _ => bail!( + "both --ios-app and --ios-test-suite must be provided together; omit both to let mobench package iOS artifacts when running against devices" + ), }; let ios_xcuitest = if target == MobileTarget::Ios && !local_only - && !devices.is_empty() + && !resolved_devices.is_empty() && ios_xcuitest.is_none() { - Some(package_ios_xcuitest_artifacts(release)?) + println!("📦 Auto-packaging iOS artifacts for BrowserStack..."); + let artifacts = package_ios_xcuitest_artifacts(release)?; + println!(" ✓ IPA: {}", artifacts.app.display()); + println!(" ✓ XCUITest: {}", artifacts.test_suite.display()); + Some(artifacts) } else { ios_xcuitest }; @@ -1460,7 +2602,7 @@ fn resolve_run_spec( function, iterations, warmup, - devices, + devices: resolved_devices, browserstack: None, ios_xcuitest, }) @@ -1660,7 +2802,10 @@ fn validate_artifacts_for_browserstack( missing.push(("iOS app IPA".to_string(), artifacts.app.clone())); } if !artifacts.test_suite.exists() { - missing.push(("iOS XCUITest runner".to_string(), artifacts.test_suite.clone())); + missing.push(( + "iOS XCUITest runner".to_string(), + artifacts.test_suite.clone(), + )); } } } @@ -1677,6 +2822,91 @@ fn validate_artifacts_for_browserstack( Ok(()) } +/// Extracted benchmark result for a single device. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedBenchmarkResult { + /// Device name. + pub device: String, + /// Benchmark function name. + pub function: String, + /// Mean execution time in nanoseconds. + pub mean_ns: u64, + /// Number of samples collected. + pub sample_count: usize, + /// Standard deviation in nanoseconds (if calculable). + pub std_dev_ns: Option, + /// Minimum sample value in nanoseconds. + pub min_ns: Option, + /// Maximum sample value in nanoseconds. + pub max_ns: Option, +} + +/// Extract a unified summary from per-device benchmark results. +/// +/// This function takes the raw benchmark results from BrowserStack and produces +/// a unified summary that's easier to work with programmatically. +pub fn extract_benchmark_summary( + results: &HashMap>, +) -> Vec { + let mut extracted = Vec::new(); + + for (device, benchmarks) in results { + for benchmark in benchmarks { + let function = benchmark + .get("function") + .and_then(|f| f.as_str()) + .unwrap_or("unknown") + .to_string(); + + let mean_ns = benchmark + .get("mean_ns") + .and_then(|m| m.as_u64()) + .unwrap_or(0); + + let samples: Vec = benchmark + .get("samples") + .and_then(|s| s.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|s| s.get("duration_ns").and_then(|d| d.as_u64())) + .collect() + }) + .unwrap_or_default(); + + let sample_count = samples.len(); + let min_ns = samples.iter().copied().min(); + let max_ns = samples.iter().copied().max(); + + let std_dev_ns = if sample_count > 1 { + let mean = mean_ns as f64; + let variance: f64 = samples + .iter() + .map(|&s| { + let diff = s as f64 - mean; + diff * diff + }) + .sum::() + / (sample_count - 1) as f64; + Some(variance.sqrt() as u64) + } else { + None + }; + + extracted.push(ExtractedBenchmarkResult { + device: device.clone(), + function, + mean_ns, + sample_count, + std_dev_ns, + min_ns, + max_ns, + }); + } + } + + extracted +} + fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path, test_apk: &Path) -> Result { // Validate artifacts exist before attempting upload validate_artifacts_for_browserstack(MobileTarget::Android, Some(apk), Some(test_apk), None)?; @@ -1708,7 +2938,10 @@ fn trigger_browserstack_espresso(spec: &RunSpec, apk: &Path, test_apk: &Path) -> println!("BrowserStack build started!"); println!(" Build ID: {}", run.build_id); println!(" Devices: {}", spec.devices.join(", ")); - println!(" Dashboard: https://app-automate.browserstack.com/dashboard/v2/builds/{}", run.build_id); + println!( + " Dashboard: https://app-automate.browserstack.com/dashboard/v2/builds/{}", + run.build_id + ); println!(); println!("Waiting for results..."); @@ -1747,7 +2980,10 @@ fn trigger_browserstack_xcuitest( println!("BrowserStack build started!"); println!(" Build ID: {}", run.build_id); println!(" Devices: {}", spec.devices.join(", ")); - println!(" Dashboard: https://app-automate.browserstack.com/dashboard/v2/builds/{}", run.build_id); + println!( + " Dashboard: https://app-automate.browserstack.com/dashboard/v2/builds/{}", + run.build_id + ); println!(); println!("Waiting for results..."); @@ -1799,7 +3035,8 @@ fn resolve_browserstack_credentials( let missing_access_key = access_key.as_deref().map(str::is_empty).unwrap_or(true); if missing_username || missing_access_key { - let error_msg = browserstack::format_credentials_error(missing_username, missing_access_key); + let error_msg = + browserstack::format_credentials_error(missing_username, missing_access_key); bail!("{}", error_msg); } @@ -1878,7 +3115,10 @@ fn validate_benchmark_function(project_root: &Path, function_name: &str) -> Resu // Also check without crate prefix (in case user specified just the function name) let simple_name = function_name.split("::").last().unwrap_or(function_name); - if benchmarks.iter().any(|b| b.ends_with(&format!("::{}", simple_name))) { + if benchmarks + .iter() + .any(|b| b.ends_with(&format!("::{}", simple_name))) + { found_function = true; break; } @@ -1888,7 +3128,10 @@ fn validate_benchmark_function(project_root: &Path, function_name: &str) -> Resu if found_any_benchmarks && !found_function { // We found benchmarks but not the one requested - this is likely an error println!("=== Warning ==="); - println!(" Benchmark function '{}' was not found in the source code.", function_name); + println!( + " Benchmark function '{}' was not found in the source code.", + function_name + ); println!(" Available benchmarks:"); for dir in &search_dirs { if !dir.join("Cargo.toml").exists() { @@ -1907,7 +3150,10 @@ fn validate_benchmark_function(project_root: &Path, function_name: &str) -> Resu } else if !found_any_benchmarks { // No benchmarks found at all - might be using direct dispatch println!("=== Note ==="); - println!(" Could not validate benchmark function '{}' (no #[benchmark] functions found).", function_name); + println!( + " Could not validate benchmark function '{}' (no #[benchmark] functions found).", + function_name + ); println!(" This is normal for projects using direct FFI dispatch (like sample-fns)."); println!(); } else { @@ -1944,12 +3190,16 @@ fn persist_mobile_spec(spec: &RunSpec, release: bool) -> Result<()> { // This ensures the requested benchmark function is always used, even when // the app is run via BrowserStack where file paths are different. let mobench_output_dir = root.join("target/mobench"); - let apps_exist = mobench_output_dir.join("android").exists() || mobench_output_dir.join("ios").exists(); + let apps_exist = + mobench_output_dir.join("android").exists() || mobench_output_dir.join("ios").exists(); if let Err(e) = embed_spec_into_apps(&mobench_output_dir, spec) { // Only warn if the apps don't exist yet - they'll be created during build if apps_exist { - println!("Warning: Failed to embed bench spec into app bundles: {}", e); + println!( + "Warning: Failed to embed bench spec into app bundles: {}", + e + ); } } else if apps_exist { println!("Embedded bench_spec.json in mobile app bundles"); @@ -1964,7 +3214,10 @@ fn persist_mobile_spec(spec: &RunSpec, release: bool) -> Result<()> { if let Err(e) = embed_meta_into_apps(&mobench_output_dir, spec, target_str, profile) { if apps_exist { - println!("Warning: Failed to embed bench meta into app bundles: {}", e); + println!( + "Warning: Failed to embed bench meta into app bundles: {}", + e + ); } } else if apps_exist { println!("Embedded bench_meta.json with build metadata"); @@ -1985,7 +3238,12 @@ fn embed_spec_into_apps(output_dir: &Path, spec: &RunSpec) -> Result<()> { } /// Embeds build metadata (bench_meta.json) into Android assets and iOS bundle resources. -fn embed_meta_into_apps(output_dir: &Path, spec: &RunSpec, target: &str, profile: &str) -> Result<()> { +fn embed_meta_into_apps( + output_dir: &Path, + spec: &RunSpec, + target: &str, + profile: &str, +) -> Result<()> { let embedded_spec = mobench_sdk::builders::EmbeddedBenchSpec { function: spec.function.clone(), iterations: spec.iterations, @@ -2005,7 +3263,7 @@ struct SummaryPaths { fn resolve_summary_paths(output: Option<&Path>) -> Result { let json = output .map(ToOwned::to_owned) - .unwrap_or_else(|| PathBuf::from("run-summary.json")); + .unwrap_or_else(|| PathBuf::from("target/mobench/results.json")); let markdown = json.with_extension("md"); let csv = json.with_extension("csv"); Ok(SummaryPaths { @@ -2112,6 +3370,153 @@ fn write_summary(summary: &RunSummary, paths: &SummaryPaths, summary_csv: bool) Ok(()) } +const EXIT_REGRESSION: i32 = 2; + +fn append_github_step_summary_from_path(path: &Path) -> Result<()> { + let Ok(summary_path) = env::var("GITHUB_STEP_SUMMARY") else { + return Ok(()); + }; + let contents = + fs::read_to_string(path).with_context(|| format!("reading summary markdown {:?}", path))?; + append_github_step_summary(&contents, &summary_path) +} + +fn append_github_step_summary(contents: &str, summary_path: &str) -> Result<()> { + ensure_parent_dir(Path::new(summary_path))?; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(summary_path) + .with_context(|| format!("opening GitHub step summary at {}", summary_path))?; + file.write_all(contents.as_bytes())?; + file.write_all(b"\n")?; + Ok(()) +} + +#[derive(Debug, Clone)] +struct RegressionFinding { + device: String, + function: String, + metric: String, + delta_pct: f64, +} + +fn detect_regressions(report: &CompareReport, threshold_pct: f64) -> Vec { + let mut findings = Vec::new(); + for row in &report.rows { + if let Some(delta) = row.median_delta_pct { + if delta > threshold_pct { + findings.push(RegressionFinding { + device: row.device.clone(), + function: row.function.clone(), + metric: "median".to_string(), + delta_pct: delta, + }); + } + } + if let Some(delta) = row.p95_delta_pct { + if delta > threshold_pct { + findings.push(RegressionFinding { + device: row.device.clone(), + function: row.function.clone(), + metric: "p95".to_string(), + delta_pct: delta, + }); + } + } + } + findings +} + +fn render_junit_report(summary: &SummaryReport, regressions: &[RegressionFinding]) -> String { + let mut output = String::new(); + let mut failures_by_case: HashMap<(String, String), Vec<&RegressionFinding>> = HashMap::new(); + for finding in regressions { + failures_by_case + .entry((finding.device.clone(), finding.function.clone())) + .or_default() + .push(finding); + } + + let mut total_tests = 0; + let mut total_failures = 0; + + for device in &summary.device_summaries { + total_tests += device.benchmarks.len(); + for bench in &device.benchmarks { + if failures_by_case.contains_key(&(device.device.clone(), bench.function.clone())) { + total_failures += 1; + } + } + } + + let _ = writeln!(output, r#""#); + let _ = writeln!( + output, + r#""#, + total_tests, total_failures + ); + + for device in &summary.device_summaries { + for bench in &device.benchmarks { + let case_name = format!("{}::{}", device.device, bench.function); + let time_secs = bench + .median_ns + .map(|ns| ns as f64 / 1_000_000_000.0) + .unwrap_or(0.0); + let _ = writeln!( + output, + r#" "#, + escape_xml(&case_name), + escape_xml(&device.device), + time_secs + ); + if let Some(findings) = + failures_by_case.get(&(device.device.clone(), bench.function.clone())) + { + let mut details = String::new(); + for finding in findings { + let _ = writeln!( + details, + "{} regression: {:+.2}%", + finding.metric, finding.delta_pct + ); + } + let _ = writeln!( + output, + r#" {}"#, + escape_xml(details.trim()) + ); + } + let _ = writeln!(output, " "); + } + } + + let _ = writeln!(output, ""); + output +} + +fn write_junit_report( + path: &Path, + summary: &SummaryReport, + regressions: &[RegressionFinding], +) -> Result<()> { + let report = render_junit_report(summary, regressions); + ensure_parent_dir(path)?; + write_file(path, report.as_bytes())?; + println!("Wrote JUnit report to {:?}", path); + Ok(()) +} + +fn escape_xml(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + /// Print a final summary with all artifact correlation information (C3). #[allow(dead_code)] fn print_run_completion_summary( @@ -2240,23 +3645,25 @@ fn ensure_parent_dir(path: &Path) -> Result<()> { Ok(()) } -#[derive(Debug)] +#[derive(Debug, Serialize)] struct CompareReport { baseline: PathBuf, candidate: PathBuf, rows: Vec, } -#[derive(Debug)] +#[derive(Debug, Serialize)] struct CompareRow { device: String, function: String, baseline_median_ns: Option, candidate_median_ns: Option, median_delta_pct: Option, + median_label: String, baseline_p95_ns: Option, candidate_p95_ns: Option, p95_delta_pct: Option, + p95_label: String, } fn compare_summaries(baseline: &Path, candidate: &Path) -> Result { @@ -2302,9 +3709,11 @@ fn compare_summaries(baseline: &Path, candidate: &Path) -> Result baseline_median_ns: baseline_median, candidate_median_ns: candidate_median, median_delta_pct: median_delta, + median_label: delta_label(median_delta, 0.0).to_string(), baseline_p95_ns: baseline_p95, candidate_p95_ns: candidate_p95, p95_delta_pct: p95_delta, + p95_label: delta_label(p95_delta, 0.0).to_string(), }); } } @@ -2342,50 +3751,344 @@ fn percent_delta(baseline: Option, candidate: Option) -> Option { Some(((candidate - baseline) / baseline) * 100.0) } -fn write_compare_report(report: &CompareReport, output: Option<&Path>) -> Result<()> { - let markdown = render_compare_markdown(report); - if let Some(path) = output { - ensure_parent_dir(path)?; - write_file(path, markdown.as_bytes())?; - println!("Wrote compare report to {:?}", path); - } else { - println!("{markdown}"); +fn delta_label(delta: Option, threshold_pct: f64) -> &'static str { + match delta { + Some(value) if value >= threshold_pct => "regressed", + Some(value) if value <= -threshold_pct => "improved", + Some(_) => "neutral", + None => "neutral", } - Ok(()) } -fn render_compare_markdown(report: &CompareReport) -> String { - let mut output = String::new(); - let _ = writeln!(output, "# Benchmark Comparison"); - let _ = writeln!(output); +fn resolve_baseline_source(source: &str) -> Result { + let trimmed = source.trim(); + if trimmed.is_empty() { + bail!("config_error: baseline source is empty"); + } + + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + let root = repo_root()?; + let baseline_dir = root.join("target/mobench/baselines"); + fs::create_dir_all(&baseline_dir)?; + let mut hasher = Sha256::new(); + hasher.update(trimmed.as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + let baseline_path = baseline_dir.join(format!("{hash}.json")); + + let response = reqwest::blocking::Client::new() + .get(trimmed) + .send() + .with_context(|| format!("provider_error: downloading baseline URL {trimmed}"))? + .error_for_status() + .with_context(|| format!("provider_error: HTTP error for baseline URL {trimmed}"))?; + let bytes = response + .bytes() + .context("provider_error: reading baseline body")?; + write_file(&baseline_path, bytes.as_ref())?; + return Ok(baseline_path); + } + + if let Some(artifact_ref) = trimmed.strip_prefix("artifact:") { + return resolve_artifact_baseline(artifact_ref.trim()); + } + + Ok(PathBuf::from(trimmed)) +} + +fn normalized_path(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .context("resolving current directory for baseline path comparison")? + .join(path) + }; + Ok(fs::canonicalize(&absolute).unwrap_or(absolute)) +} + +fn paths_point_to_same_file(lhs: &Path, rhs: &Path) -> Result { + Ok(normalized_path(lhs)? == normalized_path(rhs)?) +} + +fn snapshot_baseline_for_compare(path: &Path) -> Result { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_nanos(); + let snapshot_path = env::temp_dir().join(format!("mobench-baseline-{stamp}.json")); + fs::copy(path, &snapshot_path).with_context(|| { + format!( + "copying baseline snapshot from {} to {}", + path.display(), + snapshot_path.display() + ) + })?; + Ok(snapshot_path) +} + +fn resolve_artifact_baseline(reference: &str) -> Result { + if reference.is_empty() { + bail!("config_error: baseline artifact reference is empty"); + } + let root = repo_root()?; + let mut candidates = vec![ + PathBuf::from(reference), + root.join(reference), + root.join("target/mobench/ci").join(reference), + ]; + let artifact_path = root.join("target/mobench/ci").join(reference); + if artifact_path.is_dir() { + candidates.push(artifact_path.join("summary.json")); + } + + for candidate in candidates { + if candidate.exists() { + return Ok(candidate); + } + } + + bail!( + "config_error: baseline artifact `{}` not found (tried path and target/mobench/ci)", + reference + ) +} + +fn inject_compare_into_summary( + summary_json: &Path, + report: &CompareReport, + threshold_pct: f64, + baseline_source: Option<&str>, +) -> Result<()> { + let summary_text = + fs::read_to_string(summary_json).with_context(|| format!("reading {:?}", summary_json))?; + let mut summary_value: Value = serde_json::from_str(&summary_text) + .with_context(|| format!("parsing {:?}", summary_json))?; + + let compare_value = json!({ + "baseline": report.baseline.display().to_string(), + "baseline_source": baseline_source, + "candidate": report.candidate.display().to_string(), + "threshold_pct": threshold_pct, + "rows": report.rows.iter().map(|row| json!({ + "device": row.device, + "function": row.function, + "baseline_median_ns": row.baseline_median_ns, + "candidate_median_ns": row.candidate_median_ns, + "median_delta_pct": row.median_delta_pct, + "median_label": delta_label(row.median_delta_pct, threshold_pct), + "baseline_p95_ns": row.baseline_p95_ns, + "candidate_p95_ns": row.candidate_p95_ns, + "p95_delta_pct": row.p95_delta_pct, + "p95_label": delta_label(row.p95_delta_pct, threshold_pct), + })).collect::>() + }); + + if let Some(obj) = summary_value.as_object_mut() { + obj.insert("comparison".to_string(), compare_value); + } + write_file( + summary_json, + serde_json::to_string_pretty(&summary_value)?.as_bytes(), + )?; + Ok(()) +} + +fn write_compare_report(report: &CompareReport, output: Option<&Path>) -> Result<()> { + let markdown = render_compare_markdown(report); + if let Some(path) = output { + ensure_parent_dir(path)?; + write_file(path, markdown.as_bytes())?; + println!("Wrote compare report to {:?}", path); + } else { + println!("{markdown}"); + } + Ok(()) +} + +fn render_compare_markdown(report: &CompareReport) -> String { + let mut output = String::new(); + let _ = writeln!(output, "# Benchmark Comparison"); + let _ = writeln!(output); let _ = writeln!(output, "- Baseline: {}", report.baseline.display()); let _ = writeln!(output, "- Candidate: {}", report.candidate.display()); let _ = writeln!(output); let _ = writeln!( output, - "| Device | Function | Median (base ms) | Median (cand ms) | Median Δ% | P95 (base ms) | P95 (cand ms) | P95 Δ% |" + "| Device | Function | Median (base ms) | Median (cand ms) | Median Δ% | Median Label | P95 (base ms) | P95 (cand ms) | P95 Δ% | P95 Label |" ); let _ = writeln!( output, - "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |" + "| --- | --- | ---: | ---: | ---: | --- | ---: | ---: | ---: | --- |" ); for row in &report.rows { let _ = writeln!( output, - "| {} | {} | {} | {} | {} | {} | {} | {} |", + "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", row.device, row.function, format_ms(row.baseline_median_ns), format_ms(row.candidate_median_ns), format_delta(row.median_delta_pct), + row.median_label, format_ms(row.baseline_p95_ns), format_ms(row.candidate_p95_ns), - format_delta(row.p95_delta_pct) + format_delta(row.p95_delta_pct), + row.p95_label ); } output } +#[derive(Debug, Deserialize)] +struct GitHubIssueComment { + id: u64, + body: String, +} + +fn cmd_report_summarize(summary_path: &Path, output: Option<&Path>) -> Result { + let contents = fs::read_to_string(summary_path) + .with_context(|| format!("reading summary file {}", summary_path.display()))?; + let value: Value = serde_json::from_str(&contents) + .with_context(|| format!("parsing summary file {}", summary_path.display()))?; + let markdown = render_summary_markdown_from_output(&value)?; + + if let Some(path) = output { + ensure_parent_dir(path)?; + write_file(path, markdown.as_bytes())?; + println!("Wrote report summary markdown to {}", path.display()); + } else { + println!("{markdown}"); + } + + Ok(markdown) +} + +fn cmd_report_github( + pr: Option, + summary_path: &Path, + marker: &str, + publish: bool, + output: Option<&Path>, +) -> Result<()> { + let contents = fs::read_to_string(summary_path) + .with_context(|| format!("reading summary file {}", summary_path.display()))?; + let value: Value = serde_json::from_str(&contents) + .with_context(|| format!("parsing summary file {}", summary_path.display()))?; + let markdown = render_summary_markdown_from_output(&value)?; + let comment_body = format!("{marker}\n\n{markdown}"); + + if let Some(path) = output { + ensure_parent_dir(path)?; + write_file(path, comment_body.as_bytes())?; + println!("Wrote GitHub report body to {}", path.display()); + } else if !publish { + println!("{comment_body}"); + } + + if publish { + let pr_number = pr + .or_else(|| ci_env(&["MOBENCH_PR_NUMBER", "PR_NUMBER"])) + .or_else(infer_pr_number_from_github_ref) + .ok_or_else(|| anyhow!("provider_error: missing PR number (`--pr` or GITHUB_REF)"))?; + upsert_github_pr_comment(&pr_number, marker, &comment_body)?; + println!("Published sticky PR comment for PR #{}", pr_number); + } + + Ok(()) +} + +fn render_summary_markdown_from_output(value: &Value) -> Result { + if let Some(summary) = value.get("summary") { + let parsed: SummaryReport = + serde_json::from_value(summary.clone()).context("parsing summary report")?; + return Ok(render_markdown_summary(&parsed)); + } + + if let Some(targets) = value.get("targets").and_then(|v| v.as_object()) { + let mut target_names: Vec = targets.keys().cloned().collect(); + target_names.sort(); + + let mut sections = Vec::new(); + for name in target_names { + let Some(entry) = targets.get(&name) else { + continue; + }; + let summary_value = entry + .get("summary") + .cloned() + .unwrap_or_else(|| entry.clone()); + let parsed: SummaryReport = + serde_json::from_value(summary_value).with_context(|| { + format!("parsing summary report for target `{name}` in merged output") + })?; + sections.push(format!("## {name}\n\n{}", render_markdown_summary(&parsed))); + } + if !sections.is_empty() { + return Ok(sections.join("\n\n")); + } + } + + let parsed: SummaryReport = + serde_json::from_value(value.clone()).context("parsing summary report")?; + Ok(render_markdown_summary(&parsed)) +} + +fn upsert_github_pr_comment(pr_number: &str, marker: &str, body: &str) -> Result<()> { + let token = + env::var("GITHUB_TOKEN").context("provider_error: GITHUB_TOKEN is required for publish")?; + let repository = env::var("GITHUB_REPOSITORY") + .context("provider_error: GITHUB_REPOSITORY is required for publish")?; + let comments_url = format!( + "https://api.github.com/repos/{}/issues/{}/comments", + repository, pr_number + ); + let client = reqwest::blocking::Client::builder() + .user_agent("mobench-report") + .build()?; + + let comments: Vec = client + .get(&comments_url) + .bearer_auth(&token) + .header("Accept", "application/vnd.github+json") + .send() + .context("provider_error: listing PR comments")? + .error_for_status() + .context("provider_error: failed to list PR comments")? + .json() + .context("provider_error: failed to parse PR comments")?; + + if let Some(existing) = comments + .into_iter() + .find(|comment| comment.body.contains(marker)) + { + let update_url = format!( + "https://api.github.com/repos/{}/issues/comments/{}", + repository, existing.id + ); + client + .patch(&update_url) + .bearer_auth(&token) + .header("Accept", "application/vnd.github+json") + .json(&json!({ "body": body })) + .send() + .context("provider_error: updating sticky PR comment")? + .error_for_status() + .context("provider_error: failed to update sticky PR comment")?; + } else { + client + .post(&comments_url) + .bearer_auth(&token) + .header("Accept", "application/vnd.github+json") + .json(&json!({ "body": body })) + .send() + .context("provider_error: creating sticky PR comment")? + .error_for_status() + .context("provider_error: failed to create sticky PR comment")?; + } + + Ok(()) +} + fn format_delta(value: Option) -> String { value .map(|delta| format!("{:+.2}%", delta)) @@ -2614,8 +4317,8 @@ fn run_android_build(_ndk_home: &str, release: bool) -> Result bool { .is_file() } -fn ensure_can_write(path: &Path) -> Result<()> { - if path.exists() { +fn ensure_can_write(path: &Path, overwrite: bool) -> Result<()> { + if path.exists() && !overwrite { bail!("refusing to overwrite existing file: {:?}", path); } if let Some(parent) = path.parent() @@ -2726,7 +4429,8 @@ fn cmd_build( let project_root = std::env::current_dir().context("Failed to get current directory")?; let crate_name = detect_bench_mobile_crate_name(&project_root) .unwrap_or_else(|_| "bench-mobile".to_string()); - let effective_output_dir = output_dir.or_else(|| config_resolver.output_dir().map(|p| p.to_path_buf())); + let effective_output_dir = + output_dir.or_else(|| config_resolver.output_dir().map(|p| p.to_path_buf())); let build_config = mobench_sdk::BuildConfig { target: target.into(), @@ -2760,10 +4464,9 @@ fn cmd_build( } SdkTarget::Ios => { println!("[1/3] Building Rust library..."); - let mut builder = - mobench_sdk::builders::IosBuilder::new(&project_root, crate_name) - .verbose(false) - .dry_run(dry_run); + let mut builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name) + .verbose(false) + .dry_run(dry_run); if let Some(ref dir) = effective_output_dir { builder = builder.output_dir(dir); } @@ -2844,7 +4547,8 @@ fn cmd_build( .unwrap_or_else(|_| "bench-mobile".to_string()); // Fallback for legacy layouts // CLI flags override config file values - let effective_output_dir = output_dir.or_else(|| config_resolver.output_dir().map(|p| p.to_path_buf())); + let effective_output_dir = + output_dir.or_else(|| config_resolver.output_dir().map(|p| p.to_path_buf())); if let Some(ref dir) = effective_output_dir { println!(" Output: {:?}", dir); @@ -2928,10 +4632,9 @@ fn cmd_build( // Build iOS println!("\nBuilding for iOS..."); println!(" Building Rust library for iOS targets..."); - let mut ios_builder = - mobench_sdk::builders::IosBuilder::new(&project_root, crate_name) - .verbose(verbose) - .dry_run(dry_run); + let mut ios_builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name) + .verbose(verbose) + .dry_run(dry_run); if let Some(ref dir) = effective_output_dir { ios_builder = ios_builder.output_dir(dir); } @@ -3065,7 +4768,10 @@ fn cmd_list() -> Result<()> { } println!(); println!("Usage:"); - println!(" cargo mobench run --target android --function {} --iterations 100", all_benchmarks.first().unwrap()); + println!( + " cargo mobench run --target android --function {} --iterations 100", + all_benchmarks.first().unwrap() + ); } Ok(()) @@ -3083,7 +4789,11 @@ fn get_crate_name_from_cargo_toml(cargo_toml: &Path) -> Result { } /// Package iOS app as IPA for distribution or testing -fn cmd_package_ipa(scheme: &str, method: IosSigningMethodArg, output_dir: Option) -> Result<()> { +fn cmd_package_ipa( + scheme: &str, + method: IosSigningMethodArg, + output_dir: Option, +) -> Result<()> { println!("Packaging iOS app as IPA..."); println!(" Scheme: {}", scheme); println!(" Method: {:?}", method); @@ -3095,7 +4805,8 @@ fn cmd_package_ipa(scheme: &str, method: IosSigningMethodArg, output_dir: Option let crate_name = detect_bench_mobile_crate_name(&project_root) .unwrap_or_else(|_| "bench-mobile".to_string()); - let mut builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + let mut builder = + mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); if let Some(ref dir) = output_dir { builder = builder.output_dir(dir); } @@ -3129,7 +4840,8 @@ fn cmd_package_xcuitest(scheme: &str, output_dir: Option) -> Result<()> let crate_name = detect_bench_mobile_crate_name(&project_root) .unwrap_or_else(|_| "bench-mobile".to_string()); - let mut builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); + let mut builder = + mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); if let Some(ref dir) = output_dir { builder = builder.output_dir(dir); } @@ -3171,7 +4883,9 @@ fn cmd_verify( println!("WARNING"); println!(" No benchmarks found in registry."); println!(" This may be expected if benchmarks are in a separate crate."); - println!(" Tip: Add #[benchmark] attribute to functions and ensure mobench-sdk is linked."); + println!( + " Tip: Add #[benchmark] attribute to functions and ensure mobench-sdk is linked." + ); warnings += 1; } else { println!("OK ({} benchmark(s) found)", benchmarks.len()); @@ -3201,7 +4915,9 @@ fn cmd_verify( } else { // Try default locations let project_root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let output_base = output_dir.clone().unwrap_or_else(|| project_root.join("target/mobench")); + let output_base = output_dir + .clone() + .unwrap_or_else(|| project_root.join("target/mobench")); let default_paths = [ output_base.join("android/app/src/main/assets/bench_spec.json"), output_base.join("ios/BenchRunner/BenchRunner/bench_spec.json"), @@ -3219,8 +4935,10 @@ fn cmd_verify( match validate_spec_file(path) { Ok(spec) => { println!(" {:?}", path); - println!(" Function: {}, Iterations: {}, Warmup: {}", - spec.name, spec.iterations, spec.warmup); + println!( + " Function: {}, Iterations: {}, Warmup: {}", + spec.name, spec.iterations, spec.warmup + ); } Err(e) => { println!(" {:?} - INVALID: {}", path, e); @@ -3240,7 +4958,9 @@ fn cmd_verify( print!(" [3/4] Checking build artifacts... "); if check_artifacts { let project_root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let output_base = output_dir.clone().unwrap_or_else(|| project_root.join("target/mobench")); + let output_base = output_dir + .clone() + .unwrap_or_else(|| project_root.join("target/mobench")); let mut artifacts_ok = true; let mut artifact_details = Vec::new(); @@ -3248,8 +4968,10 @@ fn cmd_verify( if let Some(ref t) = target { match t { SdkTarget::Android | SdkTarget::Both => { - let apk_path = output_base.join("android/app/build/outputs/apk/debug/app-debug.apk"); - let apk_release = output_base.join("android/app/build/outputs/apk/release/app-release-unsigned.apk"); + let apk_path = + output_base.join("android/app/build/outputs/apk/debug/app-debug.apk"); + let apk_release = output_base + .join("android/app/build/outputs/apk/release/app-release-unsigned.apk"); if apk_path.exists() { artifact_details.push(format!("Android APK (debug): {:?}", apk_path)); } else if apk_release.exists() { @@ -3308,7 +5030,8 @@ fn cmd_verify( if artifact_details.is_empty() { artifacts_ok = false; - artifact_details.push("No artifacts found. Run 'cargo mobench build' first.".to_string()); + artifact_details + .push("No artifacts found. Run 'cargo mobench build' first.".to_string()); } } @@ -3341,7 +5064,11 @@ fn cmd_verify( }; println!(" Function: {}", func); println!(" Samples: {}", samples); - println!(" Mean: {} ns ({:.3} ms)", mean_ns, mean_ns as f64 / 1_000_000.0); + println!( + " Mean: {} ns ({:.3} ms)", + mean_ns, + mean_ns as f64 / 1_000_000.0 + ); checks_passed += 1; } Err(e) => { @@ -3364,7 +5091,11 @@ fn cmd_verify( }; println!(" Function: {} (auto-selected)", func); println!(" Samples: {}", samples); - println!(" Mean: {} ns ({:.3} ms)", mean_ns, mean_ns as f64 / 1_000_000.0); + println!( + " Mean: {} ns ({:.3} ms)", + mean_ns, + mean_ns as f64 / 1_000_000.0 + ); checks_passed += 1; } Err(e) => { @@ -3375,7 +5106,9 @@ fn cmd_verify( } } else { println!("SKIPPED (no benchmark function available)"); - println!(" Tip: Use --function to specify a function, or add benchmarks with #[benchmark]"); + println!( + " Tip: Use --function to specify a function, or add benchmarks with #[benchmark]" + ); warnings += 1; } } else { @@ -3406,8 +5139,8 @@ fn cmd_verify( /// Handles both "name" and "function" field names for compatibility /// with different spec file formats. fn validate_spec_file(path: &Path) -> Result { - let contents = fs::read_to_string(path) - .with_context(|| format!("reading spec file {:?}", path))?; + let contents = + fs::read_to_string(path).with_context(|| format!("reading spec file {:?}", path))?; // Try parsing directly first (standard BenchSpec format with "name" field) if let Ok(spec) = serde_json::from_str::(&contents) { @@ -3423,8 +5156,8 @@ fn validate_spec_file(path: &Path) -> Result { // Fall back to generic Value parsing for "function" field format // (used by persist_mobile_spec and some older formats) - let value: Value = serde_json::from_str(&contents) - .with_context(|| format!("parsing spec file {:?}", path))?; + let value: Value = + serde_json::from_str(&contents).with_context(|| format!("parsing spec file {:?}", path))?; // Extract name from either "name" or "function" field let name = value @@ -3469,8 +5202,7 @@ fn run_verify_smoke_test(function: &str) -> Result { warmup: 1, }; - mobench_sdk::run_benchmark(spec) - .map_err(|e| anyhow!("smoke test failed: {}", e)) + mobench_sdk::run_benchmark(spec).map_err(|e| anyhow!("smoke test failed: {}", e)) } /// Display summary statistics from a benchmark report JSON file @@ -3520,23 +5252,41 @@ fn extract_summary_data(value: &Value) -> Result> { // Check if this is a RunSummary format (from `mobench run`) if value.get("summary").is_some() { let summary = &value["summary"]; - let function = summary.get("function").and_then(|f| f.as_str()).map(String::from); - let iterations = summary.get("iterations").and_then(|i| i.as_u64()).map(|i| i as u32); - let warmup = summary.get("warmup").and_then(|w| w.as_u64()).map(|w| w as u32); + let function = summary + .get("function") + .and_then(|f| f.as_str()) + .map(String::from); + let iterations = summary + .get("iterations") + .and_then(|i| i.as_u64()) + .map(|i| i as u32); + let warmup = summary + .get("warmup") + .and_then(|w| w.as_u64()) + .map(|w| w as u32); if let Some(device_summaries) = summary.get("device_summaries").and_then(|d| d.as_array()) { for device_summary in device_summaries { - let device = device_summary.get("device").and_then(|d| d.as_str()).map(String::from); + let device = device_summary + .get("device") + .and_then(|d| d.as_str()) + .map(String::from); - if let Some(benchmarks) = device_summary.get("benchmarks").and_then(|b| b.as_array()) { + if let Some(benchmarks) = + device_summary.get("benchmarks").and_then(|b| b.as_array()) + { for bench in benchmarks { - let bench_function = bench.get("function").and_then(|f| f.as_str()).map(String::from); + let bench_function = bench + .get("function") + .and_then(|f| f.as_str()) + .map(String::from); results.push(SummaryData { source_file: "RunSummary".to_string(), function: bench_function.or_else(|| function.clone()), device: device.clone(), os_version: None, // RunSummary doesn't include OS version directly - sample_count: bench.get("samples").and_then(|s| s.as_u64()).unwrap_or(0) as usize, + sample_count: bench.get("samples").and_then(|s| s.as_u64()).unwrap_or(0) + as usize, mean_ns: bench.get("mean_ns").and_then(|m| m.as_u64()), median_ns: bench.get("median_ns").and_then(|m| m.as_u64()), min_ns: bench.get("min_ns").and_then(|m| m.as_u64()), @@ -3567,8 +5317,14 @@ fn extract_summary_data(value: &Value) -> Result> { min_ns: stats.as_ref().map(|s| s.min_ns), max_ns: stats.as_ref().map(|s| s.max_ns), p95_ns: stats.as_ref().map(|s| s.p95_ns), - iterations: spec.get("iterations").and_then(|i| i.as_u64()).map(|i| i as u32), - warmup: spec.get("warmup").and_then(|w| w.as_u64()).map(|w| w as u32), + iterations: spec + .get("iterations") + .and_then(|i| i.as_u64()) + .map(|i| i as u32), + warmup: spec + .get("warmup") + .and_then(|w| w.as_u64()) + .map(|w| w as u32), }); } @@ -3582,11 +5338,20 @@ fn extract_summary_data(value: &Value) -> Result> { results.push(SummaryData { source_file: "BrowserStack".to_string(), - function: entry.get("function").and_then(|f| f.as_str()).map(String::from), + function: entry + .get("function") + .and_then(|f| f.as_str()) + .map(String::from), device: Some(device.clone()), - os_version: entry.get("os_version").and_then(|o| o.as_str()).map(String::from), + os_version: entry + .get("os_version") + .and_then(|o| o.as_str()) + .map(String::from), sample_count: samples.len(), - mean_ns: entry.get("mean_ns").and_then(|m| m.as_u64()).or_else(|| stats.as_ref().map(|s| s.mean_ns)), + mean_ns: entry + .get("mean_ns") + .and_then(|m| m.as_u64()) + .or_else(|| stats.as_ref().map(|s| s.mean_ns)), median_ns: stats.as_ref().map(|s| s.median_ns), min_ns: stats.as_ref().map(|s| s.min_ns), max_ns: stats.as_ref().map(|s| s.max_ns), @@ -3607,17 +5372,35 @@ fn extract_summary_data(value: &Value) -> Result> { results.push(SummaryData { source_file: "SessionReport".to_string(), - function: value.get("function").and_then(|f| f.as_str()).map(String::from), - device: value.get("device").and_then(|d| d.as_str()).map(String::from), - os_version: value.get("os_version").and_then(|o| o.as_str()).map(String::from), + function: value + .get("function") + .and_then(|f| f.as_str()) + .map(String::from), + device: value + .get("device") + .and_then(|d| d.as_str()) + .map(String::from), + os_version: value + .get("os_version") + .and_then(|o| o.as_str()) + .map(String::from), sample_count: samples.len(), - mean_ns: value.get("mean_ns").and_then(|m| m.as_u64()).or_else(|| stats.as_ref().map(|s| s.mean_ns)), + mean_ns: value + .get("mean_ns") + .and_then(|m| m.as_u64()) + .or_else(|| stats.as_ref().map(|s| s.mean_ns)), median_ns: stats.as_ref().map(|s| s.median_ns), min_ns: stats.as_ref().map(|s| s.min_ns), max_ns: stats.as_ref().map(|s| s.max_ns), p95_ns: stats.as_ref().map(|s| s.p95_ns), - iterations: value.get("iterations").and_then(|i| i.as_u64()).map(|i| i as u32), - warmup: value.get("warmup").and_then(|w| w.as_u64()).map(|w| w as u32), + iterations: value + .get("iterations") + .and_then(|i| i.as_u64()) + .map(|i| i as u32), + warmup: value + .get("warmup") + .and_then(|w| w.as_u64()) + .map(|w| w as u32), }); } @@ -3651,11 +5434,41 @@ fn print_summary_text(data: &[SummaryData]) { println!(); println!("Statistics (nanoseconds):"); - println!(" Mean: {}", entry.mean_ns.map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)).unwrap_or_else(|| "-".to_string())); - println!(" Median: {}", entry.median_ns.map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)).unwrap_or_else(|| "-".to_string())); - println!(" Min: {}", entry.min_ns.map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)).unwrap_or_else(|| "-".to_string())); - println!(" Max: {}", entry.max_ns.map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)).unwrap_or_else(|| "-".to_string())); - println!(" P95: {}", entry.p95_ns.map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)).unwrap_or_else(|| "-".to_string())); + println!( + " Mean: {}", + entry + .mean_ns + .map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)) + .unwrap_or_else(|| "-".to_string()) + ); + println!( + " Median: {}", + entry + .median_ns + .map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)) + .unwrap_or_else(|| "-".to_string()) + ); + println!( + " Min: {}", + entry + .min_ns + .map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)) + .unwrap_or_else(|| "-".to_string()) + ); + println!( + " Max: {}", + entry + .max_ns + .map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)) + .unwrap_or_else(|| "-".to_string()) + ); + println!( + " P95: {}", + entry + .p95_ns + .map(|v| format!("{} ({:.3} ms)", v, v as f64 / 1_000_000.0)) + .unwrap_or_else(|| "-".to_string()) + ); if entry.iterations.is_some() || entry.warmup.is_some() { println!(); @@ -3683,7 +5496,9 @@ fn print_summary_json(data: &[SummaryData]) -> Result<()> { /// Print summary in CSV format fn print_summary_csv(data: &[SummaryData]) { - println!("function,device,os_version,sample_count,mean_ns,median_ns,min_ns,max_ns,p95_ns,iterations,warmup"); + println!( + "function,device,os_version,sample_count,mean_ns,median_ns,min_ns,max_ns,p95_ns,iterations,warmup" + ); for entry in data { println!( "{},{},{},{},{},{},{},{},{},{},{}", @@ -3719,7 +5534,8 @@ fn cmd_devices( let missing_username = username.is_none() || username.as_deref() == Some(""); let missing_access_key = access_key.is_none() || access_key.as_deref() == Some(""); - let error_msg = browserstack::format_credentials_error(missing_username, missing_access_key); + let error_msg = + browserstack::format_credentials_error(missing_username, missing_access_key); bail!("{}", error_msg); } }; @@ -3858,126 +5674,906 @@ fn cmd_devices( Ok(()) } -/// Check prerequisites for building mobile artifacts. -/// -/// This validates that all required tools and configurations are in place -/// before attempting a build. -fn cmd_check(target: SdkTarget, format: CheckOutputFormat) -> Result<()> { - let mut checks: Vec = Vec::new(); - let mut issues: Vec = Vec::new(); +#[derive(Debug, Clone, Serialize)] +struct ResolvedMatrixDevice { + name: String, + os: String, + os_version: String, + identifier: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tags: Vec, +} - // Common checks for both platforms - checks.push(check_cargo()); - checks.push(check_rustup()); +fn cmd_devices_resolve( + platform: DevicePlatform, + profile: Option, + config_path: Option<&Path>, + device_matrix_path: Option<&Path>, + format: CheckOutputFormat, +) -> Result<()> { + let (matrix_path, config_tags) = resolve_matrix_for_cli(config_path, device_matrix_path) + .with_context( + || "config_error: unable to resolve device matrix source for `devices resolve`", + )?; + let matrix = load_device_matrix(&matrix_path).with_context(|| { + format!( + "config_error: failed to parse device matrix at {}", + matrix_path.display() + ) + })?; - match target { - SdkTarget::Android => { - println!("Checking prerequisites for Android...\n"); - checks.push(check_android_ndk_home()); - checks.push(check_cargo_ndk()); - checks.push(check_rust_target("aarch64-linux-android")); - checks.push(check_rust_target("armv7-linux-androideabi")); - checks.push(check_rust_target("x86_64-linux-android")); - checks.push(check_jdk()); - } - SdkTarget::Ios => { - println!("Checking prerequisites for iOS...\n"); - checks.push(check_xcode()); - checks.push(check_xcodegen()); - checks.push(check_rust_target("aarch64-apple-ios")); - checks.push(check_rust_target("aarch64-apple-ios-sim")); + let selected_tags = match profile.as_ref().map(|v| v.trim()).filter(|v| !v.is_empty()) { + Some(tag) => vec![tag.to_string()], + None => config_tags + .filter(|tags| !tags.is_empty()) + .unwrap_or_else(|| vec!["default".to_string()]), + }; + + let resolved = resolve_devices_from_matrix(matrix.devices, platform, &selected_tags)?; + + match format { + CheckOutputFormat::Text => { + for device in &resolved { + println!("{}", device.identifier); + } } - SdkTarget::Both => { - println!("Checking prerequisites for Android and iOS...\n"); - // Android - checks.push(check_android_ndk_home()); - checks.push(check_cargo_ndk()); - checks.push(check_rust_target("aarch64-linux-android")); - checks.push(check_rust_target("armv7-linux-androideabi")); - checks.push(check_rust_target("x86_64-linux-android")); - checks.push(check_jdk()); - // iOS - checks.push(check_xcode()); - checks.push(check_xcodegen()); - checks.push(check_rust_target("aarch64-apple-ios")); - checks.push(check_rust_target("aarch64-apple-ios-sim")); + CheckOutputFormat::Json => { + let output = json!({ + "platform": match platform { + DevicePlatform::Android => "android", + DevicePlatform::Ios => "ios", + }, + "profile_tags": selected_tags, + "device_matrix": matrix_path.display().to_string(), + "count": resolved.len(), + "devices": resolved, + }); + println!("{}", serde_json::to_string_pretty(&output)?); } } - // Collect issues - for check in &checks { - if !check.passed { - if let Some(ref fix) = check.fix_hint { - issues.push(fix.clone()); - } + Ok(()) +} + +fn resolve_matrix_for_cli( + config_path: Option<&Path>, + device_matrix_path: Option<&Path>, +) -> Result<(PathBuf, Option>)> { + let mut discovered_matrix = None; + let mut discovered_tags = None; + + if let Some(config_path) = config_path { + let cfg = load_config(config_path)?; + discovered_tags = cfg.device_tags.clone(); + discovered_matrix = Some(cfg.device_matrix); + } else if device_matrix_path.is_none() { + let default_config = PathBuf::from("bench-config.toml"); + if default_config.exists() + && let Ok(cfg) = load_config(&default_config) + { + discovered_tags = cfg.device_tags.clone(); + discovered_matrix = Some(cfg.device_matrix); } } - match format { - CheckOutputFormat::Text => print_check_results_text(&checks, &issues), - CheckOutputFormat::Json => print_check_results_json(&checks)?, - } + let matrix_path = device_matrix_path + .map(PathBuf::from) + .or(discovered_matrix) + .or_else(|| { + let fallback = PathBuf::from("device-matrix.yaml"); + if fallback.exists() { + Some(fallback) + } else { + None + } + }) + .ok_or_else(|| { + anyhow!("config_error: provide --device-matrix, or provide --config with device_matrix") + })?; - if issues.is_empty() { - Ok(()) - } else { - bail!("{} issue(s) found. Fix them and run 'cargo mobench check --target {:?}' again.", issues.len(), target) - } + Ok((matrix_path, discovered_tags)) } -#[derive(Debug, Clone, Serialize)] -struct PrereqCheck { - name: String, - passed: bool, - detail: Option, - fix_hint: Option, -} +fn resolve_devices_from_matrix( + devices: Vec, + platform: DevicePlatform, + tags: &[String], +) -> Result> { + let wanted: Vec = tags + .iter() + .map(|tag| tag.trim().to_lowercase()) + .filter(|tag| !tag.is_empty()) + .collect(); + let platform_name = match platform { + DevicePlatform::Android => "android", + DevicePlatform::Ios => "ios", + }; -fn print_check_results_text(checks: &[PrereqCheck], issues: &[String]) { - for check in checks { - let status = if check.passed { "\u{2713}" } else { "\u{2717}" }; - let detail = check.detail.as_deref().unwrap_or(""); - if detail.is_empty() { - println!("{} {}", status, check.name); - } else { - println!("{} {} ({})", status, check.name, detail); + let mut available_tags = BTreeSet::new(); + let mut resolved = Vec::new(); + + for device in devices { + if device.os.trim().to_lowercase() != platform_name { + continue; } + let normalized_tags: Vec = device + .tags + .clone() + .unwrap_or_default() + .into_iter() + .map(|tag| tag.trim().to_lowercase()) + .filter(|tag| !tag.is_empty()) + .collect(); + for tag in &normalized_tags { + available_tags.insert(tag.clone()); + } + let tag_match = wanted.is_empty() + || normalized_tags + .iter() + .any(|tag| wanted.iter().any(|wanted_tag| wanted_tag == tag)); + if !tag_match { + continue; + } + let identifier = format!("{}-{}", device.name, device.os_version); + resolved.push(ResolvedMatrixDevice { + name: device.name, + os: device.os, + os_version: device.os_version, + identifier, + tags: normalized_tags, + }); } - if !issues.is_empty() { - println!("\nTo fix:"); - for issue in issues { - println!(" * {}", issue); + resolved.sort_by(|a, b| { + a.identifier + .cmp(&b.identifier) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.os_version.cmp(&b.os_version)) + }); + + if resolved.is_empty() { + if available_tags.is_empty() { + bail!( + "config_error: no devices matched platform `{}` and tags [{}]; no tag metadata found in matrix", + platform_name, + wanted.join(", ") + ); } - println!(); - let failed_count = checks.iter().filter(|c| !c.passed).count(); - println!("{} issue(s) found.", failed_count); - } else { - println!("\nAll prerequisites satisfied!"); + bail!( + "config_error: no devices matched platform `{}` and tags [{}]. Available tags: {}", + platform_name, + wanted.join(", "), + available_tags.into_iter().collect::>().join(", ") + ); } + + Ok(resolved) } -fn print_check_results_json(checks: &[PrereqCheck]) -> Result<()> { - let output = json!({ - "checks": checks, - "all_passed": checks.iter().all(|c| c.passed), - "passed_count": checks.iter().filter(|c| c.passed).count(), - "failed_count": checks.iter().filter(|c| !c.passed).count(), - }); - println!("{}", serde_json::to_string_pretty(&output)?); +fn cmd_fixture_init(config_path: &Path, device_matrix_path: &Path, force: bool) -> Result<()> { + write_config_template(config_path, MobileTarget::Android, force)?; + write_device_matrix_template(device_matrix_path, force)?; + println!( + "Initialized fixture files:\n - {}\n - {}", + config_path.display(), + device_matrix_path.display() + ); Ok(()) } -fn check_cargo() -> PrereqCheck { - let result = std::process::Command::new("cargo") +fn cmd_fixture_build( + target: SdkTarget, + release: bool, + output_dir: Option, + crate_path: Option, + progress: bool, +) -> Result<()> { + match target { + SdkTarget::Android => cmd_build( + SdkTarget::Android, + release, + output_dir, + crate_path, + false, + false, + progress, + )?, + SdkTarget::Ios => { + cmd_build( + SdkTarget::Ios, + release, + output_dir.clone(), + crate_path, + false, + false, + progress, + )?; + cmd_package_ipa( + "BenchRunner", + IosSigningMethodArg::Adhoc, + output_dir.clone(), + )?; + cmd_package_xcuitest("BenchRunner", output_dir)?; + } + SdkTarget::Both => { + cmd_build( + SdkTarget::Android, + release, + output_dir.clone(), + crate_path.clone(), + false, + false, + progress, + )?; + cmd_build( + SdkTarget::Ios, + release, + output_dir.clone(), + crate_path, + false, + false, + progress, + )?; + cmd_package_ipa( + "BenchRunner", + IosSigningMethodArg::Adhoc, + output_dir.clone(), + )?; + cmd_package_xcuitest("BenchRunner", output_dir)?; + } + } + Ok(()) +} + +fn cmd_fixture_verify( + config_path: &Path, + device_matrix_override: Option<&Path>, + target: SdkTarget, + profile: Option, + format: CheckOutputFormat, +) -> Result<()> { + let mut checks = Vec::new(); + let mut cfg: Option = None; + match load_config(config_path) { + Ok(parsed) => { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: true, + detail: Some(config_path.display().to_string()), + fix_hint: None, + }); + cfg = Some(parsed); + } + Err(err) => { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!("Fix config at {}", config_path.display())), + }); + } + } + + let matrix_path = device_matrix_override + .map(PathBuf::from) + .or_else(|| cfg.as_ref().map(|c| c.device_matrix.clone())); + if let Some(matrix_path) = matrix_path.as_deref() { + match load_device_matrix(matrix_path) { + Ok(matrix) => { + let mut tags = profile + .as_ref() + .map(|tag| vec![tag.clone()]) + .or_else(|| cfg.as_ref().and_then(|c| c.device_tags.clone())) + .unwrap_or_else(|| vec!["default".to_string()]); + tags.retain(|tag| !tag.trim().is_empty()); + + let platforms = match target { + SdkTarget::Android => vec![DevicePlatform::Android], + SdkTarget::Ios => vec![DevicePlatform::Ios], + SdkTarget::Both => vec![DevicePlatform::Android, DevicePlatform::Ios], + }; + + let mut unresolved = Vec::new(); + for platform in platforms { + if let Err(err) = + resolve_devices_from_matrix(matrix.devices.clone(), platform, &tags) + { + unresolved.push(err.to_string()); + } + } + if unresolved.is_empty() { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: true, + detail: Some(format!( + "{} (tags: {})", + matrix_path.display(), + tags.join(", ") + )), + fix_hint: None, + }); + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some(unresolved.join("; ")), + fix_hint: Some(format!( + "Adjust tags/profile or matrix entries in {}", + matrix_path.display() + )), + }); + } + } + Err(err) => checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!( + "Fix or regenerate device matrix at {}", + matrix_path.display() + )), + }), + } + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some("missing device matrix path".to_string()), + fix_hint: Some( + "Provide --device-matrix or set device_matrix in bench-config.toml".to_string(), + ), + }); + } + + let cargo_lock_path = repo_root()?.join("Cargo.lock"); + checks.push(PrereqCheck { + name: "Cargo.lock".to_string(), + passed: cargo_lock_path.exists(), + detail: Some(cargo_lock_path.display().to_string()), + fix_hint: if cargo_lock_path.exists() { + None + } else { + Some("Run cargo generate-lockfile".to_string()) + }, + }); + + let issues = collect_issues(&checks); + match format { + CheckOutputFormat::Text => print_check_results_text(&checks, &issues), + CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?, + } + if issues.is_empty() { + Ok(()) + } else { + bail!( + "{} issue(s) found. Fix them and rerun `cargo mobench fixture verify`.", + issues.len() + ) + } +} + +fn cmd_fixture_cache_key( + config_path: &Path, + device_matrix_override: Option<&Path>, + target: SdkTarget, + profile: Option, + format: CheckOutputFormat, +) -> Result<()> { + let cfg = load_config(config_path) + .with_context(|| format!("config_error: failed to load {}", config_path.display()))?; + let matrix_path = device_matrix_override + .map(PathBuf::from) + .unwrap_or_else(|| cfg.device_matrix.clone()); + let matrix_bytes = fs::read(&matrix_path).with_context(|| { + format!( + "config_error: failed to read device matrix {}", + matrix_path.display() + ) + })?; + let config_bytes = fs::read(config_path) + .with_context(|| format!("config_error: failed to read {}", config_path.display()))?; + let cargo_lock_path = repo_root()?.join("Cargo.lock"); + let cargo_lock_bytes = if cargo_lock_path.exists() { + fs::read(&cargo_lock_path)? + } else { + Vec::new() + }; + + let rustc_version = command_version_line("rustc", &["--version"]).unwrap_or_default(); + let cargo_version = command_version_line("cargo", &["--version"]).unwrap_or_default(); + let selected_profile = profile + .or_else(|| { + cfg.device_tags + .clone() + .and_then(|mut tags| tags.drain(..1).next()) + }) + .unwrap_or_else(|| "default".to_string()); + + let mut hasher = Sha256::new(); + hasher.update(format!("mobench={}\n", env!("CARGO_PKG_VERSION")).as_bytes()); + hasher.update(format!("target={target:?}\n").as_bytes()); + hasher.update(format!("profile={selected_profile}\n").as_bytes()); + hasher.update(format!("rustc={rustc_version}\n").as_bytes()); + hasher.update(format!("cargo={cargo_version}\n").as_bytes()); + hasher.update(config_bytes); + hasher.update(matrix_bytes); + hasher.update(cargo_lock_bytes); + let digest = hasher.finalize(); + let cache_key = format!("mobench-fixture-{:x}", digest); + + match format { + CheckOutputFormat::Text => println!("{cache_key}"), + CheckOutputFormat::Json => { + let payload = json!({ + "cache_key": cache_key, + "target": format!("{target:?}").to_lowercase(), + "profile": selected_profile, + "config": config_path.display().to_string(), + "device_matrix": matrix_path.display().to_string(), + "rustc": rustc_version, + "cargo": cargo_version, + "mobench_version": env!("CARGO_PKG_VERSION"), + }); + println!("{}", serde_json::to_string_pretty(&payload)?); + } + } + Ok(()) +} + +fn command_version_line(cmd: &str, args: &[&str]) -> Option { + let output = std::process::Command::new(cmd).args(args).output().ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .next() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) +} + +/// Check prerequisites for building mobile artifacts. +/// +/// This validates that all required tools and configurations are in place +/// before attempting a build. +fn cmd_check(target: SdkTarget, format: CheckOutputFormat) -> Result<()> { + let checks = collect_prereq_checks(target); + let issues = collect_issues(&checks); + + match format { + CheckOutputFormat::Text => print_check_results_text(&checks, &issues), + CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?, + } + + if issues.is_empty() { + Ok(()) + } else { + bail!( + "{} issue(s) found. Fix them and run 'cargo mobench check --target {:?}' again.", + issues.len(), + target + ) + } +} + +fn cmd_config_validate(config_path: &Path, format: CheckOutputFormat) -> Result<()> { + let mut checks = Vec::new(); + let mut config: Option = None; + + match load_config(config_path) { + Ok(cfg) => { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: true, + detail: Some(config_path.display().to_string()), + fix_hint: None, + }); + config = Some(cfg); + } + Err(err) => { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!( + "Fix config file syntax/fields at {}", + config_path.display() + )), + }); + } + } + + if let Some(cfg) = &config { + match load_device_matrix(&cfg.device_matrix) { + Ok(matrix) => { + if let Some(tags) = cfg.device_tags.as_ref().filter(|tags| !tags.is_empty()) { + if let Err(err) = filter_devices_by_tags(matrix.devices, tags) { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!( + "Update tags in {} or adjust device_tags in config", + cfg.device_matrix.display() + )), + }); + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: true, + detail: Some(format!( + "{} (tags: {})", + cfg.device_matrix.display(), + tags.join(", ") + )), + fix_hint: None, + }); + } + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: true, + detail: Some(cfg.device_matrix.display().to_string()), + fix_hint: None, + }); + } + } + Err(err) => { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!( + "Fix or regenerate device matrix at {}", + cfg.device_matrix.display() + )), + }); + } + } + + match resolve_browserstack_credentials(Some(&cfg.browserstack)) { + Ok(creds) => checks.push(PrereqCheck { + name: "BrowserStack credentials".to_string(), + passed: true, + detail: Some(format!("user {}", creds.username)), + fix_hint: None, + }), + Err(err) => checks.push(PrereqCheck { + name: "BrowserStack credentials".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some("Set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY".to_string()), + }), + } + } + + let issues = collect_issues(&checks); + match format { + CheckOutputFormat::Text => print_check_results_text(&checks, &issues), + CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?, + } + + if issues.is_empty() { + Ok(()) + } else { + bail!( + "{} issue(s) found. Fix them and rerun 'cargo mobench config validate'.", + issues.len() + ) + } +} + +fn cmd_doctor( + target: SdkTarget, + config_path: Option<&Path>, + device_matrix_path: Option<&Path>, + device_tags: Vec, + browserstack: bool, + format: CheckOutputFormat, +) -> Result<()> { + let mut checks = collect_prereq_checks(target); + + let mut config: Option = None; + if let Some(path) = config_path { + match load_config(path) { + Ok(cfg) => { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: true, + detail: Some(path.display().to_string()), + fix_hint: None, + }); + config = Some(cfg); + } + Err(err) => { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!("Fix or regenerate config at {}", path.display())), + }); + } + } + } else { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: true, + detail: Some("skipped (no --config)".to_string()), + fix_hint: None, + }); + } + + let resolved_matrix_path = device_matrix_path + .map(PathBuf::from) + .or_else(|| config.as_ref().map(|cfg| cfg.device_matrix.clone())); + let resolved_tags = if !device_tags.is_empty() { + Some(device_tags) + } else { + config.as_ref().and_then(|cfg| cfg.device_tags.clone()) + }; + + if resolved_matrix_path.is_none() && resolved_tags.as_ref().is_some_and(|tags| !tags.is_empty()) + { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some("device tags provided without a matrix file".to_string()), + fix_hint: Some( + "Provide --device-matrix or set device_matrix in the config".to_string(), + ), + }); + } else if let Some(path) = resolved_matrix_path.as_deref() { + match load_device_matrix(path) { + Ok(matrix) => { + if let Some(tags) = resolved_tags.as_ref().filter(|tags| !tags.is_empty()) { + if let Err(err) = filter_devices_by_tags(matrix.devices, tags) { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!( + "Update tags in {} or adjust --device-tags", + path.display() + )), + }); + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: true, + detail: Some(format!("{} (tags: {})", path.display(), tags.join(", "))), + fix_hint: None, + }); + } + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: true, + detail: Some(path.display().to_string()), + fix_hint: None, + }); + } + } + Err(err) => checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!( + "Fix or regenerate device matrix at {}", + path.display() + )), + }), + } + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: true, + detail: Some("skipped (no --device-matrix)".to_string()), + fix_hint: None, + }); + } + + if browserstack { + let cfg_ref = config.as_ref().map(|cfg| &cfg.browserstack); + match resolve_browserstack_credentials(cfg_ref) { + Ok(creds) => checks.push(PrereqCheck { + name: "BrowserStack credentials".to_string(), + passed: true, + detail: Some(format!("user {}", creds.username)), + fix_hint: None, + }), + Err(err) => checks.push(PrereqCheck { + name: "BrowserStack credentials".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some("Set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY".to_string()), + }), + } + } else { + checks.push(PrereqCheck { + name: "BrowserStack credentials".to_string(), + passed: true, + detail: Some("skipped (--browserstack=false)".to_string()), + fix_hint: None, + }); + } + + let issues = collect_issues(&checks); + match format { + CheckOutputFormat::Text => print_check_results_text(&checks, &issues), + CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?, + } + + if issues.is_empty() { + Ok(()) + } else { + bail!( + "{} issue(s) found. Fix them and rerun 'cargo mobench doctor'.", + issues.len() + ) + } +} + +fn collect_prereq_checks(target: SdkTarget) -> Vec { + let mut checks: Vec = Vec::new(); + checks.push(check_cargo()); + checks.push(check_rustup()); + + match target { + SdkTarget::Android => { + println!("Checking prerequisites for Android...\n"); + checks.push(check_android_ndk_home()); + checks.push(check_cargo_ndk()); + checks.push(check_rust_target("aarch64-linux-android")); + checks.push(check_rust_target("armv7-linux-androideabi")); + checks.push(check_rust_target("x86_64-linux-android")); + checks.push(check_jdk()); + } + SdkTarget::Ios => { + println!("Checking prerequisites for iOS...\n"); + checks.push(check_xcode()); + checks.push(check_xcodegen()); + checks.push(check_rust_target("aarch64-apple-ios")); + checks.push(check_rust_target("aarch64-apple-ios-sim")); + checks.push(check_rust_target("x86_64-apple-ios")); + } + SdkTarget::Both => { + println!("Checking prerequisites for Android and iOS...\n"); + checks.push(check_android_ndk_home()); + checks.push(check_cargo_ndk()); + checks.push(check_rust_target("aarch64-linux-android")); + checks.push(check_rust_target("armv7-linux-androideabi")); + checks.push(check_rust_target("x86_64-linux-android")); + checks.push(check_jdk()); + checks.push(check_xcode()); + checks.push(check_xcodegen()); + checks.push(check_rust_target("aarch64-apple-ios")); + checks.push(check_rust_target("aarch64-apple-ios-sim")); + checks.push(check_rust_target("x86_64-apple-ios")); + } + } + + checks +} + +fn collect_issues(checks: &[PrereqCheck]) -> Vec { + let mut issues = Vec::new(); + for check in checks { + if !check.passed { + if let Some(ref fix) = check.fix_hint { + issues.push(ValidationIssue { + category: issue_category_for_check(check), + check: check.name.clone(), + detail: check.detail.clone(), + fix_hint: fix.clone(), + }); + } + } + } + issues +} + +fn issue_category_for_check(check: &PrereqCheck) -> ContractErrorCategory { + match check.name.as_str() { + "Run config" | "Device matrix" => ContractErrorCategory::Config, + "BrowserStack credentials" => ContractErrorCategory::Provider, + _ => ContractErrorCategory::Preflight, + } +} + +#[derive(Debug, Clone, Serialize)] +struct PrereqCheck { + name: String, + passed: bool, + detail: Option, + fix_hint: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct ValidationIssue { + category: ContractErrorCategory, + check: String, + detail: Option, + fix_hint: String, +} + +fn print_check_results_text(checks: &[PrereqCheck], issues: &[ValidationIssue]) { + for check in checks { + let status = if check.passed { "\u{2713}" } else { "\u{2717}" }; + let detail = check.detail.as_deref().unwrap_or(""); + let category = if check.passed { + None + } else { + Some(issue_category_for_check(check)) + }; + if detail.is_empty() { + if let Some(category) = category { + println!("{} {} [{}]", status, check.name, category_slug(category)); + } else { + println!("{} {}", status, check.name); + } + } else { + if let Some(category) = category { + println!( + "{} {} [{}] ({})", + status, + check.name, + category_slug(category), + detail + ); + } else { + println!("{} {} ({})", status, check.name, detail); + } + } + } + + if !issues.is_empty() { + println!("\nTo fix:"); + for issue in issues { + println!(" * [{}] {}", category_slug(issue.category), issue.fix_hint); + } + println!(); + let failed_count = checks.iter().filter(|c| !c.passed).count(); + println!("{} issue(s) found.", failed_count); + } else { + println!("\nAll prerequisites satisfied!"); + } +} + +fn print_check_results_json(checks: &[PrereqCheck], issues: &[ValidationIssue]) -> Result<()> { + let output = render_check_results_json(checks, issues); + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + +fn render_check_results_json(checks: &[PrereqCheck], issues: &[ValidationIssue]) -> Value { + json!({ + "checks": checks, + "issues": issues, + "all_passed": checks.iter().all(|c| c.passed), + "passed_count": checks.iter().filter(|c| c.passed).count(), + "failed_count": checks.iter().filter(|c| !c.passed).count(), + }) +} + +fn category_slug(category: ContractErrorCategory) -> &'static str { + match category { + ContractErrorCategory::Config => "config_error", + ContractErrorCategory::Preflight => "preflight_error", + ContractErrorCategory::Provider => "provider_error", + ContractErrorCategory::Build => "build_error", + ContractErrorCategory::Benchmark => "benchmark_error", + } +} + +fn check_cargo() -> PrereqCheck { + let result = std::process::Command::new("cargo") .arg("--version") .output(); match result { Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout) - .trim() - .to_string(); + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); PrereqCheck { name: "cargo installed".to_string(), passed: true, @@ -4047,7 +6643,10 @@ fn check_android_ndk_home() -> PrereqCheck { name: "ANDROID_NDK_HOME set".to_string(), passed: false, detail: None, - fix_hint: Some("Set ANDROID_NDK_HOME: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/".to_string()), + fix_hint: Some( + "Set ANDROID_NDK_HOME: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/" + .to_string(), + ), }, } } @@ -4059,9 +6658,7 @@ fn check_cargo_ndk() -> PrereqCheck { match result { Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout) - .trim() - .to_string(); + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); PrereqCheck { name: "cargo-ndk installed".to_string(), passed: true, @@ -4114,9 +6711,7 @@ fn check_rust_target(target: &str) -> PrereqCheck { fn check_jdk() -> PrereqCheck { // Try java -version - let result = std::process::Command::new("java") - .arg("-version") - .output(); + let result = std::process::Command::new("java").arg("-version").output(); match result { Ok(output) => { @@ -4173,7 +6768,9 @@ fn check_xcode() -> PrereqCheck { name: "Xcode installed".to_string(), passed: false, detail: None, - fix_hint: Some("Install Xcode from the App Store or run: xcode-select --install".to_string()), + fix_hint: Some( + "Install Xcode from the App Store or run: xcode-select --install".to_string(), + ), }, } } @@ -4185,9 +6782,7 @@ fn check_xcodegen() -> PrereqCheck { match result { Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout) - .trim() - .to_string(); + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); PrereqCheck { name: "xcodegen installed".to_string(), passed: true, @@ -4207,6 +6802,8 @@ fn check_xcodegen() -> PrereqCheck { #[cfg(test)] mod tests { use super::*; + use jsonschema::JSONSchema; + use tempfile::TempDir; // Register a lightweight benchmark for tests so the inventory contains at least one entry. #[mobench_sdk::benchmark] @@ -4224,6 +6821,8 @@ mod tests { vec!["pixel".into()], None, None, + Vec::new(), + None, None, false, false, // release @@ -4237,6 +6836,84 @@ mod tests { assert!(spec.ios_xcuitest.is_none()); } + #[test] + fn resolve_run_spec_prefers_cli_device_matrix_with_config() { + let temp_dir = TempDir::new().expect("temp dir"); + let config_matrix_path = temp_dir.path().join("config-matrix.yml"); + let cli_matrix_path = temp_dir.path().join("cli-matrix.yml"); + let config_path = temp_dir.path().join("bench-config.toml"); + + write_file( + &config_matrix_path, + br#"devices: + - name: Config Device + os: android + os_version: "14" +"#, + ) + .expect("write config matrix"); + write_file( + &cli_matrix_path, + br#"devices: + - name: CLI Device + os: android + os_version: "14" +"#, + ) + .expect("write cli matrix"); + + let config_toml = format!( + r#"target = "android" +function = "sample_fns::fibonacci" +iterations = 10 +warmup = 2 +device_matrix = "{}" + +[browserstack] +app_automate_username = "user" +app_automate_access_key = "key" +project = "proj" +"#, + config_matrix_path.display() + ); + write_file(&config_path, config_toml.as_bytes()).expect("write config"); + + let spec = resolve_run_spec( + MobileTarget::Android, + "ignored::value".into(), + 1, + 0, + Vec::new(), + Some(config_path.as_path()), + Some(cli_matrix_path.as_path()), + Vec::new(), + None, + None, + false, + false, + ) + .expect("resolve spec"); + + assert_eq!(spec.devices, vec!["CLI Device".to_string()]); + } + + #[test] + fn snapshot_baseline_creates_distinct_copy() { + let temp_dir = TempDir::new().expect("temp dir"); + let baseline = temp_dir.path().join("baseline.json"); + write_file(&baseline, br#"{"ok":true}"#).expect("write baseline"); + + assert!(paths_point_to_same_file(&baseline, &baseline).expect("compare path")); + + let snapshot = snapshot_baseline_for_compare(&baseline).expect("snapshot baseline"); + assert_ne!(snapshot, baseline); + let original_contents = fs::read_to_string(&baseline).expect("read baseline"); + let snapshot_contents = fs::read_to_string(&snapshot).expect("read snapshot"); + assert_eq!(snapshot_contents, original_contents); + + fs::remove_file(snapshot).expect("remove snapshot"); + } + #[test] fn local_smoke_produces_samples() { let spec = RunSpec { @@ -4263,6 +6940,8 @@ mod tests { vec!["iphone".into()], None, None, + Vec::new(), + None, None, false, false, // release @@ -4306,4 +6985,519 @@ mod tests { assert_eq!(format_ms(Some(1_500_000_000)), "1.500s"); assert_eq!(format_ms(None), "-"); } + + #[test] + fn doctor_browserstack_defaults_to_true() { + let cli = Cli::parse_from(["mobench", "doctor"]); + match cli.command { + Command::Doctor { browserstack, .. } => assert!(browserstack), + _ => panic!("expected doctor command"), + } + } + + #[test] + fn doctor_browserstack_can_be_disabled() { + let cli = Cli::parse_from(["mobench", "doctor", "--browserstack=false"]); + match cli.command { + Command::Doctor { browserstack, .. } => assert!(!browserstack), + _ => panic!("expected doctor command"), + } + } + + #[test] + fn ci_run_parses_required_args_with_defaults() { + let cli = Cli::parse_from([ + "mobench", + "ci", + "run", + "--target", + "android", + "--function", + "sample_fns::fibonacci", + ]); + + match cli.command { + Command::Ci { + command: CiCommand::Run(args), + } => { + assert_eq!(args.target, CiTarget::Android); + assert_eq!(args.function, "sample_fns::fibonacci"); + assert_eq!(args.output_dir, PathBuf::from("target/mobench/ci")); + } + _ => panic!("expected ci run command"), + } + } + + #[test] + fn ci_run_parses_both_target() { + let cli = Cli::parse_from([ + "mobench", + "ci", + "run", + "--target", + "both", + "--function", + "sample_fns::fibonacci", + ]); + + match cli.command { + Command::Ci { + command: CiCommand::Run(args), + } => { + assert_eq!(args.target, CiTarget::Both); + } + _ => panic!("expected ci run command"), + } + } + + #[test] + fn devices_resolve_parses() { + let cli = Cli::parse_from([ + "mobench", + "devices", + "resolve", + "--platform", + "android", + "--profile", + "default", + "--device-matrix", + "device-matrix.yaml", + ]); + match cli.command { + Command::Devices { + command: + Some(DevicesCommand::Resolve { + platform, profile, .. + }), + .. + } => { + assert_eq!(platform, DevicePlatform::Android); + assert_eq!(profile, Some("default".to_string())); + } + _ => panic!("expected devices resolve command"), + } + } + + #[test] + fn fixture_cache_key_parses() { + let cli = Cli::parse_from(["mobench", "fixture", "cache-key"]); + match cli.command { + Command::Fixture { + command: + FixtureCommand::CacheKey { + config, + target, + format, + .. + }, + } => { + assert_eq!(config, PathBuf::from("bench-config.toml")); + assert_eq!(target, SdkTarget::Both); + assert_eq!(format, CheckOutputFormat::Text); + } + _ => panic!("expected fixture cache-key command"), + } + } + + #[test] + fn report_github_parses() { + let cli = Cli::parse_from(["mobench", "report", "github", "--pr", "123"]); + match cli.command { + Command::Report { + command: ReportCommand::Github { pr, publish, .. }, + } => { + assert_eq!(pr, Some("123".to_string())); + assert!(!publish); + } + _ => panic!("expected report github command"), + } + } + + #[test] + fn config_validate_parses_required_args_with_defaults() { + let cli = Cli::parse_from(["mobench", "config", "validate"]); + match cli.command { + Command::Config { + command: ConfigCommand::Validate { config, format }, + } => { + assert_eq!(config, PathBuf::from("bench-config.toml")); + assert_eq!(format, CheckOutputFormat::Text); + } + _ => panic!("expected config validate command"), + } + } + + #[test] + fn issue_categories_align_with_contract_taxonomy() { + let checks = vec![ + PrereqCheck { + name: "Run config".to_string(), + passed: false, + detail: Some("missing".to_string()), + fix_hint: Some("fix config".to_string()), + }, + PrereqCheck { + name: "BrowserStack credentials".to_string(), + passed: false, + detail: Some("missing".to_string()), + fix_hint: Some("set env".to_string()), + }, + PrereqCheck { + name: "cargo installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("install rust".to_string()), + }, + ]; + let issues = collect_issues(&checks); + assert_eq!(issues.len(), 3); + assert_eq!(category_slug(issues[0].category), "config_error"); + assert_eq!(category_slug(issues[1].category), "provider_error"); + assert_eq!(category_slug(issues[2].category), "preflight_error"); + } + + #[test] + fn check_results_json_includes_issue_categories() { + let checks = vec![PrereqCheck { + name: "Run config".to_string(), + passed: false, + detail: Some("missing".to_string()), + fix_hint: Some("fix config".to_string()), + }]; + let issues = collect_issues(&checks); + let rendered = render_check_results_json(&checks, &issues); + let category = rendered + .get("issues") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|first| first.get("category")) + .and_then(|v| v.as_str()); + assert_eq!(category, Some("config")); + } + + #[test] + fn resolve_devices_from_matrix_is_deterministic() { + let devices = vec![ + DeviceEntry { + name: "Pixel 7".to_string(), + os: "android".to_string(), + os_version: "13.0".to_string(), + tags: Some(vec!["default".to_string(), "pixel".to_string()]), + }, + DeviceEntry { + name: "Pixel 6".to_string(), + os: "android".to_string(), + os_version: "12.0".to_string(), + tags: Some(vec!["default".to_string()]), + }, + DeviceEntry { + name: "iPhone 14".to_string(), + os: "ios".to_string(), + os_version: "16".to_string(), + tags: Some(vec!["default".to_string(), "iphone".to_string()]), + }, + ]; + + let resolved = + resolve_devices_from_matrix(devices, DevicePlatform::Android, &["default".to_string()]) + .expect("resolved devices"); + let ids: Vec = resolved.into_iter().map(|d| d.identifier).collect(); + assert_eq!(ids, vec!["Pixel 6-12.0", "Pixel 7-13.0"]); + } + + #[test] + fn render_summary_markdown_from_merged_output() { + let summary = json!({ + "generated_at": "2026-02-16T00:00:00Z", + "generated_at_unix": 1708041600, + "target": "android", + "function": "noop_benchmark", + "iterations": 3, + "warmup": 1, + "devices": ["local"], + "device_summaries": [] + }); + let merged = json!({ + "targets": { + "android": { "summary": summary }, + "ios": { "summary": { + "generated_at": "2026-02-16T00:00:00Z", + "generated_at_unix": 1708041600, + "target": "ios", + "function": "noop_benchmark", + "iterations": 3, + "warmup": 1, + "devices": ["local"], + "device_summaries": [] + }} + } + }); + let markdown = render_summary_markdown_from_output(&merged).expect("render markdown"); + assert!(markdown.contains("## android")); + assert!(markdown.contains("## ios")); + } + + #[test] + fn compare_markdown_includes_delta_labels() { + let report = CompareReport { + baseline: PathBuf::from("baseline.json"), + candidate: PathBuf::from("candidate.json"), + rows: vec![CompareRow { + device: "Pixel 7".to_string(), + function: "noop_benchmark".to_string(), + baseline_median_ns: Some(100), + candidate_median_ns: Some(110), + median_delta_pct: Some(10.0), + median_label: "regressed".to_string(), + baseline_p95_ns: Some(120), + candidate_p95_ns: Some(118), + p95_delta_pct: Some(-1.66), + p95_label: "improved".to_string(), + }], + }; + let markdown = render_compare_markdown(&report); + assert!(markdown.contains("Median Label")); + assert!(markdown.contains("P95 Label")); + assert!(markdown.contains("regressed")); + assert!(markdown.contains("improved")); + } + + #[test] + fn parse_pr_number_from_github_ref_extracts_pull_number() { + assert_eq!( + parse_pr_number_from_ref("refs/pull/123/merge"), + Some("123".to_string()) + ); + assert_eq!(parse_pr_number_from_ref("refs/heads/main"), None); + } + + #[test] + fn contract_schema_files_compile() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let summary_schema_path = root.join("docs/schemas/summary-v1.schema.json"); + let ci_schema_path = root.join("docs/schemas/ci-contract-v1.schema.json"); + + let summary_schema: Value = serde_json::from_str( + &fs::read_to_string(&summary_schema_path).expect("read summary schema"), + ) + .expect("parse summary schema"); + let ci_schema: Value = + serde_json::from_str(&fs::read_to_string(&ci_schema_path).expect("read ci schema")) + .expect("parse ci schema"); + + JSONSchema::options() + .compile(&summary_schema) + .expect("compile summary schema"); + JSONSchema::options() + .compile(&ci_schema) + .expect("compile ci schema"); + } + + #[test] + fn run_summary_validates_against_summary_schema() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let summary_schema_path = root.join("docs/schemas/summary-v1.schema.json"); + let summary_schema: Value = serde_json::from_str( + &fs::read_to_string(&summary_schema_path).expect("read summary schema"), + ) + .expect("parse summary schema"); + let validator = JSONSchema::options() + .compile(&summary_schema) + .expect("compile summary schema"); + + let spec = RunSpec { + target: MobileTarget::Android, + function: "noop_benchmark".into(), + iterations: 3, + warmup: 1, + devices: vec![], + browserstack: None, + ios_xcuitest: None, + }; + let local_report = run_local_smoke(&spec).expect("local harness"); + let mut run_summary = RunSummary { + spec, + artifacts: None, + local_report, + remote_run: None, + summary: empty_summary(&RunSpec { + target: MobileTarget::Android, + function: "noop_benchmark".into(), + iterations: 3, + warmup: 1, + devices: vec![], + browserstack: None, + ios_xcuitest: None, + }), + benchmark_results: None, + performance_metrics: None, + }; + run_summary.summary = build_summary(&run_summary).expect("build summary"); + let value = serde_json::to_value(&run_summary).expect("serialize run summary"); + + if let Err(errors) = validator.validate(&value) { + let messages: Vec = errors.map(|e| e.to_string()).collect(); + panic!("summary schema validation failed: {}", messages.join(" | ")); + } + } + + #[test] + fn ci_payload_validates_against_ci_schema() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let ci_schema_path = root.join("docs/schemas/ci-contract-v1.schema.json"); + let ci_schema: Value = + serde_json::from_str(&fs::read_to_string(&ci_schema_path).expect("read ci schema")) + .expect("parse ci schema"); + let validator = JSONSchema::options() + .compile(&ci_schema) + .expect("compile ci schema"); + + let payload = json!({ + "ci": { + "metadata": { + "requested_by": "codex", + "pr_number": "123", + "request_command": "cargo mobench ci run --target android --function noop_benchmark", + "mobench_ref": "refs/heads/codex/ci-devex", + "mobench_version": env!("CARGO_PKG_VERSION") + }, + "outputs": { + "summary_json": "target/mobench/ci/summary.json", + "summary_md": "target/mobench/ci/summary.md", + "results_csv": "target/mobench/ci/results.csv" + } + } + }); + + if let Err(errors) = validator.validate(&payload) { + let messages: Vec = errors.map(|e| e.to_string()).collect(); + panic!("ci schema validation failed: {}", messages.join(" | ")); + } + } +} + +#[cfg(test)] +mod result_extraction_tests { + use super::*; + use serde_json::json; + + #[test] + fn test_extract_all_benchmark_results() { + let results: HashMap> = [ + ( + "Pixel 7".to_string(), + vec![json!({ + "function": "my_crate::bench_fn", + "mean_ns": 12345678, + "samples": [{"duration_ns": 12345678}] + })], + ), + ( + "iPhone 14".to_string(), + vec![json!({ + "function": "my_crate::bench_fn", + "mean_ns": 11111111, + "samples": [{"duration_ns": 11111111}] + })], + ), + ] + .into_iter() + .collect(); + + let extracted = extract_benchmark_summary(&results); + assert_eq!(extracted.len(), 2); + assert!(extracted.iter().any(|r| r.device == "Pixel 7")); + assert!(extracted.iter().any(|r| r.device == "iPhone 14")); + } + + #[test] + fn test_extract_with_multiple_samples() { + let results: HashMap> = [( + "Device".to_string(), + vec![json!({ + "function": "test_fn", + "mean_ns": 100, + "samples": [ + {"duration_ns": 80}, + {"duration_ns": 100}, + {"duration_ns": 120} + ] + })], + )] + .into_iter() + .collect(); + + let extracted = extract_benchmark_summary(&results); + assert_eq!(extracted.len(), 1); + let result = &extracted[0]; + assert_eq!(result.sample_count, 3); + assert_eq!(result.min_ns, Some(80)); + assert_eq!(result.max_ns, Some(120)); + assert!(result.std_dev_ns.is_some()); + } +} + +#[cfg(test)] +mod init_sdk_tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_init_sdk_creates_mobench_toml() { + let temp_dir = TempDir::new().unwrap(); + let output_dir = temp_dir.path().join("my-bench"); + + // Run init-sdk + cmd_init_sdk( + SdkTarget::Android, + "my-bench".to_string(), + output_dir.clone(), + false, + ) + .unwrap(); + + // Check mobench.toml was created + let config_path = output_dir.join("mobench.toml"); + assert!( + config_path.exists(), + "mobench.toml should be created by init-sdk" + ); + + let contents = std::fs::read_to_string(&config_path).unwrap(); + assert!( + contents.contains("my-bench"), + "Config should contain project name" + ); + assert!( + contents.contains("[project]"), + "Config should have [project] section" + ); + assert!( + contents.contains("[benchmarks]"), + "Config should have [benchmarks] section" + ); + } + + #[test] + fn test_init_sdk_mobench_toml_has_correct_library_name() { + let temp_dir = TempDir::new().unwrap(); + let output_dir = temp_dir.path().join("my-project"); + + cmd_init_sdk( + SdkTarget::Android, + "my-project".to_string(), + output_dir.clone(), + false, + ) + .unwrap(); + + let config_path = output_dir.join("mobench.toml"); + let contents = std::fs::read_to_string(&config_path).unwrap(); + + // Library name should have hyphens replaced with underscores + assert!( + contents.contains("library_name = \"my_project\""), + "Config should have library_name with underscores" + ); + } } diff --git a/crates/mobench/templates/ci/action.README.md b/crates/mobench/templates/ci/action.README.md new file mode 100644 index 0000000..b7f0d01 --- /dev/null +++ b/crates/mobench/templates/ci/action.README.md @@ -0,0 +1,75 @@ +# mobench GitHub Action + +Run `mobench ci run` in GitHub Actions with caching, Android SDK setup, and artifact upload. + +## Usage + +```yaml +- uses: ./.github/actions/mobench + with: + command: cargo mobench ci run + run-args: | + --target android + --function sample_fns::fibonacci + --iterations 30 + --warmup 5 + --devices "Google Pixel 7-13.0" + --release + --fetch + ci: false + ndk-version: "26.1.10909125" + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} +``` + +## Inputs + +- `command`: command to invoke. Supported values are `cargo mobench ci run` (default) and `cargo mobench run`. +- `run-args`: arguments passed to `command`. Use quoted values for arguments containing spaces (for example device names). +- `ci`: append `--ci` only when `command` is exactly `cargo mobench run`; ignored for `cargo mobench ci run`. +- `install-mobench`: install `mobench` with cargo-binstall/cargo install. +- `mobench-version`: optional version to install. +- `install-cargo-ndk`: install `cargo-ndk` for Android builds. +- `setup-android`: install Android SDK/NDK packages. +- `ndk-version`: Android NDK version (used for setup + `ANDROID_NDK_HOME`). +- `android-sdk-root`: Android SDK root directory on the runner. +- `android-packages`: SDK packages list for `setup-android`. +- `cache-cargo`: cache cargo registry/git and `target`. +- `cache-target`: cache `target/` (can be large). +- `cache-gradle`: cache `~/.gradle` wrapper and caches. +- `cache-android`: cache Android SDK/NDK. +- `artifact-name`: artifact name. +- `artifact-path`: paths to upload. +- `pr-comment`: publish sticky PR comment from CI summary (`true|false`). +- `pr-number`: PR number override (optional). +- `pr-comment-marker`: sticky comment marker used for idempotent updates. +- `github-token`: token for PR comment publishing. + +## Notes + +- Inputs are passed through environment variables in shell steps to reduce script-injection risk from workflow inputs. +- `command` is allow-listed in the action implementation; unsupported command values fail the job early. + +## Cache keys + +The action uses deterministic cache keys: +- Cargo cache: `${runner.os}-cargo-${hashFiles('**/Cargo.lock')}` +- Target cache: `${runner.os}-target-${hashFiles('**/Cargo.lock')}` +- Gradle cache: `${runner.os}-gradle-${hashFiles('**/*.gradle*', '**/gradle/wrapper/gradle-wrapper.properties', '**/gradle.properties')}` +- Android SDK cache: `${runner.os}-android-${inputs.ndk-version}` + +## PR comment mode + +To enable sticky PR comments, grant workflow permissions and pass token: + +```yaml +permissions: + contents: read + pull-requests: write + +- uses: ./.github/actions/mobench + with: + pr-comment: true + github-token: ${{ github.token }} +``` diff --git a/crates/mobench/templates/ci/action.yml b/crates/mobench/templates/ci/action.yml new file mode 100644 index 0000000..50dded6 --- /dev/null +++ b/crates/mobench/templates/ci/action.yml @@ -0,0 +1,244 @@ +name: "mobench" +description: "Run mobile benchmarks with mobench" +inputs: + command: + description: "Command to invoke (supported: cargo mobench ci run | cargo mobench run)" + required: false + default: "cargo mobench ci run" + run-args: + description: "Arguments passed to command (quote values containing spaces)" + required: true + ci: + description: "Append --ci only when command is cargo mobench run (ignored for ci run)" + required: false + default: "false" + install-mobench: + description: "Install mobench with cargo-binstall or cargo install" + required: false + default: "true" + mobench-version: + description: "Optional mobench version to install" + required: false + default: "" + install-cargo-ndk: + description: "Install cargo-ndk" + required: false + default: "true" + setup-android: + description: "Setup Android SDK/NDK" + required: false + default: "true" + ndk-version: + description: "Android NDK version" + required: false + default: "26.1.10909125" + android-sdk-root: + description: "Android SDK root directory on the runner" + required: false + default: "/usr/local/lib/android/sdk" + android-packages: + description: "SDK packages to install via setup-android" + required: false + default: | + platform-tools + platforms;android-34 + build-tools;34.0.0 + ndk;26.1.10909125 + cache-cargo: + description: "Cache cargo registry/git and target" + required: false + default: "true" + cache-target: + description: "Cache Cargo target/ directory (can be large)" + required: false + default: "true" + cache-gradle: + description: "Cache Gradle wrapper and caches (useful if mobench triggers Gradle builds)" + required: false + default: "true" + cache-android: + description: "Cache Android SDK/NDK" + required: false + default: "true" + artifact-name: + description: "Artifact name for upload" + required: false + default: "mobench-results" + artifact-path: + description: "Paths to upload as artifacts" + required: false + default: | + target/mobench/ci/summary.json + target/mobench/ci/summary.md + target/mobench/ci/results.csv + target/browserstack + pr-comment: + description: "Publish sticky PR comment from CI summary" + required: false + default: "false" + pr-number: + description: "PR number (optional, auto-detected from GITHUB_REF when omitted)" + required: false + default: "" + pr-comment-marker: + description: "Marker string used to update an existing sticky PR comment" + required: false + default: "" + github-token: + description: "GitHub token used to publish PR comments" + required: false + default: "" +runs: + using: "composite" + steps: + - name: Cache cargo + if: inputs.cache-cargo == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Cache target + if: inputs.cache-target == 'true' + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-target-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-target- + + - name: Cache Gradle + if: inputs.cache-gradle == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle/wrapper/gradle-wrapper.properties', '**/gradle.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Cache Android SDK + if: inputs.cache-android == 'true' && inputs.setup-android == 'true' + uses: actions/cache@v4 + with: + path: | + ${{ inputs.android-sdk-root }}/ndk/${{ inputs.ndk-version }} + ${{ inputs.android-sdk-root }}/platform-tools + ${{ inputs.android-sdk-root }}/platforms + ${{ inputs.android-sdk-root }}/build-tools + key: ${{ runner.os }}-android-${{ inputs.ndk-version }} + restore-keys: | + ${{ runner.os }}-android- + + - name: Setup Android SDK/NDK + if: inputs.setup-android == 'true' + uses: android-actions/setup-android@v3 + with: + packages: ${{ inputs.android-packages }} + + - name: Install cargo-ndk + if: inputs.install-cargo-ndk == 'true' + shell: bash + run: | + if ! command -v cargo-ndk >/dev/null 2>&1; then + cargo install cargo-ndk + fi + + - name: Install mobench + if: inputs.install-mobench == 'true' + shell: bash + env: + MOBENCH_VERSION: ${{ inputs.mobench-version }} + run: | + if command -v cargo-mobench >/dev/null 2>&1 || command -v mobench >/dev/null 2>&1; then + exit 0 + fi + install_args=() + if [ -n "$MOBENCH_VERSION" ]; then + install_args=(--version "$MOBENCH_VERSION") + fi + if command -v cargo-binstall >/dev/null 2>&1; then + cargo binstall -y mobench "${install_args[@]}" + else + cargo install cargo-binstall + cargo binstall -y mobench "${install_args[@]}" + fi + + - name: Run mobench + shell: bash + env: + ANDROID_SDK_ROOT_INPUT: ${{ inputs.android-sdk-root }} + NDK_VERSION_INPUT: ${{ inputs.ndk-version }} + MOBENCH_CI: ${{ inputs.ci }} + MOBENCH_COMMAND: ${{ inputs.command }} + MOBENCH_RUN_ARGS: ${{ inputs.run-args }} + run: | + export ANDROID_SDK_ROOT="$ANDROID_SDK_ROOT_INPUT" + export ANDROID_HOME="$ANDROID_SDK_ROOT_INPUT" + export ANDROID_NDK_HOME="$ANDROID_SDK_ROOT_INPUT/ndk/$NDK_VERSION_INPUT" + extra_args=() + if [ "$MOBENCH_CI" = "true" ] && [ "$MOBENCH_COMMAND" = "cargo mobench run" ]; then + extra_args=(--ci) + fi + case "$MOBENCH_COMMAND" in + "cargo mobench run"|"cargo mobench ci run") + ;; + *) + echo "Unsupported command input: $MOBENCH_COMMAND" + exit 1 + ;; + esac + read -r -a command_parts <<< "$MOBENCH_COMMAND" + run_args=() + if [ -n "$MOBENCH_RUN_ARGS" ]; then + while IFS= read -r line; do + if [ -z "$line" ]; then + continue + fi + read -r -a parts <<< "$line" + run_args+=("${parts[@]}") + done <<< "$MOBENCH_RUN_ARGS" + fi + "${command_parts[@]}" "${extra_args[@]}" "${run_args[@]}" + + - name: Publish sticky PR comment + if: always() && inputs.pr-comment == 'true' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + PR_NUMBER_INPUT: ${{ inputs.pr-number }} + PR_COMMENT_MARKER: ${{ inputs.pr-comment-marker }} + run: | + pr_number="$PR_NUMBER_INPUT" + if [ -z "$pr_number" ] && [ -n "${GITHUB_REF:-}" ]; then + pr_number="$(echo "$GITHUB_REF" | awk -F/ '/^refs\/pull\// { print $3 }')" + fi + if [ -z "$pr_number" ]; then + echo "No PR number detected; skipping sticky comment publish." + exit 0 + fi + if [ ! -f target/mobench/ci/summary.json ]; then + echo "CI summary not found at target/mobench/ci/summary.json; skipping sticky comment publish." + exit 0 + fi + if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "GITHUB_TOKEN is empty; skipping sticky comment publish." + exit 0 + fi + cargo mobench report github \ + --pr "$pr_number" \ + --summary target/mobench/ci/summary.json \ + --marker "$PR_COMMENT_MARKER" \ + --publish + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.artifact-path }} diff --git a/crates/mobench/templates/ci/mobile-bench.yml b/crates/mobench/templates/ci/mobile-bench.yml new file mode 100644 index 0000000..f230f91 --- /dev/null +++ b/crates/mobench/templates/ci/mobile-bench.yml @@ -0,0 +1,62 @@ +name: Mobile Benchmarks + +on: + workflow_dispatch: + inputs: + platform: + description: "android | ios | both" + required: false + default: "android" + +permissions: + contents: read + +jobs: + android: + if: ${{ github.event.inputs.platform == 'android' || github.event.inputs.platform == 'both' || github.event.inputs.platform == '' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + - uses: ./.github/actions/mobench + with: + run-args: > + --target android + --function sample_fns::fibonacci + --iterations 30 + --warmup 5 + --devices "Google Pixel 7-13.0" + --release + --fetch + ci: false + ndk-version: "26.1.10909125" + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + + ios: + if: ${{ github.event.inputs.platform == 'ios' || github.event.inputs.platform == 'both' || github.event.inputs.platform == '' }} + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios + - uses: ./.github/actions/mobench + with: + run-args: > + --target ios + --function sample_fns::fibonacci + --iterations 30 + --warmup 5 + --devices "iPhone 14-16" + --release + --fetch + ci: false + setup-android: false + install-cargo-ndk: false + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} diff --git a/docs/CONCERNS_RESOLUTION_2026-02-16.md b/docs/CONCERNS_RESOLUTION_2026-02-16.md new file mode 100644 index 0000000..3c301df --- /dev/null +++ b/docs/CONCERNS_RESOLUTION_2026-02-16.md @@ -0,0 +1,74 @@ +# Concerns Resolution (2026-02-16) + +This document records the disposition of concerns previously tracked in `.planning/codebase/CONCERNS.md`. + +Disposition labels: +- `fixed`: implemented in code on `codex/ci-devex` +- `accepted`: reviewed and explicitly accepted as non-blocking for current release scope +- `deferred`: valid concern, intentionally deferred to follow-up engineering work + +## Tech Debt + +- Large monolithic build modules (`ios.rs`, `android.rs`): `deferred` +- Unwrap calls in production code paths (`ios.rs` packaging flows): `fixed` +- Path canonicalization fallback in `IosBuilder::new`: `fixed` (now emits explicit warning on fallback) +- String parsing of cargo metadata target dir (`common.rs`): `fixed` (now parses JSON payload) +- Generated code validation before use: `deferred` + +## Known Bugs + +- iOS XCUITest packaging with complex paths/spaces: `fixed` (OsStr-safe command args) +- Cargo metadata fallback behavior in workspaces: `fixed` (JSON parse path; clearer fallback behavior) +- Template variable name collision (`sample_fns`): `deferred` + +## Security Considerations + +- BrowserStack credentials in verbose output: `accepted` (current code logs command invocations, not secret env values) +- User path validation / traversal guardrails: `deferred` +- ZIP/command execution with unchecked path length/characters: `deferred` +- Secret redaction wrapper types for config/env credentials: `deferred` + +## Performance Bottlenecks + +- Sequential multi-target compilation: `deferred` +- Cargo metadata invocation on every build (no cache): `deferred` +- Manual xcframework directory construction overhead: `deferred` + +## Fragile Areas + +- Manual xcframework plist generation: `deferred` +- String-substitution template rendering: `deferred` +- Gradle artifact validation after build: `deferred` +- CLI inter-argument validation coverage: `deferred` + +## Scaling Limits + +- APK size and BrowserStack upload limit behavior: `accepted` (documented operational constraint) +- Large benchmark registry scaling profile: `deferred` +- Large device matrix/session limit handling: `deferred` + +## Dependencies at Risk + +- Rustls backend feature fragility: `accepted` (monitoring required) +- UniFFI version lock consistency: `deferred` +- Embedded template updatability via `include_dir`: `accepted` + +## Missing Features + +- Resume/restart after partial build failure: `deferred` +- Artifact correctness validation beyond build success: `deferred` +- Incremental rebuild strategy: `deferred` +- Android compatibility matrix validation guidance: `deferred` + +## Test Coverage Gaps + +- Builder error-path tests: `deferred` +- Template rendering edge-case tests: `deferred` +- BrowserStack end-to-end integration tests: `deferred` +- CLI invalid-combination tests: `deferred` +- Cross-platform path handling tests: `deferred` + +## Notes + +- This file replaces the previous `.planning/codebase/CONCERNS.md` backlog artifact. +- Resolved items in this pass include safer iOS packaging command construction, cargo metadata JSON parsing, and canonicalization fallback visibility. diff --git a/docs/CONTRACT_CI_V1.md b/docs/CONTRACT_CI_V1.md new file mode 100644 index 0000000..447e992 --- /dev/null +++ b/docs/CONTRACT_CI_V1.md @@ -0,0 +1,108 @@ +# Mobench CI Contract v1 + +## Status + +- Version: `v1` +- Stability: Frozen for v1 consumers +- Effective date: 2026-02-16 + +## Scope + +This document defines the stable contract for `cargo mobench ci run`. + +It covers: +- Input contract (CLI/environment inputs accepted by `ci run`) +- Output contract (`summary.json`, `summary.md`, `results.csv`) +- Error taxonomy categories used by CI-focused validation commands + +## Input Contract + +### Required CLI inputs + +- `--target ` +- `--function ` + +### Optional execution inputs + +- Iteration controls: `--iterations`, `--warmup` +- Device selection: `--devices`, `--device-matrix`, `--device-tags` +- Runtime mode: `--local-only`, `--release`, `--fetch` +- iOS artifacts: `--ios-app`, `--ios-test-suite` +- Regression mode: `--baseline`, `--regression-threshold-pct` +- Output path: `--output-dir` + +Behavior notes: +- In config-driven runs (`--config`), `--device-matrix` overrides the matrix path from config when both are provided. +- If `--baseline` resolves to the same file as the candidate output, mobench snapshots the previous baseline file before writing the candidate summary so regression comparison remains valid. + +### Optional metadata inputs + +Metadata can be provided via flags or CI environment discovery: + +- `requested_by` (`--requested-by`, `MOBENCH_REQUESTED_BY`, `GITHUB_ACTOR`) +- `pr_number` (`--pr-number`, `MOBENCH_PR_NUMBER`, `PR_NUMBER`, `GITHUB_PR_NUMBER`, `GITHUB_PULL_REQUEST_NUMBER`, or parsed from `GITHUB_REF`) +- `request_command` (`--request-command`, fallback to argv) +- `mobench_ref` (`--mobench-ref`, `MOBENCH_REF`, `GITHUB_SHA`, `GITHUB_REF`) +- `mobench_version` (derived from package version) + +## Output Contract + +Default directory: `target/mobench/ci/` + +Required files: +- `summary.json` +- `summary.md` +- `results.csv` + +`summary.json` MUST include: +- run summary data +- `ci.metadata` object with: + - `requested_by` + - `pr_number` (optional) + - `request_command` + - `mobench_ref` (optional) + - `mobench_version` +- `ci.outputs` object with: + - `summary_json` + - `summary_md` + - `results_csv` + +Machine-readable schema artifacts: +- `docs/schemas/summary-v1.schema.json` +- `docs/schemas/ci-contract-v1.schema.json` + +## Error Taxonomy + +The following categories are used for contract-aligned checks: +- `config_error` +- `preflight_error` +- `provider_error` +- `build_error` +- `benchmark_error` + +Current command mapping: +- `cargo mobench doctor` and `cargo mobench config validate` emit category-aligned issues for config/preflight/provider failures. +- Build/benchmark failures remain surfaced by run/build/report commands and can be mapped by callers into the same taxonomy. + +## Breaking-Change Policy (v1) + +A change is breaking if it modifies or removes: +- required output filenames +- required metadata keys +- required schema fields/types + +Breaking changes require: +1. New versioned contract docs/schema files. +2. Backward-compatibility note in release notes. +3. Migration guidance update. + +## Compatibility Window + +- `v1` outputs and metadata are maintained for at least one minor release window after any successor is introduced. +- Additive fields are allowed in `summary.json` as long as required keys remain stable. + +## Non-goals + +- Defining provider-specific BrowserStack API payload formats. +- Real-time dashboard protocols. +- Enforcing thresholds by default in v1 reporting. diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md new file mode 100644 index 0000000..bea46c6 --- /dev/null +++ b/docs/MIGRATION_GUIDE.md @@ -0,0 +1,84 @@ +# Mobench CI Migration Guide + +This guide migrates custom BrowserStack benchmark CI flows to the standardized `mobench` v1 contract. + +## Goals + +- One-command orchestration via `cargo mobench ci run` +- Stable contract outputs: `summary.json`, `summary.md`, `results.csv` +- Optional sticky PR comments and deterministic matrix resolution + +## Old -> New Mapping + +| Legacy pattern | New command/workflow | +| --- | --- | +| Custom prereq scripts | `cargo mobench doctor --target both` | +| Ad-hoc TOML/YAML validation | `cargo mobench config validate --config bench-config.toml` | +| Custom matrix/tag resolution | `cargo mobench devices resolve --platform --profile ` | +| Manual build/upload/fetch orchestration | `cargo mobench ci run --target --function ` | +| Custom artifact naming | Standard output dir `target/mobench/ci/` | +| Custom PR comment scripts | `cargo mobench report github --pr --publish` | +| Hand-rolled cache keys | `cargo mobench fixture cache-key --config bench-config.toml` | + +## Command Matrix + +| Concern | Command | +| --- | --- | +| Preflight | `cargo mobench doctor --target both --config bench-config.toml --device-matrix device-matrix.yaml` | +| Config contract | `cargo mobench config validate --config bench-config.toml --format json` | +| Device resolution | `cargo mobench devices resolve --platform android --profile default --device-matrix device-matrix.yaml` | +| Fixture setup | `cargo mobench fixture init` | +| Fixture verify | `cargo mobench fixture verify --config bench-config.toml` | +| Fixture cache key | `cargo mobench fixture cache-key --config bench-config.toml --format json` | +| CI orchestration | `cargo mobench ci run --target both --function sample_fns::fibonacci --local-only` | +| Summary markdown | `cargo mobench report summarize --summary target/mobench/ci/summary.json` | +| Sticky PR comment | `cargo mobench report github --pr 123 --summary target/mobench/ci/summary.json --publish` | + +## Minimal Reference Workflow + +Use `.github/workflows/mobile-bench-action-example.yml` as the copy-paste baseline. Minimal form: + +```yaml +name: Mobench CI (minimal) + +on: + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + mobench: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + - uses: ./.github/actions/mobench + with: + command: cargo mobench ci run + run-args: | + --target android + --function sample_fns::fibonacci + --iterations 20 + --warmup 5 + --local-only + pr-comment: true + github-token: ${{ github.token }} +``` + +### Action input notes + +- `command` is allow-listed to `cargo mobench ci run` and `cargo mobench run`. +- `ci` only appends `--ci` when `command: cargo mobench run`. +- Prefer multiline `run-args` with explicit quoting for values containing spaces. + +## Compatibility Notes + +- Contract docs: `docs/CONTRACT_CI_V1.md` +- ADR: `docs/adr/0001-mobench-ci-contract-v1.md` +- Schemas: `docs/schemas/summary-v1.schema.json`, `docs/schemas/ci-contract-v1.schema.json` + +Any change to required output files or metadata keys requires a contract-version bump. diff --git a/docs/adr/0001-mobench-ci-contract-v1.md b/docs/adr/0001-mobench-ci-contract-v1.md new file mode 100644 index 0000000..bf5d5a7 --- /dev/null +++ b/docs/adr/0001-mobench-ci-contract-v1.md @@ -0,0 +1,74 @@ +# ADR 0001: Mobench CI Contract v1 + +- Date: 2026-02-16 +- Status: Accepted +- Decision owners: Engineering / Mobench + +## Context + +Mobench CI integrations need a stable interface for inputs, outputs, and error categories so that workflows and PR reporting do not break across iterative CLI improvements. + +## Decision + +Adopt `v1` CI contract documented in `docs/CONTRACT_CI_V1.md` and versioned schemas under `docs/schemas/`. + +### Scope boundaries + +Included: +- `cargo mobench ci run` contract +- output files and metadata fields +- error taxonomy categories for CI-oriented validation +- deterministic device-matrix override semantics (`--device-matrix` over config value when both are provided) +- baseline comparison safety when baseline and candidate paths overlap + +Excluded (non-goals): +- provider-specific API payloads +- dashboard/event streaming protocols +- default threshold gating policy changes + +### Versioning strategy + +- Contract artifacts are versioned as `vN`. +- Output schema and required metadata are append-only within a major contract version. +- Any removal/rename/type-breaking change requires a new contract version. + +### Action interface versioning + +- GitHub Action references use semantic tags plus immutable SHAs. +- Repository-local action examples must map to the same required output contract. + +### Deprecation policy + +- When introducing a successor to `v1`, keep `v1` compatibility for at least one minor release window. +- Mark deprecated fields/artifacts in docs before removal. + +### Reporting defaults + +- v1 default reporting mode is descriptive-only. +- Threshold gating remains explicit and opt-in. + +### Baseline default (v1.1 planning) + +- Default baseline source is previous successful run, with pinned artifacts supported explicitly. + +### Minimum supported CI environments/toolchains + +- Linux: Ubuntu latest runner with Rust stable. +- macOS: macOS latest runner with Rust stable. +- Required Rust targets are documented in workflow templates. + +## Consequences + +Positive: +- Integrators get stable artifact paths and metadata contract. +- CI tooling can rely on fixed machine-readable schema files. + +Tradeoffs: +- Requires explicit version bumps for contract evolution. +- Maintainers must update schema/tests/docs together. + +## Links + +- Contract: `docs/CONTRACT_CI_V1.md` +- Schemas: `docs/schemas/summary-v1.schema.json`, `docs/schemas/ci-contract-v1.schema.json` +- Migration guide placeholder: `docs/MIGRATION_GUIDE.md` diff --git a/docs/schemas/ci-contract-v1.schema.json b/docs/schemas/ci-contract-v1.schema.json new file mode 100644 index 0000000..6d1d5a4 --- /dev/null +++ b/docs/schemas/ci-contract-v1.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://world.org/mobench/schemas/ci-contract-v1.schema.json", + "title": "Mobench CI Contract v1", + "type": "object", + "required": ["ci"], + "properties": { + "ci": { + "type": "object", + "required": ["metadata", "outputs"], + "properties": { + "metadata": { + "type": "object", + "required": ["requested_by", "request_command", "mobench_version"], + "properties": { + "requested_by": { "type": "string" }, + "pr_number": { "type": ["string", "null"] }, + "request_command": { "type": "string" }, + "mobench_ref": { "type": ["string", "null"] }, + "mobench_version": { "type": "string" } + } + }, + "outputs": { + "type": "object", + "required": ["summary_json", "summary_md", "results_csv"], + "properties": { + "summary_json": { "type": "string" }, + "summary_md": { "type": "string" }, + "results_csv": { "type": "string" } + } + } + } + } + }, + "additionalProperties": true +} diff --git a/docs/schemas/summary-v1.schema.json b/docs/schemas/summary-v1.schema.json new file mode 100644 index 0000000..4c7a254 --- /dev/null +++ b/docs/schemas/summary-v1.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://world.org/mobench/schemas/summary-v1.schema.json", + "title": "Mobench Summary v1", + "type": "object", + "required": ["summary"], + "properties": { + "summary": { + "type": "object", + "required": [ + "generated_at", + "generated_at_unix", + "target", + "function", + "iterations", + "warmup", + "devices", + "device_summaries" + ], + "properties": { + "generated_at": { "type": "string" }, + "generated_at_unix": { "type": "integer" }, + "target": { "type": "string", "enum": ["android", "ios"] }, + "function": { "type": "string" }, + "iterations": { "type": "integer" }, + "warmup": { "type": "integer" }, + "devices": { + "type": "array", + "items": { "type": "string" } + }, + "device_summaries": { + "type": "array", + "items": { + "type": "object", + "required": ["device", "benchmarks"], + "properties": { + "device": { "type": "string" }, + "benchmarks": { + "type": "array", + "items": { + "type": "object", + "required": ["function", "samples"], + "properties": { + "function": { "type": "string" }, + "samples": { "type": "integer" }, + "mean_ns": { "type": ["integer", "null"] }, + "median_ns": { "type": ["integer", "null"] }, + "p95_ns": { "type": ["integer", "null"] }, + "min_ns": { "type": ["integer", "null"] }, + "max_ns": { "type": ["integer", "null"] } + } + } + } + } + } + } + } + } + }, + "additionalProperties": true +} diff --git a/examples/ffi-benchmark/src/lib.rs b/examples/ffi-benchmark/src/lib.rs index b2c8372..3d5e369 100644 --- a/examples/ffi-benchmark/src/lib.rs +++ b/examples/ffi-benchmark/src/lib.rs @@ -4,6 +4,14 @@ //! `run_benchmark`) for Kotlin/Swift bindings. For the minimal SDK-only usage, //! see `examples/basic-benchmark`. +// Alternative: The mobench_sdk::ffi module provides pre-defined types that match +// what UniFFI expects. You can use these directly or as templates for your own types: +// +// use mobench_sdk::ffi::{BenchSpecFfi, BenchReportFfi, BenchErrorFfi, run_benchmark_ffi}; +// +// This example defines its own types for demonstration, but the ffi module +// is a simpler starting point for most use cases. + use mobench_sdk::benchmark; const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; diff --git a/mobench-bugs-summary.md b/mobench-bugs-summary.md deleted file mode 100644 index e3ffc7f..0000000 --- a/mobench-bugs-summary.md +++ /dev/null @@ -1,208 +0,0 @@ -# mobench Bug Summary for Development - -**Last Updated:** 2026-01-19 -**Tested Versions:** 0.1.9, 0.1.10, 0.1.11, Local Build -**Test Crate:** bench-mobile (World ID ZK Proof Benchmarks) - ---- - -## Quick Status - -| Version | Critical Bugs | Builds Without Fixes | Recommendation | -|---------|---------------|---------------------|----------------| -| 0.1.9 | 12 | ❌ No | Do not use | -| 0.1.10 | 2 | ✅ Yes (false failure) | Usable | -| 0.1.11 | 1 | ⚠️ Partial | Usable with workaround | -| Local | 2 | ⚠️ Requires --crate-path | Needs crate detection fix | - ---- - -## Version Evolution - -### 0.1.9 → 0.1.10 (12 bugs fixed) -All template placeholder bugs fixed, Gradle wrapper and properties added. - -### 0.1.10 → 0.1.11 (3 bugs fixed, 1 new bug) - -**Fixed:** -- ✅ APK filename detection (now parses output-metadata.json) -- ✅ iOS bundle identifier (removed invalid hyphens/underscores) -- ✅ proguard-rules.pro now generated - -**New Bug:** -- ❌ `assembleReleaseAndroidTest` task not found - -### 0.1.11 → Local Build (0 bugs fixed, 1 new bug) - -**New Bug:** -- ❌ Crate detection fails - requires `--crate-path .` flag - -**Still Present:** -- ❌ `assembleReleaseAndroidTest` task not found -- ❌ Hardcoded local.properties -- ❌ Source files not in package directory -- ❌ iOS bundle ID duplication - ---- - -## Current Bugs in Local Build - -### CRITICAL - -#### Bug 0: Crate Detection Fails (NEW in Local Build) -**Location:** mobench-sdk crate detection logic -**Symptom:** -``` -build error: Benchmark crate 'bench-mobile' not found. - -Searched locations: -- /path/bench-mobile/bench-mobile/Cargo.toml -- /path/bench-mobile/crates/bench-mobile/Cargo.toml -``` - -**Cause:** mobench looks for nested directories instead of checking if the current directory is a valid crate. - -**Impact:** -- Build fails to start without workaround -- Must use `--crate-path .` flag - -**Workaround:** -```bash -mobench build --target android --release --crate-path . -``` - ---- - -#### Bug 1: Test APK Task Not Found (from 0.1.11) -**Location:** mobench-sdk android.rs -**Symptom:** -``` -Task 'assembleReleaseAndroidTest' not found in root project -``` - -**Cause:** Android Gradle only creates test tasks for debug by default. Release test task requires `testBuildType "release"`. - -**Impact:** -- Main APK builds ✅ -- Test APK fails ❌ -- mobench reports overall failure - -**Workaround:** -```gradle -// Add to app/build.gradle -android { - defaultConfig { - testBuildType "release" - } -} -``` - -**Or:** Use debug build type for tests (recommended) - ---- - -### HIGH - -#### Bug 2: Hardcoded local.properties (Still present) -**File:** `target/mobench/android/local.properties` -```properties -sdk.dir=/Users/dcbuilder/Library/Android/sdk -``` - -**Impact:** Breaks builds on other machines - -**Fix:** Don't generate this file - ---- - -#### Bug 3: Test File Wrong Directory -**Current:** `app/src/androidTest/java/MainActivityTest.kt` -**Expected:** `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` - -**Impact:** Test compilation may fail - ---- - -### MEDIUM - -#### Bug 4: iOS Bundle ID Duplication -**Current:** `dev.world.benchmobile.benchmobile` -**Expected:** `dev.world.benchmobile.BenchRunner` - -**Impact:** Cosmetic, works but non-standard - ---- - -#### Bug 5: Silent Error Fallbacks -Both platforms catch exceptions broadly and lose error context. - ---- - -## Fixed Bugs (Historical) - -### Fixed in 0.1.11 -| Bug | Description | Status | -|-----|-------------|--------| -| APK filename | Expected wrong name | ✅ Fixed | -| iOS bundle ID | Invalid chars | ✅ Fixed | -| proguard-rules.pro | Missing file | ✅ Fixed | - -### Fixed in 0.1.10 -| Bug | Description | Status | -|-----|-------------|--------| -| `{{PACKAGE_NAME}}` | Not replaced | ✅ Fixed | -| `{{LIBRARY_NAME}}` | Not replaced | ✅ Fixed | -| `{{PROJECT_NAME}}` | Not replaced | ✅ Fixed | -| `{{APP_NAME}}` | Not replaced | ✅ Fixed | -| gradle.properties | Missing | ✅ Fixed | -| Gradle wrapper | Missing | ✅ Fixed | -| AGP version | Invalid 8.13.2 | ✅ Fixed | -| x86_64 iOS sim | Missing | ✅ Fixed | - ---- - -## Verification Commands - -```bash -# Build Android (APK succeeds, test APK fails) -mobench build --target android --release --verbose -ls -la target/mobench/android/app/build/outputs/apk/release/ - -# Build iOS (succeeds fully) -mobench build --target ios --release --verbose -ls -la target/mobench/ios/bench_mobile.xcframework/ - -# Workaround for test APK -cd target/mobench/android -# Add testBuildType to build.gradle, then: -./gradlew assembleReleaseAndroidTest -``` - ---- - -## Recommended Priority Fixes for Next Release - -### P0 (Blocker) -1. **Fix crate detection** - Check current directory Cargo.toml, not just nested paths -2. **Fix test APK task** - Use debug or add testBuildType config - -### P1 (Should Fix) -3. Don't generate local.properties with hardcoded paths -4. Fix source file directory structure (place in package path) -5. Log UniFFI cleanup errors instead of swallowing - -### P2 (Nice to Have) -6. Fix iOS bundle ID duplication -7. Standardize package naming across platforms -8. Improve error handling to preserve types - ---- - -## Files Changed - -- `bench-mobile/Cargo.toml` - Using local mobench-sdk path -- `docs/mobench-0.1.9-dx-report.md` - Full 0.1.9 report -- `docs/mobench-0.1.10-dx-report.md` - Full 0.1.10 report -- `docs/mobench-0.1.11-dx-report.md` - Full 0.1.11 report -- `docs/mobench-local-build-dx-report.md` - Full local build report -- `docs/mobench-bugs-summary.md` - This file diff --git a/mobench-local-build-dx-report.md b/mobench-local-build-dx-report.md deleted file mode 100644 index d46d95f..0000000 --- a/mobench-local-build-dx-report.md +++ /dev/null @@ -1,244 +0,0 @@ -# mobench Local Build DX Report - -**Date:** 2026-01-19 -**Tested Version:** Local build from `../mobile-bench-rs` (based on 0.1.11) -**Platform:** macOS Darwin 25.1.0 (arm64) - -## Executive Summary - -Testing the local mobench build revealed **1 new bug** (crate detection) while confirming that previous bugs from 0.1.11 remain unfixed. Both platforms build successfully, but the test APK step still fails. - -**Build Status:** -- Android APK: ✅ Built successfully (133MB) -- Android Test APK: ❌ Failed (`assembleReleaseAndroidTest` task not found) -- iOS: ✅ Built successfully - ---- - -## New Bug Found - -### BUG: Crate Detection Fails Without `--crate-path` - -**Severity:** CRITICAL (build won't start) - -**Symptom:** -``` -build error: Benchmark crate 'bench-mobile' not found. - -Searched locations: -- /Users/.../bench-mobile/bench-mobile/Cargo.toml -- /Users/.../bench-mobile/crates/bench-mobile/Cargo.toml -``` - -**Root Cause:** mobench looks for nested directories (`bench-mobile/bench-mobile/` or `bench-mobile/crates/bench-mobile/`) instead of checking if the current directory contains a valid crate with a matching name. - -**Workaround:** Use `--crate-path .` flag: -```bash -mobench build --target android --release --crate-path . -``` - -**Fix Required:** mobench should check if the current directory's `Cargo.toml` has a matching `[package] name`. - ---- - -## Confirmed Bugs (Still Present from 0.1.11) - -### 1. Test APK Task Not Found - -**Status:** STILL PRESENT -**Impact:** Android build reports failure even though main APK builds successfully - -``` -Task 'assembleReleaseAndroidTest' not found in root project 'bench_mobile-android' -``` - -**Workaround:** Add to `app/build.gradle`: -```gradle -android { - defaultConfig { - testBuildType "release" - } -} -``` - ---- - -### 2. Hardcoded local.properties - -**Status:** STILL PRESENT -**File:** `target/mobench/android/local.properties` -```properties -sdk.dir=/Users/dcbuilder/Library/Android/sdk -``` - -**Impact:** Breaks builds on other machines. - ---- - -### 3. Source Files Not in Package Directory - -**Status:** STILL PRESENT - -**Current:** -- `app/src/main/java/MainActivity.kt` -- `app/src/androidTest/java/MainActivityTest.kt` - -**Expected:** -- `app/src/main/java/dev/world/bench_mobile/MainActivity.kt` -- `app/src/androidTest/java/dev/world/bench_mobile/MainActivityTest.kt` - -**Impact:** Kotlin files with `package dev.world.bench_mobile` must be in matching directory structure. - ---- - -### 4. iOS Bundle ID Duplication - -**Status:** STILL PRESENT -**File:** `target/mobench/ios/BenchRunner/project.yml` - -**Current:** `dev.world.benchmobile.benchmobile` (duplicated) -**Expected:** `dev.world.benchmobile` - ---- - -### 5. Cross-Platform Naming Inconsistency - -**Android:** `dev.world.bench_mobile` (snake_case) -**iOS:** `dev.world.benchmobile` (camelCase) - ---- - -### 6. Version String Mismatch - -**Android:** `0.1` -**iOS:** `1.0` - ---- - -## Silent Failure Issues - -### CRITICAL: UniFFI Cleanup Errors Swallowed - -**File:** `app/src/main/java/uniffi/bench_mobile/bench_mobile.kt:895-905` -```kotlin -} catch (e: Throwable) { - // swallow -} -``` - -All exceptions during `destroy()` are silently discarded, hiding memory leaks and native crashes. - ---- - -### CRITICAL: Broad Exception Catch Without Logging - -**File:** `app/src/main/java/MainActivity.kt:59-61` -```kotlin -} catch (e: Exception) { - "Unexpected error: ${e.message}" -} -``` - -Catches all exceptions but doesn't log them, making production debugging impossible. - ---- - -### HIGH: Silent Config Fallbacks - -Both platforms silently fall back to defaults when config parsing fails: -- Android: `MainActivity.kt:191-197` - logs but user isn't notified -- iOS: `BenchRunnerFFI.swift:35-52` - no logging at all for invalid numeric values - ---- - -## What's Working - -✅ APK filename detection (parses `output-metadata.json`) -✅ `proguard-rules.pro` generated with correct JNA/UniFFI rules -✅ iOS bundle identifier format (no invalid characters) -✅ Gradle wrapper generated -✅ `gradle.properties` generated -✅ All template placeholders replaced -✅ x86_64 iOS simulator support (universal binary) -✅ iOS xcframework code-signed - ---- - -## Build Outputs - -### Android -``` -Location: target/mobench/android/ -APK: app/build/outputs/apk/release/app-release-unsigned.apk -Size: 133,561,304 bytes (127 MB) -Status: ✅ Built successfully -``` - -### iOS -``` -Location: target/mobench/ios/ -Framework: bench_mobile.xcframework/ -Architectures: ios-arm64, ios-arm64_x86_64-simulator -Status: ✅ Built successfully -``` - ---- - -## Priority Fixes for Next Release - -### P0 (Blocker) -1. **Fix crate detection** - Check current directory Cargo.toml, not just nested paths -2. **Fix test APK task** - Use `assembleDebugAndroidTest` or add `testBuildType` config - -### P1 (Should Fix) -3. Don't generate `local.properties` with hardcoded paths -4. Fix source file directory structure (place in package path) -5. Log UniFFI cleanup errors instead of swallowing - -### P2 (Nice to Have) -6. Fix iOS bundle ID duplication -7. Standardize package naming across platforms -8. Align version strings -9. Add user-visible config fallback warnings - ---- - -## Test Commands - -```bash -# Build with local mobench (requires --crate-path flag) -cd bench-mobile -mobench build --target android --release --verbose --crate-path . -mobench build --target ios --release --verbose --crate-path . - -# Verify APK exists despite error -ls -la target/mobench/android/app/build/outputs/apk/release/ - -# Verify iOS xcframework -ls -la target/mobench/ios/bench_mobile.xcframework/ -``` - ---- - -## Agent Analysis - -Three debugging agents were deployed in parallel: - -1. **Code Reviewer** - Confirmed test task issue, found source file directory structure bug -2. **Silent Failure Hunter** - Found 9 error handling issues (2 critical, 4 high, 3 medium) -3. **Explorer** - Found bundle ID duplication, naming inconsistencies, version mismatch - ---- - -## Comparison: 0.1.11 vs Local Build - -| Issue | 0.1.11 | Local Build | -|-------|--------|-------------| -| Crate detection | ✅ | ❌ NEW BUG | -| Test APK task | ❌ | ❌ | -| local.properties | ❌ | ❌ | -| Source file paths | ❌ | ❌ | -| iOS bundle ID dupe | ❌ | ❌ | -| proguard-rules.pro | ✅ | ✅ | -| APK detection | ✅ | ✅ | -| iOS bundle ID chars | ✅ | ✅ | From 9dbe9782f5bc15ef944818245f4fcc60c618ee2a Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 18:33:26 +0100 Subject: [PATCH 061/196] chore: bump to 0.1.15, add comfy-table dependency --- Cargo.lock | 92 +++++++++++++++++++++++++++++++++++++-- Cargo.toml | 2 +- crates/mobench/Cargo.toml | 1 + 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c7b342..509c454 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,6 +314,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -323,6 +334,29 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -363,6 +397,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -906,6 +949,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -968,10 +1017,11 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.14" +version = "0.1.15" dependencies = [ "anyhow", "clap", + "comfy-table", "dotenvy", "inventory", "jsonschema", @@ -989,7 +1039,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.14" +version = "0.1.15" dependencies = [ "proc-macro2", "quote", @@ -998,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.14" +version = "0.1.15" dependencies = [ "anyhow", "include_dir", @@ -1472,7 +1522,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.14" +version = "0.1.15" dependencies = [ "camino", "mobench-sdk", @@ -1982,6 +2032,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "uniffi" version = "0.28.3" @@ -2284,6 +2346,28 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index db2ae9c..8acde87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.14" +version = "0.1.15" [workspace.dependencies] anyhow = "1" diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 9fdda8e..2ab70b6 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -46,6 +46,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" dotenvy = "0.15" time.workspace = true sha2 = "0.10" +comfy-table = "7" [dev-dependencies] tempfile = "3" From 41d49df688f0416683f03a5d3e5a9bffcf716360 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 18:36:41 +0100 Subject: [PATCH 062/196] feat: add ci summarize subcommand scaffold with args --- crates/mobench/src/lib.rs | 52 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index fa2f230..c772aee 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -477,6 +477,8 @@ enum CiCommand { }, /// Run a full CI benchmark flow with stable output contract. Run(CiRunArgs), + /// Summarize benchmark results with device metrics. + Summarize(CiSummarizeArgs), } #[derive(Subcommand, Debug)] @@ -661,6 +663,45 @@ struct CiRunArgs { mobench_ref: Option, } +#[derive(Args, Debug, Clone)] +struct CiSummarizeArgs { + /// BrowserStack build ID to fetch metrics from (online mode). + #[arg(long)] + build_id: Option, + + /// Directory containing summary.json/CSV results (offline mode). + #[arg(long)] + results_dir: Option, + + /// Output format: table (terminal), markdown, or json. + #[arg(long, value_enum, default_value_t = SummarizeFormat::Table)] + output_format: SummarizeFormat, + + /// Write output to file in addition to stdout. + #[arg(long)] + output_file: Option, + + /// Fetch device hardware specs (chipset, RAM) from BrowserStack. + #[arg(long)] + include_device_specs: bool, + + /// Show per-iteration breakdown instead of just aggregates. + #[arg(long)] + verbose: bool, + + /// Platform filter (show only one platform). + #[arg(long, value_enum)] + platform: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum SummarizeFormat { + Table, + Markdown, + Json, +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] enum CiTarget { @@ -1398,6 +1439,9 @@ pub fn run() -> Result<()> { CiCommand::Run(args) => { cmd_ci_run(args)?; } + CiCommand::Summarize(args) => { + cmd_ci_summarize(args)?; + } }, Command::Fetch { target, @@ -2018,6 +2062,14 @@ fn ci_metadata_from_args(args: &CiRunArgs) -> CiContractMetadata { } } +fn cmd_ci_summarize(args: CiSummarizeArgs) -> Result<()> { + if args.build_id.is_none() && args.results_dir.is_none() { + anyhow::bail!("Either --build-id or --results-dir must be provided"); + } + eprintln!("ci summarize: not yet implemented"); + Ok(()) +} + fn cmd_ci_run_single( args: &CiRunArgs, target: MobileTarget, From a7c2ec70289eb7d37f9ba33ec47b6289ad287a03 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 18:39:46 +0100 Subject: [PATCH 063/196] feat: add summarize data types module --- crates/mobench/src/lib.rs | 1 + crates/mobench/src/summarize.rs | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 crates/mobench/src/summarize.rs diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index c772aee..b148d64 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -138,6 +138,7 @@ use browserstack::{BrowserStackAuth, BrowserStackClient}; mod browserstack; pub mod config; +pub mod summarize; /// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. #[derive(Parser, Debug)] diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs new file mode 100644 index 0000000..c17bd1b --- /dev/null +++ b/crates/mobench/src/summarize.rs @@ -0,0 +1,61 @@ +//! Types and logic for the `ci summarize` command. + +use serde::{Deserialize, Serialize}; + +/// A fully-assembled summary ready for rendering. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SummarizeReport { + pub platforms: Vec, +} + +/// Results for a single platform (iOS or Android). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlatformReport { + pub platform: String, + pub device: DeviceInfo, + pub benchmarks: Vec, + pub iterations: u32, + pub warmup: u32, +} + +/// Device information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceInfo { + pub name: String, + pub os: String, + pub os_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub chipset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ram_gb: Option, +} + +/// Aggregated result for a single benchmark function. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkResult { + pub name: String, + pub label: String, + pub timing: TimingStats, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_usage: Option, +} + +/// Timing statistics across all iterations (in milliseconds). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimingStats { + pub avg_ms: f64, + pub median_ms: f64, + pub best_ms: f64, + pub worst_ms: f64, + pub p95_ms: f64, + pub std_dev_ms: f64, +} + +/// Resource usage metrics from BrowserStack session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceUsage { + pub cpu_avg_percent: Option, + pub cpu_peak_percent: Option, + pub ram_avg_mb: Option, + pub ram_peak_mb: Option, +} From 3ec1aa7ead550a8f77397ac7dd9f81889055d70e Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 18:40:48 +0100 Subject: [PATCH 064/196] feat: implement offline results loading for ci summarize --- crates/mobench/src/summarize.rs | 252 ++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index c17bd1b..41aa467 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -1,6 +1,8 @@ //! Types and logic for the `ci summarize` command. +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use std::path::Path; /// A fully-assembled summary ready for rendering. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -59,3 +61,253 @@ pub struct ResourceUsage { pub ram_avg_mb: Option, pub ram_peak_mb: Option, } + +/// Parse a summary.json value into a [`SummarizeReport`]. +pub fn parse_summary_value(value: &serde_json::Value) -> Result { + let summary = value + .get("summary") + .context("Missing 'summary' key in JSON")?; + + let target = summary + .get("target") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let iterations = summary + .get("iterations") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + let warmup = summary + .get("warmup") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + let device_summaries = summary + .get("device_summaries") + .and_then(|v| v.as_array()) + .context("Missing 'device_summaries'")?; + + let mut platforms = Vec::new(); + + for ds in device_summaries { + let device_str = ds + .get("device") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + let device = parse_device_string(device_str); + + let benchmarks = ds + .get("benchmarks") + .and_then(|v| v.as_array()) + .unwrap_or(&vec![]) + .iter() + .filter_map(|b| parse_benchmark_entry(b).ok()) + .collect(); + + platforms.push(PlatformReport { + platform: target.clone(), + device, + benchmarks, + iterations, + warmup, + }); + } + + Ok(SummarizeReport { platforms }) +} + +fn parse_device_string(s: &str) -> DeviceInfo { + let (name, os_version) = match s.rsplit_once('-') { + Some((n, v)) => (n.to_string(), v.to_string()), + None => (s.to_string(), "unknown".to_string()), + }; + + let os = if name.contains("iPhone") || name.contains("iPad") { + "iOS".to_string() + } else { + "Android".to_string() + }; + + DeviceInfo { + name, + os, + os_version, + chipset: None, + ram_gb: None, + } +} + +fn parse_benchmark_entry(value: &serde_json::Value) -> Result { + let name = value + .get("function") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let label = humanize_benchmark_name(&name); + + let ns_to_ms = |key: &str| -> f64 { + value + .get(key) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) + / 1_000_000.0 + }; + + let timing = TimingStats { + avg_ms: ns_to_ms("mean_ns"), + median_ms: ns_to_ms("median_ns"), + best_ms: ns_to_ms("min_ns"), + worst_ms: ns_to_ms("max_ns"), + p95_ms: ns_to_ms("p95_ns"), + std_dev_ms: 0.0, + }; + + Ok(BenchmarkResult { + name, + label, + timing, + resource_usage: None, + }) +} + +fn humanize_benchmark_name(name: &str) -> String { + let s = name + .replace("bench_", "") + .replace("_generation", "") + .replace("_only", ""); + + if s.contains("nullifier") { + format!("\u{03C0}2 {}", s.replace('_', "-")) + } else if s.contains("query") { + format!("\u{03C0}1 {}", s.replace('_', "-")) + } else { + s.replace('_', "-") + } +} + +/// Load all summary JSON files from a results directory. +pub fn load_results_dir(dir: &Path) -> Result { + let mut all_platforms = Vec::new(); + + for entry in std::fs::read_dir(dir).context("Failed to read results directory")? { + let entry = entry?; + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + let content = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + let value: serde_json::Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", path.display()))?; + + if let Ok(report) = parse_summary_value(&value) { + all_platforms.extend(report.platforms); + } + } + } + + if all_platforms.is_empty() { + anyhow::bail!("No valid summary JSON files found in {}", dir.display()); + } + + Ok(SummarizeReport { + platforms: all_platforms, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_summary_json() -> serde_json::Value { + serde_json::json!({ + "summary": { + "generated_at": "2026-02-26T12:00:00Z", + "target": "ios", + "function": "bench_nullifier_proving_only", + "iterations": 30, + "warmup": 5, + "devices": ["iPhone 14-16.0"], + "device_summaries": [{ + "device": "iPhone 14-16.0", + "benchmarks": [{ + "function": "bench_nullifier_proving_only", + "samples": 30, + "mean_ns": 1204500000_u64, + "median_ns": 1198000000_u64, + "p95_ns": 1290000000_u64, + "min_ns": 1180200000_u64, + "max_ns": 1298100000_u64 + }] + }] + } + }) + } + + #[test] + fn test_parse_summary_json() { + let json = sample_summary_json(); + let report = parse_summary_value(&json).unwrap(); + assert_eq!(report.platforms.len(), 1); + let p = &report.platforms[0]; + assert_eq!(p.platform, "ios"); + assert_eq!(p.iterations, 30); + assert_eq!(p.warmup, 5); + assert_eq!(p.benchmarks.len(), 1); + let b = &p.benchmarks[0]; + assert!((b.timing.avg_ms - 1204.5).abs() < 0.1); + assert!((b.timing.best_ms - 1180.2).abs() < 0.1); + assert!((b.timing.worst_ms - 1298.1).abs() < 0.1); + } + + #[test] + fn test_parse_device_string_ios() { + let d = parse_device_string("iPhone 14-16.0"); + assert_eq!(d.name, "iPhone 14"); + assert_eq!(d.os, "iOS"); + assert_eq!(d.os_version, "16.0"); + } + + #[test] + fn test_parse_device_string_android() { + let d = parse_device_string("Google Pixel 6-12.0"); + assert_eq!(d.name, "Google Pixel 6"); + assert_eq!(d.os, "Android"); + assert_eq!(d.os_version, "12.0"); + } + + #[test] + fn test_humanize_benchmark_name() { + assert_eq!( + humanize_benchmark_name("bench_nullifier_proving_only"), + "\u{03C0}2 nullifier-proving" + ); + assert_eq!( + humanize_benchmark_name("bench_query_proof_generation"), + "\u{03C0}1 query-proof" + ); + } + + #[test] + fn test_load_results_dir() { + let dir = tempfile::tempdir().unwrap(); + let json = sample_summary_json(); + std::fs::write( + dir.path().join("test.json"), + serde_json::to_string(&json).unwrap(), + ) + .unwrap(); + + let report = load_results_dir(dir.path()).unwrap(); + assert_eq!(report.platforms.len(), 1); + assert_eq!(report.platforms[0].platform, "ios"); + } + + #[test] + fn test_load_results_dir_empty() { + let dir = tempfile::tempdir().unwrap(); + assert!(load_results_dir(dir.path()).is_err()); + } +} From 8af546759724a8750b67f88a7ab70e7bf145ff64 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 18:44:30 +0100 Subject: [PATCH 065/196] feat: implement table, markdown, and json rendering for ci summarize --- crates/mobench/src/summarize.rs | 280 ++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index 41aa467..5de24e8 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -1,6 +1,7 @@ //! Types and logic for the `ci summarize` command. use anyhow::{Context, Result}; +use comfy_table::{presets::UTF8_FULL, Attribute, Cell, ContentArrangement, Table}; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -217,6 +218,187 @@ pub fn load_results_dir(dir: &Path) -> Result { }) } +/// Render the full report as terminal tables. +pub fn render_table(report: &SummarizeReport) -> String { + let mut output = String::new(); + + for platform in &report.platforms { + if !output.is_empty() { + output.push('\n'); + } + output.push_str(&render_platform_table(platform)); + } + + output +} + +fn render_platform_table(platform: &PlatformReport) -> String { + let mut output = String::new(); + + // Header line + let mut header = format!( + "{} — {} ({} {})", + platform.platform.to_uppercase(), + platform.device.name, + platform.device.os, + platform.device.os_version, + ); + if let Some(chipset) = &platform.device.chipset { + header.push_str(&format!(" · {chipset}")); + } + if let Some(ram) = platform.device.ram_gb { + header.push_str(&format!(" · {ram} GB RAM")); + } + output.push_str(&header); + output.push('\n'); + + let has_resource_usage = platform + .benchmarks + .iter() + .any(|b| b.resource_usage.is_some()); + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic); + + let mut headers = vec!["Benchmark", "Avg ms", "Best", "Worst", "Median", "P95"]; + if has_resource_usage { + headers.extend(["CPU %", "RAM MB"]); + } + table.set_header( + headers + .iter() + .map(|h| Cell::new(h).add_attribute(Attribute::Bold)), + ); + + for bench in &platform.benchmarks { + let mut row = vec![ + Cell::new(&bench.label), + Cell::new(format!("{:.1}", bench.timing.avg_ms)).add_attribute(Attribute::Bold), + Cell::new(format!("{:.1}", bench.timing.best_ms)), + Cell::new(format!("{:.1}", bench.timing.worst_ms)), + Cell::new(format!("{:.1}", bench.timing.median_ms)), + Cell::new(format!("{:.1}", bench.timing.p95_ms)), + ]; + + if has_resource_usage { + if let Some(ru) = &bench.resource_usage { + row.push(Cell::new( + ru.cpu_avg_percent + .map(|v| format!("{v:.0}%")) + .unwrap_or_else(|| "—".to_string()), + )); + row.push(Cell::new( + ru.ram_avg_mb + .map(|v| format!("{v:.0}")) + .unwrap_or_else(|| "—".to_string()), + )); + } else { + row.push(Cell::new("—")); + row.push(Cell::new("—")); + } + } + + table.add_row(row); + } + + output.push_str(&table.to_string()); + output.push_str(&format!( + "\n {} iterations · {} warmup · avg is primary metric\n", + platform.iterations, platform.warmup + )); + + output +} + +/// Render the report as a markdown table. +pub fn render_markdown(report: &SummarizeReport) -> String { + let mut output = String::new(); + + for platform in &report.platforms { + if !output.is_empty() { + output.push('\n'); + } + + let mut header = format!( + "### {} — {} ({} {})", + platform.platform.to_uppercase(), + platform.device.name, + platform.device.os, + platform.device.os_version, + ); + if let Some(chipset) = &platform.device.chipset { + header.push_str(&format!(" · {chipset}")); + } + if let Some(ram) = platform.device.ram_gb { + header.push_str(&format!(" · {ram} GB RAM")); + } + output.push_str(&header); + output.push_str("\n\n"); + + let has_ru = platform + .benchmarks + .iter() + .any(|b| b.resource_usage.is_some()); + + if has_ru { + output.push_str( + "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU % | RAM MB |\n", + ); + output.push_str( + "|-----------|--------|------|-------|--------|-----|-------|--------|\n", + ); + } else { + output.push_str("| Benchmark | Avg ms | Best | Worst | Median | P95 |\n"); + output.push_str("|-----------|--------|------|-------|--------|-----|\n"); + } + + for bench in &platform.benchmarks { + let mut row = format!( + "| {} | **{:.1}** | {:.1} | {:.1} | {:.1} | {:.1} |", + bench.label, + bench.timing.avg_ms, + bench.timing.best_ms, + bench.timing.worst_ms, + bench.timing.median_ms, + bench.timing.p95_ms, + ); + + if has_ru { + if let Some(ru) = &bench.resource_usage { + row.push_str(&format!( + " {} | {} |", + ru.cpu_avg_percent + .map(|v| format!("{v:.0}%")) + .unwrap_or_else(|| "—".into()), + ru.ram_avg_mb + .map(|v| format!("{v:.0}")) + .unwrap_or_else(|| "—".into()), + )); + } else { + row.push_str(" — | — |"); + } + } + + output.push_str(&row); + output.push('\n'); + } + + output.push_str(&format!( + "\n*{} iterations · {} warmup · avg is primary metric*\n", + platform.iterations, platform.warmup + )); + } + + output +} + +/// Render the report as JSON. +pub fn render_json(report: &SummarizeReport) -> Result { + serde_json::to_string_pretty(report).context("Failed to serialize report as JSON") +} + #[cfg(test)] mod tests { use super::*; @@ -310,4 +492,102 @@ mod tests { let dir = tempfile::tempdir().unwrap(); assert!(load_results_dir(dir.path()).is_err()); } + + #[test] + fn test_render_table_output() { + let report = SummarizeReport { + platforms: vec![PlatformReport { + platform: "ios".to_string(), + device: DeviceInfo { + name: "iPhone 14".to_string(), + os: "iOS".to_string(), + os_version: "16.0".to_string(), + chipset: Some("A15 Bionic".to_string()), + ram_gb: Some(6.0), + }, + benchmarks: vec![BenchmarkResult { + name: "bench_nullifier_proving_only".to_string(), + label: "\u{03C0}2 nullifier-proving".to_string(), + timing: TimingStats { + avg_ms: 1204.5, + median_ms: 1198.0, + best_ms: 1180.2, + worst_ms: 1298.1, + p95_ms: 1290.0, + std_dev_ms: 35.2, + }, + resource_usage: Some(ResourceUsage { + cpu_avg_percent: Some(94.0), + cpu_peak_percent: Some(98.0), + ram_avg_mb: Some(623.0), + ram_peak_mb: Some(650.0), + }), + }], + iterations: 30, + warmup: 5, + }], + }; + let output = render_table(&report); + assert!(output.contains("iPhone 14")); + assert!(output.contains("1204.5")); + assert!(output.contains("A15 Bionic")); + assert!(output.contains("6 GB RAM")); + } + + #[test] + fn test_render_markdown_output() { + let report = SummarizeReport { + platforms: vec![PlatformReport { + platform: "ios".to_string(), + device: DeviceInfo { + name: "iPhone 14".to_string(), + os: "iOS".to_string(), + os_version: "16.0".to_string(), + chipset: None, + ram_gb: None, + }, + benchmarks: vec![BenchmarkResult { + name: "bench_nullifier_proving_only".to_string(), + label: "\u{03C0}2 nullifier-proving".to_string(), + timing: TimingStats { + avg_ms: 1204.5, + median_ms: 1198.0, + best_ms: 1180.2, + worst_ms: 1298.1, + p95_ms: 1290.0, + std_dev_ms: 35.2, + }, + resource_usage: None, + }], + iterations: 30, + warmup: 5, + }], + }; + let output = render_markdown(&report); + assert!(output.contains("### IOS")); + assert!(output.contains("**1204.5**")); + assert!(output.contains("| Benchmark |")); + } + + #[test] + fn test_render_json_output() { + let report = SummarizeReport { + platforms: vec![PlatformReport { + platform: "ios".to_string(), + device: DeviceInfo { + name: "iPhone 14".to_string(), + os: "iOS".to_string(), + os_version: "16.0".to_string(), + chipset: None, + ram_gb: None, + }, + benchmarks: vec![], + iterations: 30, + warmup: 5, + }], + }; + let json_str = render_json(&report).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + assert_eq!(parsed["platforms"][0]["platform"], "ios"); + } } From 0d522b04dc95a03963e155fc5210181a00d88dd2 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 18:48:24 +0100 Subject: [PATCH 066/196] feat: add session details and build summary to BrowserStack client --- crates/mobench/src/browserstack.rs | 107 +++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index 5cfb668..d684cf3 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -783,6 +783,85 @@ impl BrowserStackClient { Ok((benchmark_results, performance_metrics)) } } + + /// Fetch session details from BrowserStack API. + pub fn get_session_details( + &self, + build_id: &str, + session_id: &str, + ) -> Result { + let path = format!("/app-automate/builds/{build_id}/sessions/{session_id}"); + let value = self.get_json(&path)?; + + let automation_session = value + .get("automation_session") + .context("Missing automation_session in response")?; + + Ok(SessionDetails { + device: automation_session + .get("device") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + os: automation_session + .get("os") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + os_version: automation_session + .get("os_version") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + duration: automation_session + .get("duration") + .and_then(|v| v.as_u64()), + }) + } + + /// Fetch build details with all sessions and performance data. + pub fn get_build_summary(&self, build_id: &str, platform: &str) -> Result { + let status = match platform { + "ios" => self.get_xcuitest_build_status(build_id)?, + _ => self.get_espresso_build_status(build_id)?, + }; + + let mut sessions = Vec::new(); + for device_session in &status.devices { + let details = self + .get_session_details(build_id, &device_session.session_id) + .ok(); + + let perf = device_session + .device_logs + .as_ref() + .and_then(|logs| self.extract_performance_metrics(logs).ok()); + + sessions.push(SessionSummary { + session_id: device_session.session_id.clone(), + device: details + .as_ref() + .map(|d| d.device.clone()) + .unwrap_or_else(|| device_session.device.clone()), + os: details + .as_ref() + .map(|d| d.os.clone()) + .unwrap_or_default(), + os_version: details + .as_ref() + .map(|d| d.os_version.clone()) + .unwrap_or_default(), + duration_secs: details.as_ref().and_then(|d| d.duration), + performance: perf, + }); + } + + Ok(BuildSummary { + build_id: build_id.to_string(), + status: status.status, + sessions, + }) + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1289,6 +1368,34 @@ fn validate_device_spec( }) } +/// Details about a single BrowserStack session (from the session API). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionDetails { + pub device: String, + pub os: String, + pub os_version: String, + pub duration: Option, +} + +/// High-level summary of a BrowserStack build with all sessions and their metrics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildSummary { + pub build_id: String, + pub status: String, + pub sessions: Vec, +} + +/// Summary of a single session within a build. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionSummary { + pub session_id: String, + pub device: String, + pub os: String, + pub os_version: String, + pub duration_secs: Option, + pub performance: Option, +} + /// Format a helpful error message for missing BrowserStack credentials. pub fn format_credentials_error(_missing_username: bool, _missing_access_key: bool) -> String { let mut message = String::from("BrowserStack credentials not configured.\n\n"); From a1e0c5b7023cf03249376acfaa02f456f768085d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 18:48:28 +0100 Subject: [PATCH 067/196] feat: implement BrowserStack API enrichment for ci summarize --- crates/mobench/src/summarize.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index 5de24e8..578c45f 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -394,6 +394,39 @@ pub fn render_markdown(report: &SummarizeReport) -> String { output } +/// Enrich an offline report with BrowserStack session metrics. +pub fn enrich_with_browserstack( + report: &mut SummarizeReport, + build_summary: &crate::browserstack::BuildSummary, +) { + for platform in &mut report.platforms { + for session in &build_summary.sessions { + // Update device info from session details + if !session.os.is_empty() { + platform.device.os = session.os.clone(); + platform.device.os_version = session.os_version.clone(); + if platform.device.name == "unknown" { + platform.device.name = session.device.clone(); + } + } + + // Enrich benchmarks with performance metrics + if let Some(perf) = &session.performance { + for bench in &mut platform.benchmarks { + if bench.resource_usage.is_none() { + bench.resource_usage = Some(ResourceUsage { + cpu_avg_percent: perf.cpu.as_ref().map(|c| c.average_percent), + cpu_peak_percent: perf.cpu.as_ref().map(|c| c.peak_percent), + ram_avg_mb: perf.memory.as_ref().map(|m| m.average_mb), + ram_peak_mb: perf.memory.as_ref().map(|m| m.peak_mb), + }); + } + } + } + } + } +} + /// Render the report as JSON. pub fn render_json(report: &SummarizeReport) -> Result { serde_json::to_string_pretty(report).context("Failed to serialize report as JSON") From 1b30f124fad3a414496712ffc5da6b9c7b1b3ab7 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 18:48:31 +0100 Subject: [PATCH 068/196] feat: wire up ci summarize command handler --- crates/mobench/src/lib.rs | 65 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index b148d64..e3338a0 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -2067,7 +2067,70 @@ fn cmd_ci_summarize(args: CiSummarizeArgs) -> Result<()> { if args.build_id.is_none() && args.results_dir.is_none() { anyhow::bail!("Either --build-id or --results-dir must be provided"); } - eprintln!("ci summarize: not yet implemented"); + + // Load the report from offline results + let mut report = if let Some(ref dir) = args.results_dir { + summarize::load_results_dir(dir)? + } else { + // If only build_id, we need results_dir too for now + anyhow::bail!("--results-dir is required (--build-id enriches existing results)"); + }; + + // Enrich with BrowserStack data if build_id provided + if let Some(ref build_id) = args.build_id { + match resolve_browserstack_credentials(None) { + Ok(creds) => { + match BrowserStackClient::new( + BrowserStackAuth { + username: creds.username, + access_key: creds.access_key, + }, + creds.project, + ) { + Ok(client) => { + // Try iOS first, then Android + let build_summary = client + .get_build_summary(build_id, "ios") + .or_else(|_| client.get_build_summary(build_id, "android")); + + match build_summary { + Ok(summary) => { + summarize::enrich_with_browserstack(&mut report, &summary) + } + Err(e) => { + eprintln!("Warning: could not fetch BrowserStack data: {e}") + } + } + } + Err(e) => eprintln!("Warning: could not create BrowserStack client: {e}"), + } + } + Err(e) => eprintln!("Warning: BrowserStack credentials not available: {e}"), + } + } + + // Filter by platform if requested + if let Some(platform) = &args.platform { + let target = platform.as_str(); + report.platforms.retain(|p| p.platform == target); + } + + // Render output + let output = match args.output_format { + SummarizeFormat::Table => summarize::render_table(&report), + SummarizeFormat::Markdown => summarize::render_markdown(&report), + SummarizeFormat::Json => summarize::render_json(&report)?, + }; + + println!("{output}"); + + // Optionally write to file + if let Some(ref path) = args.output_file { + std::fs::write(path, &output) + .with_context(|| format!("Failed to write output to {}", path.display()))?; + eprintln!("Output written to {}", path.display()); + } + Ok(()) } From 9f574605db77e917b3dade4c9626b857a4585d76 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 18:52:01 +0100 Subject: [PATCH 069/196] feat: add ci check-run subcommand scaffold --- Cargo.toml | 2 +- crates/mobench/src/lib.rs | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8acde87..46c29e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ version = "0.1.15" [workspace.dependencies] anyhow = "1" -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index e3338a0..f3e7951 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -480,6 +480,8 @@ enum CiCommand { Run(CiRunArgs), /// Summarize benchmark results with device metrics. Summarize(CiSummarizeArgs), + /// Create a GitHub Check Run with benchmark results. + CheckRun(CiCheckRunArgs), } #[derive(Subcommand, Debug)] @@ -695,6 +697,37 @@ struct CiSummarizeArgs { platform: Option, } +#[derive(Args, Debug, Clone)] +struct CiCheckRunArgs { + /// Path to summary JSON with benchmark results. + #[arg(long)] + results: PathBuf, + + /// GitHub repository (owner/repo format). + #[arg(long)] + repo: String, + + /// Git commit SHA to annotate. + #[arg(long)] + sha: String, + + /// GitHub App token (from actions/create-github-app-token). + #[arg(long, env = "GITHUB_TOKEN")] + token: String, + + /// Check Run name displayed in the PR. + #[arg(long, default_value = "Mobench")] + name: String, + + /// Optional baseline JSON for regression detection. + #[arg(long)] + baseline: Option, + + /// Regression threshold percentage. + #[arg(long, default_value_t = 5.0)] + regression_threshold_pct: f64, +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] enum SummarizeFormat { @@ -1443,6 +1476,9 @@ pub fn run() -> Result<()> { CiCommand::Summarize(args) => { cmd_ci_summarize(args)?; } + CiCommand::CheckRun(args) => { + cmd_ci_check_run(args)?; + } }, Command::Fetch { target, @@ -2134,6 +2170,11 @@ fn cmd_ci_summarize(args: CiSummarizeArgs) -> Result<()> { Ok(()) } +fn cmd_ci_check_run(_args: CiCheckRunArgs) -> Result<()> { + eprintln!("ci check-run: not yet implemented"); + Ok(()) +} + fn cmd_ci_run_single( args: &CiRunArgs, target: MobileTarget, From 542e28a651579bb02be3090f6cbdeb3de35926e7 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 18:52:40 +0100 Subject: [PATCH 070/196] feat: add GitHub Checks API client module --- crates/mobench/src/github.rs | 101 +++++++++++++++++++++++++++++++++++ crates/mobench/src/lib.rs | 1 + 2 files changed, 102 insertions(+) create mode 100644 crates/mobench/src/github.rs diff --git a/crates/mobench/src/github.rs b/crates/mobench/src/github.rs new file mode 100644 index 0000000..49b7b5b --- /dev/null +++ b/crates/mobench/src/github.rs @@ -0,0 +1,101 @@ +//! GitHub Checks API client for creating Check Runs. + +use anyhow::{Context, Result}; +use serde::Serialize; + +const GITHUB_API_BASE: &str = "https://api.github.com"; + +pub struct GitHubClient { + http: reqwest::blocking::Client, + token: String, +} + +#[derive(Debug, Serialize)] +struct CreateCheckRunRequest { + name: String, + head_sha: String, + status: String, + conclusion: String, + output: CheckRunOutput, +} + +#[derive(Debug, Serialize)] +struct CheckRunOutput { + title: String, + summary: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + annotations: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CheckRunAnnotation { + pub path: String, + pub start_line: u32, + pub end_line: u32, + pub annotation_level: String, + pub message: String, + pub title: String, +} + +pub struct CheckRunResult { + pub conclusion: String, + pub annotations_count: usize, +} + +impl GitHubClient { + pub fn new(token: String) -> Result { + let http = reqwest::blocking::Client::builder() + .user_agent("mobench") + .build() + .context("Failed to create HTTP client")?; + + Ok(Self { http, token }) + } + + pub fn create_check_run( + &self, + repo: &str, + sha: &str, + name: &str, + conclusion: &str, + title: &str, + summary: &str, + annotations: Vec, + ) -> Result { + let url = format!("{GITHUB_API_BASE}/repos/{repo}/check-runs"); + let annotations_count = annotations.len(); + + let body = CreateCheckRunRequest { + name: name.to_string(), + head_sha: sha.to_string(), + status: "completed".to_string(), + conclusion: conclusion.to_string(), + output: CheckRunOutput { + title: title.to_string(), + summary: summary.to_string(), + annotations, + }, + }; + + let response = self + .http + .post(&url) + .header("Authorization", format!("Bearer {}", self.token)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .json(&body) + .send() + .context("Failed to send Check Run request")?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().unwrap_or_default(); + anyhow::bail!("GitHub API returned {status}: {text}"); + } + + Ok(CheckRunResult { + conclusion: conclusion.to_string(), + annotations_count, + }) + } +} diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index f3e7951..e26fc99 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -138,6 +138,7 @@ use browserstack::{BrowserStackAuth, BrowserStackClient}; mod browserstack; pub mod config; +mod github; pub mod summarize; /// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. From 8f0ddb5daafbc06da2f293f4bad9c9a24885cce0 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 18:53:38 +0100 Subject: [PATCH 071/196] feat: wire up ci check-run with regression detection --- crates/mobench/src/lib.rs | 81 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index e26fc99..6fa6d7d 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -2171,8 +2171,85 @@ fn cmd_ci_summarize(args: CiSummarizeArgs) -> Result<()> { Ok(()) } -fn cmd_ci_check_run(_args: CiCheckRunArgs) -> Result<()> { - eprintln!("ci check-run: not yet implemented"); +fn cmd_ci_check_run(args: CiCheckRunArgs) -> Result<()> { + // Load results + let content = std::fs::read_to_string(&args.results) + .with_context(|| format!("Failed to read {}", args.results.display()))?; + let value: serde_json::Value = serde_json::from_str(&content)?; + let report = summarize::parse_summary_value(&value)?; + + // Generate markdown summary + let summary_md = summarize::render_markdown(&report); + + // Check for regressions if baseline provided + let mut annotations = Vec::new(); + let mut has_regression = false; + + if let Some(baseline_path) = &args.baseline { + let baseline_content = std::fs::read_to_string(baseline_path) + .with_context(|| format!("Failed to read baseline {}", baseline_path.display()))?; + let baseline_value: serde_json::Value = serde_json::from_str(&baseline_content)?; + let baseline_report = summarize::parse_summary_value(&baseline_value)?; + + for platform in &report.platforms { + for bench in &platform.benchmarks { + let baseline_bench = baseline_report + .platforms + .iter() + .filter(|p| p.platform == platform.platform) + .flat_map(|p| &p.benchmarks) + .find(|b| b.name == bench.name); + + if let Some(base) = baseline_bench { + if base.timing.avg_ms > 0.0 { + let pct_change = + (bench.timing.avg_ms - base.timing.avg_ms) / base.timing.avg_ms * 100.0; + + if pct_change > args.regression_threshold_pct { + has_regression = true; + annotations.push(github::CheckRunAnnotation { + path: "src/lib.rs".to_string(), + start_line: 1, + end_line: 1, + annotation_level: "warning".to_string(), + message: format!( + "{} regressed {pct_change:+.1}% ({:.1}ms \u{2192} {:.1}ms)", + bench.label, base.timing.avg_ms, bench.timing.avg_ms + ), + title: format!("Regression: {}", bench.label), + }); + } + } + } + } + } + } + + let conclusion = if has_regression { "failure" } else { "success" }; + + let bench_count: usize = report.platforms.iter().map(|p| p.benchmarks.len()).sum(); + let title = if has_regression { + format!("{bench_count} benchmarks \u{2014} {} regressed", annotations.len()) + } else { + format!("{bench_count} benchmarks passed") + }; + + let client = github::GitHubClient::new(args.token)?; + let result = client.create_check_run( + &args.repo, + &args.sha, + &args.name, + conclusion, + &title, + &summary_md, + annotations, + )?; + + eprintln!( + "Check Run created: conclusion={}, annotations={}", + result.conclusion, result.annotations_count + ); + Ok(()) } From 4a260e05f27c25a82a9cb4bc8af819e4f66f639d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 19:14:42 +0100 Subject: [PATCH 072/196] feat: add reusable benchmark workflow for consumer repos --- .github/workflows/reusable-bench.yml | 550 +++++++++++++++++++++++++++ 1 file changed, 550 insertions(+) create mode 100644 .github/workflows/reusable-bench.yml diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml new file mode 100644 index 0000000..1876380 --- /dev/null +++ b/.github/workflows/reusable-bench.yml @@ -0,0 +1,550 @@ +name: Reusable Mobile Benchmark (BrowserStack) + +on: + workflow_call: + inputs: + crate_path: + description: "Path to the benchmark crate in the caller repo" + required: true + type: string + functions: + description: "JSON array of benchmark function names to run" + required: true + type: string + iterations: + description: "Number of benchmark iterations" + required: false + type: string + default: "30" + warmup: + description: "Number of warmup iterations" + required: false + type: string + default: "5" + platform: + description: "Target platform: android, ios, or both" + required: false + type: string + default: "both" + device_profile: + description: "Device profile: low-spec, mid-spec, high-spec, custom" + required: false + type: string + default: "low-spec" + ios_device: + description: "Custom iOS device name (required when device_profile=custom)" + required: false + type: string + ios_os_version: + description: "Custom iOS OS version (required when device_profile=custom)" + required: false + type: string + android_device: + description: "Custom Android device name (required when device_profile=custom)" + required: false + type: string + android_os_version: + description: "Custom Android OS version (required when device_profile=custom)" + required: false + type: string + rust_targets_ios: + description: "Comma-separated iOS Rust targets" + required: false + type: string + default: "aarch64-apple-ios,aarch64-apple-ios-sim" + rust_targets_android: + description: "Comma-separated Android Rust targets" + required: false + type: string + default: "aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android" + build_release: + description: "Build in release mode" + required: false + type: boolean + default: true + mobench_version: + description: "Mobench version to install" + required: false + type: string + default: "0.1.15" + mobench_ref: + description: "Git ref for mobile-bench-rs (overrides mobench_version when set)" + required: false + type: string + pr_number: + description: "PR number for reporting" + required: false + type: string + requested_by: + description: "Who triggered the run" + required: false + type: string + check_run_name: + description: "GitHub Check Run name" + required: false + type: string + default: "Mobench" + regression_threshold_pct: + description: "Regression threshold percentage" + required: false + type: string + default: "5.0" + secrets: + BROWSERSTACK_USERNAME: + required: true + BROWSERSTACK_ACCESS_KEY: + required: true + MOBENCH_APP_PRIVATE_KEY: + required: false + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + # --------------------------------------------------------------------------- + # iOS BrowserStack benchmark + # --------------------------------------------------------------------------- + ios: + name: iOS BrowserStack benchmark + if: inputs.platform == 'ios' || inputs.platform == 'both' + runs-on: macos-15 + + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + IPHONEOS_DEPLOYMENT_TARGET: "13.0" + CFLAGS_aarch64_apple_ios: "-miphoneos-version-min=13.0" + CFLAGS_aarch64_apple_ios_sim: "-mios-simulator-version-min=13.0" + CFLAGS_x86_64_apple_ios: "-mios-simulator-version-min=13.0" + CARGO_TARGET_AARCH64_APPLE_IOS_RUSTFLAGS: "-C link-arg=-miphoneos-version-min=13.0" + CARGO_TARGET_AARCH64_APPLE_IOS_SIM_RUSTFLAGS: "-C link-arg=-mios-simulator-version-min=13.0" + CARGO_TARGET_X86_64_APPLE_IOS_RUSTFLAGS: "-C link-arg=-mios-simulator-version-min=13.0" + + steps: + - name: Checkout caller repo + uses: actions/checkout@v4 + with: + path: caller + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ inputs.rust_targets_ios }} + + - name: Cache cargo registry/git + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-ios-${{ hashFiles('caller/**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-ios- + + - name: Cache cargo target + uses: actions/cache@v4 + with: + path: caller/target + key: ${{ runner.os }}-target-ios-${{ hashFiles('caller/**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-target-ios- + + - name: Install mobench + shell: bash + env: + MOBENCH_VERSION: ${{ inputs.mobench_version }} + MOBENCH_REF: ${{ inputs.mobench_ref }} + run: | + if [ -n "$MOBENCH_REF" ]; then + cargo install mobench \ + --git https://github.com/worldcoin/mobile-bench-rs \ + --branch "$MOBENCH_REF" \ + --locked + elif [ -n "$MOBENCH_VERSION" ]; then + cargo install mobench --version "$MOBENCH_VERSION" --locked + else + cargo install mobench --locked + fi + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Resolve iOS device + id: resolve_device + working-directory: caller + shell: bash + env: + DEVICE_PROFILE: ${{ inputs.device_profile }} + IOS_DEVICE_OVERRIDE: ${{ inputs.ios_device }} + IOS_OS_VERSION_OVERRIDE: ${{ inputs.ios_os_version }} + run: | + if [ -n "$IOS_DEVICE_OVERRIDE" ] && [ -n "$IOS_OS_VERSION_OVERRIDE" ]; then + echo "device_name=${IOS_DEVICE_OVERRIDE}" >> "$GITHUB_OUTPUT" + echo "os_version=${IOS_OS_VERSION_OVERRIDE}" >> "$GITHUB_OUTPUT" + echo "Using custom device: ${IOS_DEVICE_OVERRIDE} (${IOS_OS_VERSION_OVERRIDE})" + else + output=$(cargo-mobench devices resolve \ + --platform ios \ + --profile "${DEVICE_PROFILE}" \ + --format json 2>&1) || { + echo "::error::Failed to resolve iOS device: $output" + exit 1 + } + device_name=$(echo "$output" | jq -r '.device // .name') + os_version=$(echo "$output" | jq -r '.os_version') + echo "device_name=${device_name}" >> "$GITHUB_OUTPUT" + echo "os_version=${os_version}" >> "$GITHUB_OUTPUT" + echo "Resolved iOS device: ${device_name} (${os_version})" + fi + + - name: Build iOS artifacts + working-directory: caller + shell: bash + env: + RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} + run: | + set -euo pipefail + cargo-mobench build --target ios $RELEASE_FLAG + cargo-mobench package-ipa --method adhoc + cargo-mobench package-xcuitest + test -f target/mobench/ios/BenchRunner.ipa + test -f target/mobench/ios/BenchRunnerUITests.zip + + - name: Run iOS benchmarks + working-directory: caller + shell: bash + env: + FUNCTIONS_JSON: ${{ inputs.functions }} + ITERATIONS: ${{ inputs.iterations }} + WARMUP: ${{ inputs.warmup }} + IOS_DEVICE: ${{ steps.resolve_device.outputs.device_name }} + IOS_OS_VERSION: ${{ steps.resolve_device.outputs.os_version }} + RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} + run: | + set -euo pipefail + mkdir -p target/mobench/ci/ios + + device_spec="${IOS_DEVICE}-${IOS_OS_VERSION}" + echo "Running iOS benchmarks on: ${device_spec}" + + for func in $(echo "$FUNCTIONS_JSON" | jq -r '.[]'); do + slug=$(echo "$func" | tr ':' '_' | tr '/' '-') + echo "::group::Benchmark: ${func}" + cargo-mobench run \ + --target ios \ + --function "${func}" \ + --iterations "${ITERATIONS}" \ + --warmup "${WARMUP}" \ + --devices "${device_spec}" \ + $RELEASE_FLAG \ + --fetch \ + --summary-csv \ + --output "target/mobench/ci/ios/${slug}.json" || { + echo "::warning::iOS benchmark failed for function: ${func}" + } + echo "::endgroup::" + done + + - name: Upload iOS results + if: always() + uses: actions/upload-artifact@v4 + with: + name: mobench-results-ios + path: | + caller/target/mobench/ci/ios/*.json + caller/target/mobench/ci/ios/*.csv + caller/target/browserstack/** + if-no-artifact-found: warn + + # --------------------------------------------------------------------------- + # Android BrowserStack benchmark + # --------------------------------------------------------------------------- + android: + name: Android BrowserStack benchmark + if: inputs.platform == 'android' || inputs.platform == 'both' + runs-on: macos-14 + + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + + steps: + - name: Checkout caller repo + uses: actions/checkout@v4 + with: + path: caller + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ inputs.rust_targets_android }} + + - name: Cache cargo registry/git + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-android-${{ hashFiles('caller/**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-android- + + - name: Cache cargo target + uses: actions/cache@v4 + with: + path: caller/target + key: ${{ runner.os }}-target-android-${{ hashFiles('caller/**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-target-android- + + - name: Setup Android SDK/NDK + uses: android-actions/setup-android@v3 + with: + packages: | + platform-tools + platforms;android-34 + build-tools;34.0.0 + ndk;26.1.10909125 + + - name: Install cargo-ndk + run: cargo install cargo-ndk --locked + + - name: Install mobench + shell: bash + env: + MOBENCH_VERSION: ${{ inputs.mobench_version }} + MOBENCH_REF: ${{ inputs.mobench_ref }} + run: | + if [ -n "$MOBENCH_REF" ]; then + cargo install mobench \ + --git https://github.com/worldcoin/mobile-bench-rs \ + --branch "$MOBENCH_REF" \ + --locked + elif [ -n "$MOBENCH_VERSION" ]; then + cargo install mobench --version "$MOBENCH_VERSION" --locked + else + cargo install mobench --locked + fi + + - name: Resolve Android device + id: resolve_device + working-directory: caller + shell: bash + env: + DEVICE_PROFILE: ${{ inputs.device_profile }} + ANDROID_DEVICE_OVERRIDE: ${{ inputs.android_device }} + ANDROID_OS_VERSION_OVERRIDE: ${{ inputs.android_os_version }} + run: | + if [ -n "$ANDROID_DEVICE_OVERRIDE" ] && [ -n "$ANDROID_OS_VERSION_OVERRIDE" ]; then + echo "device_name=${ANDROID_DEVICE_OVERRIDE}" >> "$GITHUB_OUTPUT" + echo "os_version=${ANDROID_OS_VERSION_OVERRIDE}" >> "$GITHUB_OUTPUT" + echo "Using custom device: ${ANDROID_DEVICE_OVERRIDE} (${ANDROID_OS_VERSION_OVERRIDE})" + else + output=$(cargo-mobench devices resolve \ + --platform android \ + --profile "${DEVICE_PROFILE}" \ + --format json 2>&1) || { + echo "::error::Failed to resolve Android device: $output" + exit 1 + } + device_name=$(echo "$output" | jq -r '.device // .name') + os_version=$(echo "$output" | jq -r '.os_version') + echo "device_name=${device_name}" >> "$GITHUB_OUTPUT" + echo "os_version=${os_version}" >> "$GITHUB_OUTPUT" + echo "Resolved Android device: ${device_name} (${os_version})" + fi + + - name: Build Android artifacts + working-directory: caller + shell: bash + env: + ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/26.1.10909125 + RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} + run: | + set -euo pipefail + cargo-mobench build --target android $RELEASE_FLAG + + - name: Run Android benchmarks + working-directory: caller + shell: bash + env: + FUNCTIONS_JSON: ${{ inputs.functions }} + ITERATIONS: ${{ inputs.iterations }} + WARMUP: ${{ inputs.warmup }} + ANDROID_DEVICE: ${{ steps.resolve_device.outputs.device_name }} + ANDROID_OS_VERSION: ${{ steps.resolve_device.outputs.os_version }} + ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/26.1.10909125 + RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} + run: | + set -euo pipefail + mkdir -p target/mobench/ci/android + + device_spec="${ANDROID_DEVICE}-${ANDROID_OS_VERSION}" + echo "Running Android benchmarks on: ${device_spec}" + + for func in $(echo "$FUNCTIONS_JSON" | jq -r '.[]'); do + slug=$(echo "$func" | tr ':' '_' | tr '/' '-') + echo "::group::Benchmark: ${func}" + cargo-mobench run \ + --target android \ + --function "${func}" \ + --iterations "${ITERATIONS}" \ + --warmup "${WARMUP}" \ + --devices "${device_spec}" \ + $RELEASE_FLAG \ + --fetch \ + --summary-csv \ + --output "target/mobench/ci/android/${slug}.json" || { + echo "::warning::Android benchmark failed for function: ${func}" + } + echo "::endgroup::" + done + + - name: Upload Android results + if: always() + uses: actions/upload-artifact@v4 + with: + name: mobench-results-android + path: | + caller/target/mobench/ci/android/*.json + caller/target/mobench/ci/android/*.csv + caller/target/browserstack/** + if-no-artifact-found: warn + + # --------------------------------------------------------------------------- + # Summarize results from both platforms + # --------------------------------------------------------------------------- + summarize: + name: Summarize benchmark results + needs: [ios, android] + if: always() && (needs.ios.result == 'success' || needs.android.result == 'success') + runs-on: ubuntu-latest + + steps: + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install mobench + shell: bash + env: + MOBENCH_VERSION: ${{ inputs.mobench_version }} + MOBENCH_REF: ${{ inputs.mobench_ref }} + run: | + if [ -n "$MOBENCH_REF" ]; then + cargo install mobench \ + --git https://github.com/worldcoin/mobile-bench-rs \ + --branch "$MOBENCH_REF" \ + --locked + elif [ -n "$MOBENCH_VERSION" ]; then + cargo install mobench --version "$MOBENCH_VERSION" --locked + else + cargo install mobench --locked + fi + + - name: Download iOS results + if: needs.ios.result == 'success' + uses: actions/download-artifact@v4 + with: + name: mobench-results-ios + path: results/ios + + - name: Download Android results + if: needs.android.result == 'success' + uses: actions/download-artifact@v4 + with: + name: mobench-results-android + path: results/android + + - name: Summarize iOS results + if: needs.ios.result == 'success' + shell: bash + run: | + echo "## iOS Benchmark Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if [ -d results/ios ]; then + cargo-mobench ci summarize \ + --results-dir results/ios \ + --output-format table || true + + cargo-mobench ci summarize \ + --results-dir results/ios \ + --output-format markdown \ + --output-file /tmp/ios-summary.md || true + + if [ -f /tmp/ios-summary.md ]; then + cat /tmp/ios-summary.md >> "$GITHUB_STEP_SUMMARY" + fi + else + echo "No iOS results found." >> "$GITHUB_STEP_SUMMARY" + fi + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Summarize Android results + if: needs.android.result == 'success' + shell: bash + run: | + echo "## Android Benchmark Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if [ -d results/android ]; then + cargo-mobench ci summarize \ + --results-dir results/android \ + --output-format table || true + + cargo-mobench ci summarize \ + --results-dir results/android \ + --output-format markdown \ + --output-file /tmp/android-summary.md || true + + if [ -f /tmp/android-summary.md ]; then + cat /tmp/android-summary.md >> "$GITHUB_STEP_SUMMARY" + fi + else + echo "No Android results found." >> "$GITHUB_STEP_SUMMARY" + fi + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Create Check Run + if: inputs.pr_number != '' + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.MOBENCH_APP_PRIVATE_KEY }} + PR_NUMBER: ${{ inputs.pr_number }} + CHECK_RUN_NAME: ${{ inputs.check_run_name }} + REGRESSION_THRESHOLD: ${{ inputs.regression_threshold_pct }} + run: | + if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "::notice::MOBENCH_APP_PRIVATE_KEY not provided; skipping Check Run." + exit 0 + fi + + # Find result JSONs from either platform + summary_json="" + for dir in results/ios results/android; do + if [ -d "$dir" ]; then + found=$(find "$dir" -name '*.json' -type f | head -1) + if [ -n "$found" ]; then + summary_json="$found" + break + fi + fi + done + + if [ -z "$summary_json" ]; then + echo "::warning::No result JSONs found for Check Run." + exit 0 + fi + + cargo-mobench ci check-run \ + --results "$summary_json" \ + --repo "${{ github.repository }}" \ + --sha "${{ github.sha }}" \ + --token "$GITHUB_TOKEN" \ + --name "$CHECK_RUN_NAME" \ + --regression-threshold-pct "$REGRESSION_THRESHOLD" || { + echo "::warning::Check Run creation failed." + } From c21fe79d42dc7ef49338406e0b2878964c5c33a4 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Feb 2026 21:15:39 +0100 Subject: [PATCH 073/196] feat: add interim sticky PR comment to reusable workflow summarize job --- .github/workflows/reusable-bench.yml | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 1876380..7adaebd 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -508,6 +508,57 @@ jobs: fi echo "" >> "$GITHUB_STEP_SUMMARY" + # NOTE: This step requires the caller workflow to have `pull-requests: write` permission + # so that github.token can post/update PR comments. + - name: Post sticky PR comment + if: inputs.pr_number != '' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + REPO: ${{ github.repository }} + run: | + MARKER="" + + # Build markdown body + BODY="${MARKER} + ## Mobench Benchmark Results + + " + for dir in results/ios results/android; do + if [ -d "$dir" ]; then + PLATFORM_MD=$(cargo-mobench ci summarize --results-dir "$dir" --output-format markdown 2>/dev/null || true) + if [ -n "$PLATFORM_MD" ]; then + BODY="${BODY}${PLATFORM_MD} + + " + fi + fi + done + + BODY="${BODY} + --- + *Posted by [mobench](https://github.com/worldcoin/mobile-bench-rs) · $(date -u '+%Y-%m-%d %H:%M UTC')*" + + # Find existing comment with marker + EXISTING_COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ + 2>/dev/null | head -1) + + if [ -n "$EXISTING_COMMENT_ID" ]; then + # Update existing comment + gh api "repos/${REPO}/issues/comments/${EXISTING_COMMENT_ID}" \ + -X PATCH \ + -f body="${BODY}" \ + --silent + echo "Updated existing PR comment #${EXISTING_COMMENT_ID}" + else + # Create new comment + gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + -f body="${BODY}" \ + --silent + echo "Created new PR comment" + fi + - name: Create Check Run if: inputs.pr_number != '' shell: bash From dadc1e42d167b2faf6f55820f6c0f5790e73a40f Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 2 Mar 2026 16:40:05 +0100 Subject: [PATCH 074/196] fix: address code review findings (artifact params, permissions, check-run improvements) --- .github/workflows/reusable-bench.yml | 55 ++++++++++++---------------- crates/mobench/src/lib.rs | 32 +++++++++++----- crates/mobench/src/summarize.rs | 11 ++++++ 3 files changed, 58 insertions(+), 40 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 7adaebd..be5d983 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -96,9 +96,12 @@ on: required: true MOBENCH_APP_PRIVATE_KEY: required: false + MOBENCH_APP_ID: + required: false permissions: contents: read + pull-requests: write env: CARGO_TERM_COLOR: always @@ -257,7 +260,7 @@ jobs: caller/target/mobench/ci/ios/*.json caller/target/mobench/ci/ios/*.csv caller/target/browserstack/** - if-no-artifact-found: warn + if-no-files-found: warn # --------------------------------------------------------------------------- # Android BrowserStack benchmark @@ -412,7 +415,7 @@ jobs: caller/target/mobench/ci/android/*.json caller/target/mobench/ci/android/*.csv caller/target/browserstack/** - if-no-artifact-found: warn + if-no-files-found: warn # --------------------------------------------------------------------------- # Summarize results from both platforms @@ -559,43 +562,33 @@ jobs: echo "Created new PR comment" fi + - name: Generate GitHub App token + id: app-token + if: inputs.pr_number != '' && secrets.MOBENCH_APP_PRIVATE_KEY != '' + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.MOBENCH_APP_ID }} + private-key: ${{ secrets.MOBENCH_APP_PRIVATE_KEY }} + - name: Create Check Run - if: inputs.pr_number != '' + if: steps.app-token.outputs.token != '' shell: bash env: - GITHUB_TOKEN: ${{ secrets.MOBENCH_APP_PRIVATE_KEY }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ inputs.pr_number }} CHECK_RUN_NAME: ${{ inputs.check_run_name }} REGRESSION_THRESHOLD: ${{ inputs.regression_threshold_pct }} run: | - if [ -z "${GITHUB_TOKEN:-}" ]; then - echo "::notice::MOBENCH_APP_PRIVATE_KEY not provided; skipping Check Run." - exit 0 - fi - - # Find result JSONs from either platform - summary_json="" for dir in results/ios results/android; do if [ -d "$dir" ]; then - found=$(find "$dir" -name '*.json' -type f | head -1) - if [ -n "$found" ]; then - summary_json="$found" - break - fi + PLATFORM=$(basename "$dir") + cargo-mobench ci check-run \ + --results-dir "$dir" \ + --repo "${{ github.repository }}" \ + --sha "${{ github.event.pull_request.head.sha || github.sha }}" \ + --name "$CHECK_RUN_NAME — ${PLATFORM}" \ + --annotation-path "${{ inputs.crate_path }}/src/lib.rs" \ + --regression-threshold-pct "$REGRESSION_THRESHOLD" \ + || true fi done - - if [ -z "$summary_json" ]; then - echo "::warning::No result JSONs found for Check Run." - exit 0 - fi - - cargo-mobench ci check-run \ - --results "$summary_json" \ - --repo "${{ github.repository }}" \ - --sha "${{ github.sha }}" \ - --token "$GITHUB_TOKEN" \ - --name "$CHECK_RUN_NAME" \ - --regression-threshold-pct "$REGRESSION_THRESHOLD" || { - echo "::warning::Check Run creation failed." - } diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 6fa6d7d..a06b8bf 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -669,7 +669,7 @@ struct CiRunArgs { #[derive(Args, Debug, Clone)] struct CiSummarizeArgs { - /// BrowserStack build ID to fetch metrics from (online mode). + /// BrowserStack build ID to enrich results with device metrics (requires --results-dir). #[arg(long)] build_id: Option, @@ -701,8 +701,12 @@ struct CiSummarizeArgs { #[derive(Args, Debug, Clone)] struct CiCheckRunArgs { /// Path to summary JSON with benchmark results. - #[arg(long)] - results: PathBuf, + #[arg(long, required_unless_present = "results_dir")] + results: Option, + + /// Directory containing summary JSON files (processes all). + #[arg(long, required_unless_present = "results")] + results_dir: Option, /// GitHub repository (owner/repo format). #[arg(long)] @@ -727,6 +731,10 @@ struct CiCheckRunArgs { /// Regression threshold percentage. #[arg(long, default_value_t = 5.0)] regression_threshold_pct: f64, + + /// File path used in Check Run annotations (relative to repo root). + #[arg(long, default_value = "src/lib.rs")] + annotation_path: String, } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] @@ -2110,7 +2118,7 @@ fn cmd_ci_summarize(args: CiSummarizeArgs) -> Result<()> { summarize::load_results_dir(dir)? } else { // If only build_id, we need results_dir too for now - anyhow::bail!("--results-dir is required (--build-id enriches existing results)"); + anyhow::bail!("--results-dir is required. Use --build-id alongside --results-dir to enrich offline results with BrowserStack metrics."); }; // Enrich with BrowserStack data if build_id provided @@ -2173,10 +2181,16 @@ fn cmd_ci_summarize(args: CiSummarizeArgs) -> Result<()> { fn cmd_ci_check_run(args: CiCheckRunArgs) -> Result<()> { // Load results - let content = std::fs::read_to_string(&args.results) - .with_context(|| format!("Failed to read {}", args.results.display()))?; - let value: serde_json::Value = serde_json::from_str(&content)?; - let report = summarize::parse_summary_value(&value)?; + let report = if let Some(ref dir) = args.results_dir { + summarize::load_results_dir(dir)? + } else if let Some(ref path) = args.results { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + let value: serde_json::Value = serde_json::from_str(&content)?; + summarize::parse_summary_value(&value)? + } else { + anyhow::bail!("Either --results or --results-dir must be provided"); + }; // Generate markdown summary let summary_md = summarize::render_markdown(&report); @@ -2208,7 +2222,7 @@ fn cmd_ci_check_run(args: CiCheckRunArgs) -> Result<()> { if pct_change > args.regression_threshold_pct { has_regression = true; annotations.push(github::CheckRunAnnotation { - path: "src/lib.rs".to_string(), + path: args.annotation_path.clone(), start_line: 1, end_line: 1, annotation_level: "warning".to_string(), diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index 578c45f..abafbde 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -400,7 +400,18 @@ pub fn enrich_with_browserstack( build_summary: &crate::browserstack::BuildSummary, ) { for platform in &mut report.platforms { + // Find sessions that match this platform for session in &build_summary.sessions { + let session_is_ios = session.os.eq_ignore_ascii_case("ios") + || session.os.eq_ignore_ascii_case("iPhone") + || session.os.eq_ignore_ascii_case("iPad"); + let platform_is_ios = platform.platform == "ios"; + + // Skip if platform doesn't match + if session_is_ios != platform_is_ios { + continue; + } + // Update device info from session details if !session.os.is_empty() { platform.device.os = session.os.clone(); From 5fa0b5fcb1f03835bbe57a5fea081cd8f280e477 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 4 Mar 2026 16:25:50 +0100 Subject: [PATCH 075/196] fix: remove dead CLI flags, parse std_dev_ns, add annotation guards - Remove unused --include-device-specs and --verbose flags from ci summarize - Parse std_dev_ns from source JSON instead of hardcoding 0.0 - Skip serializing std_dev_ms when zero to avoid misleading output - Give each regression annotation a distinct line number - Truncate annotations at 50 (GitHub API limit) with warning --- .gitignore | 2 ++ crates/mobench/src/github.rs | 9 ++++++++- crates/mobench/src/lib.rs | 13 +++---------- crates/mobench/src/summarize.rs | 7 ++++++- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index a84b81f..6605443 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ target/ **/*.DS_Store .vscode/ +.claude/ +.conductor/ .cursor/ android/app/src/main/jniLibs/*/libsample_fns.so android/app/src/main/jniLibs/*/libuniffi_sample_fns.so diff --git a/crates/mobench/src/github.rs b/crates/mobench/src/github.rs index 49b7b5b..e9490d3 100644 --- a/crates/mobench/src/github.rs +++ b/crates/mobench/src/github.rs @@ -60,9 +60,16 @@ impl GitHubClient { conclusion: &str, title: &str, summary: &str, - annotations: Vec, + mut annotations: Vec, ) -> Result { let url = format!("{GITHUB_API_BASE}/repos/{repo}/check-runs"); + if annotations.len() > 50 { + eprintln!( + "Warning: {} annotations exceed GitHub's 50-annotation limit, truncating", + annotations.len() + ); + annotations.truncate(50); + } let annotations_count = annotations.len(); let body = CreateCheckRunRequest { diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index a06b8bf..ae6d3df 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -685,14 +685,6 @@ struct CiSummarizeArgs { #[arg(long)] output_file: Option, - /// Fetch device hardware specs (chipset, RAM) from BrowserStack. - #[arg(long)] - include_device_specs: bool, - - /// Show per-iteration breakdown instead of just aggregates. - #[arg(long)] - verbose: bool, - /// Platform filter (show only one platform). #[arg(long, value_enum)] platform: Option, @@ -2221,10 +2213,11 @@ fn cmd_ci_check_run(args: CiCheckRunArgs) -> Result<()> { if pct_change > args.regression_threshold_pct { has_regression = true; + let line = annotations.len() as u32 + 1; annotations.push(github::CheckRunAnnotation { path: args.annotation_path.clone(), - start_line: 1, - end_line: 1, + start_line: line, + end_line: line, annotation_level: "warning".to_string(), message: format!( "{} regressed {pct_change:+.1}% ({:.1}ms \u{2192} {:.1}ms)", diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index abafbde..7ea9de6 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -5,6 +5,10 @@ use comfy_table::{presets::UTF8_FULL, Attribute, Cell, ContentArrangement, Table use serde::{Deserialize, Serialize}; use std::path::Path; +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} + /// A fully-assembled summary ready for rendering. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SummarizeReport { @@ -51,6 +55,7 @@ pub struct TimingStats { pub best_ms: f64, pub worst_ms: f64, pub p95_ms: f64, + #[serde(skip_serializing_if = "is_zero")] pub std_dev_ms: f64, } @@ -164,7 +169,7 @@ fn parse_benchmark_entry(value: &serde_json::Value) -> Result { best_ms: ns_to_ms("min_ns"), worst_ms: ns_to_ms("max_ns"), p95_ms: ns_to_ms("p95_ns"), - std_dev_ms: 0.0, + std_dev_ms: ns_to_ms("std_dev_ns"), }; Ok(BenchmarkResult { From e25abbcde0b38eff43bb4f43a54b138e1e1f3349 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 14:31:15 -0700 Subject: [PATCH 076/196] docs: capture mobench 0.1.17 resolver design --- ...obench-0.1.17-project-resolution-design.md | 145 ++++++++ ...03-23-mobench-0.1.17-project-resolution.md | 319 ++++++++++++++++++ 2 files changed, 464 insertions(+) create mode 100644 docs/plans/2026-03-23-mobench-0.1.17-project-resolution-design.md create mode 100644 docs/plans/2026-03-23-mobench-0.1.17-project-resolution.md diff --git a/docs/plans/2026-03-23-mobench-0.1.17-project-resolution-design.md b/docs/plans/2026-03-23-mobench-0.1.17-project-resolution-design.md new file mode 100644 index 0000000..74c6d12 --- /dev/null +++ b/docs/plans/2026-03-23-mobench-0.1.17-project-resolution-design.md @@ -0,0 +1,145 @@ +# Mobench 0.1.17 Project Resolution Design + +**Status:** Approved + +**Goal:** Make `mobench` resolve project roots, benchmark crates, library names, dotenv files, and artifact paths from explicit inputs and `mobench.toml` first, with legacy `bench-mobile` behavior retained only as a fallback. + +## Problem + +`mobench 0.1.16` still spreads project detection across several helpers: + +- `repo_root()` / `is_repo_root()` treat a repo as valid only if it contains `bench-mobile/` or `crates/sample-fns/`. +- `detect_bench_mobile_crate_name()` is hardcoded to legacy crate names and is called from build, run, packaging, list, verify, and validation paths. +- `build --progress` bypasses config-based crate resolution. +- `load_dotenv()` anchors only to the legacy repo root. +- `list` and `verify` do not use config-based crate resolution. +- `verify --smoke-test` assumes the benchmark crate is linked into the CLI binary. + +That behavior works for the repository fixtures, but fails for custom layouts where the benchmark crate is configured in `mobench.toml` and does not live at `bench-mobile/`. + +## Selected Approach + +Introduce one CLI-side resolver that every relevant command uses. + +```rust +struct ResolvedProjectLayout { + project_root: PathBuf, + crate_dir: PathBuf, + crate_name: String, + library_name: String, + config_path: Option, + config: Option, + output_dir: PathBuf, + default_function: Option, + resolution_notes: Vec, +} +``` + +This keeps the SDK builders mostly unchanged. The CLI becomes responsible for determining the correct root and crate once, then passes the resolved paths/names into builders and helper functions. + +## Resolution Rules + +### Root discovery + +Project root resolution will use this precedence: + +1. Explicit `--project-root` +2. Parent directory of explicit `--config` +3. Parent directory of discovered `mobench.toml` +4. Cargo workspace root discovered from `cargo metadata` +5. Git root +6. Current working directory + +Legacy `bench-mobile/` detection remains only as a last-resort compatibility fallback when no stronger signal exists. + +### Crate discovery + +Crate resolution will use this precedence: + +1. Explicit `--crate-path` +2. `[project].crate` from `mobench.toml` +3. Cargo package at `project_root` if its package name matches the configured crate name +4. Cargo metadata package lookup under the resolved project root +5. Legacy fallback locations: + - `project_root/bench-mobile` + - `project_root/crates/` + - `project_root/` + - `project_root/crates/sample-fns` + +If the crate directory is found, `crate_name` comes from its `Cargo.toml`. `library_name` comes from `[project].library_name` when set, otherwise from `crate_name` with `-` replaced by `_`. + +## Command Changes + +The shared resolver will be used by: + +- `run` +- `build` +- `package-ipa` +- `package-xcuitest` +- `list` +- `verify` +- helper paths used by `run`, including auto-packaging and benchmark validation +- dotenv loading +- default output/spec path generation + +CLI parity changes: + +- Add `--crate-path` to `run`, `package-ipa`, `package-xcuitest`, `list`, and `verify` +- Add `--project-root` to `run`, `build`, `package-ipa`, `package-xcuitest`, `list`, and `verify` + +## List and Verify Semantics + +### `list` + +`list` will scan the resolved `crate_dir` first and optionally include runtime inventory entries from the CLI binary. The source-scanned list is authoritative for external crates. + +### `verify` + +`verify` will use the resolved layout for: + +- spec path defaults +- artifact path defaults +- artifact name checks derived from `library_name` +- benchmark discovery in the resolved crate + +`verify --smoke-test` will keep executing only for benchmark crates linked into the CLI binary. For external crates, it will return a clear unsupported message instead of printing `Available benchmarks: []`. + +This is a deliberate `0.1.17` scope limit. Subprocess-based smoke testing can be added later without changing the resolver contract. + +## Dotenv Loading + +Dotenv loading will use the resolved layout instead of legacy repo-root detection: + +- load `.env` +- load `.env.local` +- if `--config` points outside `project_root`, also try the config directory + +Later files may override earlier ones so `.env.local` still wins. + +## Default Naming + +Default benchmark names and artifact checks will derive from resolved values: + +- crate defaults from `crate_name` +- library defaults from `library_name` +- benchmark defaults from `[benchmarks].default_function` + +This removes remaining `bench_mobile::*` and `sample_fns` assumptions from config-aware paths. + +## Testing Strategy + +Add focused tests around resolver behavior and command semantics: + +- resolver uses `mobench.toml` without `bench-mobile/` +- `build --progress` respects config and resolved crate path +- `run --dry-run` with `--crate-path` stays under the repo +- `list` finds benchmarks in a custom crate named by config +- `verify` reports external smoke tests as unsupported instead of empty inventory +- artifact checks use resolved `library_name` + +## Non-Goals + +- Moving crate/root resolution into `mobench-sdk` +- Implementing subprocess smoke tests for external crates +- Redesigning `devices resolve --format json` + diff --git a/docs/plans/2026-03-23-mobench-0.1.17-project-resolution.md b/docs/plans/2026-03-23-mobench-0.1.17-project-resolution.md new file mode 100644 index 0000000..e70431c --- /dev/null +++ b/docs/plans/2026-03-23-mobench-0.1.17-project-resolution.md @@ -0,0 +1,319 @@ +# Mobench 0.1.17 Project Resolution Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace legacy repo-root/crate detection with one config-first resolver and wire it through `build`, `run`, packaging, `list`, `verify`, and dotenv loading. + +**Architecture:** Add a shared resolver in `crates/mobench` that computes `project_root`, `crate_dir`, `crate_name`, `library_name`, config path, and output directory once. Commands consume that resolved layout instead of calling hardcoded `bench-mobile` helpers directly. Verification remains source-scan based for external crates, with smoke tests explicitly unsupported unless the benchmark crate is linked into the CLI binary. + +**Tech Stack:** Rust, Clap, `cargo metadata`, `toml`, existing `mobench` CLI tests, existing `mobench-sdk` builders. + +--- + +### Task 1: Add resolver-focused tests first + +**Files:** +- Modify: `crates/mobench/src/lib.rs` +- Modify: `crates/mobench/src/config.rs` + +**Step 1: Write the failing tests** + +Add unit tests that create temporary custom-layout projects with: + +- a root `mobench.toml` +- a benchmark crate such as `zk-mobile-bench` +- `library_name = "zk_mobile_bench"` +- at least one `#[benchmark]` function or a source pattern detectable by `detect_all_benchmarks` + +Cover: + +- resolver finds the configured crate without `bench-mobile/` +- `build --progress` uses config-derived crate resolution +- `list` returns `zk_mobile_bench::*` +- `verify --smoke-test` reports unsupported for an external crate + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +cargo test -p mobench resolver_ --lib -- --nocapture +cargo test -p mobench list_ --lib -- --nocapture +cargo test -p mobench verify_ --lib -- --nocapture +``` + +Expected: failures referencing missing shared resolution, hardcoded legacy crate lookup, or old verify semantics. + +**Step 3: Commit** + +```bash +git add crates/mobench/src/lib.rs crates/mobench/src/config.rs +git commit -m "test: cover config-first project resolution" +``` + +### Task 2: Introduce `ResolvedProjectLayout` + +**Files:** +- Modify: `crates/mobench/src/lib.rs` +- Modify: `crates/mobench/src/config.rs` + +**Step 1: Write the failing test** + +Add a test that exercises resolver precedence: + +- explicit `--project-root` +- explicit `--config` +- discovered `mobench.toml` +- Cargo workspace root +- git root +- legacy fallback + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test -p mobench resolved_project_layout_precedence --lib -- --nocapture +``` + +Expected: FAIL because the resolver type and precedence logic do not exist yet. + +**Step 3: Write minimal implementation** + +Implement: + +- `ResolvedProjectLayout` +- helpers for reading package names/manifests +- project-root resolution +- crate-dir resolution +- `library_name` derivation +- output-dir/default-function accessors + +Use `cargo metadata` when available, and fall back to legacy layout checks only at the end. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test -p mobench resolved_project_layout_precedence --lib -- --nocapture +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/mobench/src/lib.rs crates/mobench/src/config.rs +git commit -m "feat: add shared project layout resolver" +``` + +### Task 3: Add CLI parity flags + +**Files:** +- Modify: `crates/mobench/src/lib.rs` + +**Step 1: Write the failing tests** + +Add parser tests showing that: + +- `run` accepts `--crate-path` and `--project-root` +- `package-ipa` accepts `--crate-path` and `--project-root` +- `package-xcuitest` accepts `--crate-path` and `--project-root` +- `list` accepts `--crate-path` and `--project-root` +- `verify` accepts `--crate-path` and `--project-root` + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +cargo test -p mobench parses_project_resolution_flags --lib -- --nocapture +``` + +Expected: FAIL because the flags are not declared on all commands. + +**Step 3: Write minimal implementation** + +Add the new flags to the command variants and thread them into command handlers. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test -p mobench parses_project_resolution_flags --lib -- --nocapture +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/mobench/src/lib.rs +git commit -m "feat: add project resolution flags across commands" +``` + +### Task 4: Switch build, run, and packaging helpers to the resolver + +**Files:** +- Modify: `crates/mobench/src/lib.rs` + +**Step 1: Write the failing tests** + +Add tests proving: + +- `build --progress` uses config-based crate resolution +- `run --dry-run` resolves within the temp repo and does not fall back to cargo registry paths +- packaging helpers use the resolved crate/layout metadata + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +cargo test -p mobench build_progress_ --lib -- --nocapture +cargo test -p mobench run_dry_run_ --lib -- --nocapture +``` + +Expected: FAIL because the helpers still call `repo_root()` and `detect_bench_mobile_crate_name()`. + +**Step 3: Write minimal implementation** + +Replace direct calls to: + +- `repo_root()` +- `detect_bench_mobile_crate_name()` +- hardcoded builder construction paths + +with a single resolved layout passed into: + +- `run_ios_build` +- `run_android_build` +- `package_ios_xcuitest_artifacts` +- `cmd_build` +- `cmd_package_ipa` +- `cmd_package_xcuitest` +- `validate_benchmark_function` +- `persist_mobile_spec` + +**Step 4: Run tests to verify they pass** + +Run: + +```bash +cargo test -p mobench build_progress_ --lib -- --nocapture +cargo test -p mobench run_dry_run_ --lib -- --nocapture +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/mobench/src/lib.rs +git commit -m "feat: use shared resolver for build and packaging" +``` + +### Task 5: Fix dotenv, list, and verify semantics + +**Files:** +- Modify: `crates/mobench/src/lib.rs` + +**Step 1: Write the failing tests** + +Add tests showing: + +- dotenv loads from resolved project root and config directory +- `list` finds benchmarks in the resolved crate +- `verify` artifact checks use resolved `library_name` +- `verify --smoke-test` returns unsupported for external crates + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +cargo test -p mobench dotenv_ --lib -- --nocapture +cargo test -p mobench list_ --lib -- --nocapture +cargo test -p mobench verify_ --lib -- --nocapture +``` + +Expected: FAIL because these paths still rely on legacy assumptions. + +**Step 3: Write minimal implementation** + +Update: + +- `load_dotenv` +- `cmd_list` +- `cmd_verify` +- `run_verify_smoke_test` + +Use the resolved layout for crate scanning and artifact names. Return an explicit unsupported error for external-crate smoke tests. + +**Step 4: Run tests to verify they pass** + +Run: + +```bash +cargo test -p mobench dotenv_ --lib -- --nocapture +cargo test -p mobench list_ --lib -- --nocapture +cargo test -p mobench verify_ --lib -- --nocapture +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/mobench/src/lib.rs +git commit -m "feat: fix resolver-based list verify and dotenv" +``` + +### Task 6: Update docs and final regression checks + +**Files:** +- Modify: `crates/mobench/README.md` +- Modify: `README.md` + +**Step 1: Write the failing test** + +Add or update parser/help tests if needed for documented flags and semantics. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test -p mobench parses_project_resolution_flags --lib -- --nocapture +``` + +Expected: FAIL only if docs-driven CLI changes are still incomplete. + +**Step 3: Write minimal implementation** + +Document: + +- shared project resolution behavior +- new `--project-root` / `--crate-path` support +- `verify --smoke-test` limitations for external crates + +**Step 4: Run full verification** + +Run: + +```bash +cargo test -p mobench --lib +cargo test -p mobench --bins +cargo test +``` + +Expected: PASS, subject only to known environment-dependent platform tooling outside the resolver scope. + +**Step 5: Commit** + +```bash +git add crates/mobench/README.md README.md crates/mobench/src/lib.rs crates/mobench/src/config.rs +git commit -m "docs: document config-first project resolution" +``` From e0aceb5c4c3e9ced872f766ef356d8820495044f Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 14:45:29 -0700 Subject: [PATCH 077/196] feat: make mobench project resolution config-first --- crates/mobench/src/lib.rs | 1358 +++++++++++++++++++++++++++---------- 1 file changed, 1000 insertions(+), 358 deletions(-) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index ae6d3df..6fd8d56 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -183,6 +183,16 @@ enum Command { target: MobileTarget, #[arg(long, help = "Fully-qualified Rust function to benchmark")] function: String, + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + crate_path: Option, #[arg(long, default_value_t = 100)] iterations: u32, #[arg(long, default_value_t = 10)] @@ -332,6 +342,11 @@ enum Command { target: SdkTarget, #[arg(long, help = "Build in release mode")] release: bool, + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option, #[arg( long, help = "Output directory for mobile artifacts (default: target/mobench)" @@ -351,6 +366,16 @@ enum Command { scheme: String, #[arg(long, value_enum, default_value = "adhoc", help = "Signing method")] method: IosSigningMethodArg, + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + crate_path: Option, #[arg( long, help = "Output directory for mobile artifacts (default: target/mobench)" @@ -365,6 +390,16 @@ enum Command { PackageXcuitest { #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] scheme: String, + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + crate_path: Option, #[arg( long, help = "Output directory for mobile artifacts (default: target/mobench)" @@ -372,7 +407,18 @@ enum Command { output_dir: Option, }, /// List all discovered benchmark functions (Phase 1 MVP). - List, + List { + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + crate_path: Option, + }, /// Verify benchmark setup: registry, spec, artifacts, and optional smoke test. /// /// This command validates: @@ -381,6 +427,16 @@ enum Command { /// - Artifacts are present and consistent (if --check-artifacts) /// - Runs a local smoke test (if --smoke-test and function is specified) Verify { + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + crate_path: Option, #[arg(long, value_enum, help = "Target platform to verify artifacts for")] target: Option, #[arg(long, help = "Path to bench_spec.json to validate")] @@ -924,6 +980,37 @@ struct RunSummary { performance_metrics: Option>, } +#[derive(Debug, Clone)] +struct ResolvedProjectLayout { + project_root: PathBuf, + crate_dir: PathBuf, + crate_name: String, + library_name: String, + config_path: Option, + output_dir: PathBuf, + default_function: Option, +} + +#[derive(Debug, Clone, Copy)] +struct ProjectLayoutOptions<'a> { + start_dir: Option<&'a Path>, + project_root: Option<&'a Path>, + crate_path: Option<&'a Path>, + config_path: Option<&'a Path>, +} + +#[derive(Debug, Deserialize)] +struct CargoMetadataPackage { + name: String, + manifest_path: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct CargoMetadataOutput { + workspace_root: PathBuf, + packages: Vec, +} + #[derive(Debug, Serialize, Deserialize, Clone)] struct SummaryReport { generated_at: String, @@ -968,12 +1055,13 @@ enum RemoteRun { } pub fn run() -> Result<()> { - load_dotenv(); let cli = Cli::parse(); match cli.command { Command::Run { target, function, + project_root, + crate_path, iterations, warmup, devices, @@ -996,12 +1084,21 @@ pub fn run() -> Result<()> { fetch_timeout_secs, progress, } => { + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: project_root.as_deref(), + crate_path: crate_path.as_deref(), + config_path: config.as_deref(), + })?; + load_dotenv_for_layout(&layout); let spec = resolve_run_spec( target, function, iterations, warmup, devices, + project_root.as_deref(), + crate_path.as_deref(), config.as_deref(), device_matrix.as_deref(), device_tags, @@ -1009,14 +1106,18 @@ pub fn run() -> Result<()> { ios_test_suite, local_only, release, + cli.dry_run, )?; let summary_paths = resolve_summary_paths(output.as_deref())?; - let root = repo_root()?; - let output_dir = root.join("target/mobench"); + let output_dir = layout.output_dir.clone(); // Validate device specs early to catch errors before building (C2: Device validation) if !spec.devices.is_empty() && !local_only { - if let Ok(creds) = resolve_browserstack_credentials(spec.browserstack.as_ref()) { + if cli.dry_run { + println!("[dry-run] Skipping BrowserStack device validation"); + } else if let Ok(creds) = + resolve_browserstack_credentials(spec.browserstack.as_ref()) + { let client = BrowserStackClient::new( BrowserStackAuth { username: creds.username, @@ -1072,6 +1173,9 @@ pub fn run() -> Result<()> { " Profile: {}", if release { "release" } else { "debug" } ); + if cli.dry_run { + println!(" Mode: dry-run"); + } if !spec.devices.is_empty() { println!(" Devices: {}", spec.devices.join(", ")); } else { @@ -1115,14 +1219,21 @@ pub fn run() -> Result<()> { // A2: Validate that the requested benchmark function exists (if we can detect it) if !progress { - validate_benchmark_function(&root, &spec.function)?; + validate_benchmark_function(&layout, &spec.function)?; } // Persist the spec and metadata to mobile app bundles if progress { println!("[1/4] Preparing benchmark spec..."); } - persist_mobile_spec(&spec, release)?; + if cli.dry_run { + println!( + "[dry-run] Would write bench_spec.json and bench_meta.json under {}", + output_dir.display() + ); + } else { + persist_mobile_spec(&layout, &spec, release)?; + } // Skip local smoke test - sample-fns uses direct dispatch, not inventory registry // Benchmarks will run on the actual mobile device @@ -1151,7 +1262,7 @@ pub fn run() -> Result<()> { let ndk = std::env::var("ANDROID_NDK_HOME").context( "ANDROID_NDK_HOME must be set for Android builds. Example: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/", )?; - let build = run_android_build(&ndk, release)?; + let build = run_android_build(&layout, &ndk, release, cli.dry_run)?; let apk = build.app_path; if !progress { println!("\u{2713} Built Android APK at {:?}", apk); @@ -1161,6 +1272,11 @@ pub fn run() -> Result<()> { println!("Skipping BrowserStack upload/run: no devices provided"); } Some(MobileArtifacts::Android { apk }) + } else if cli.dry_run { + if !progress { + println!("[dry-run] Skipping BrowserStack upload/run for Android"); + } + Some(MobileArtifacts::Android { apk }) } else { if progress { println!("[3/4] Uploading to BrowserStack..."); @@ -1180,7 +1296,7 @@ pub fn run() -> Result<()> { println!("Building for iOS..."); println!(" Building Rust library for iOS targets..."); } - let (xcframework, header) = run_ios_build(release)?; + let (xcframework, header) = run_ios_build(&layout, release, cli.dry_run)?; if !progress { println!("\u{2713} Built iOS xcframework at {:?}", xcframework); } @@ -1190,6 +1306,10 @@ pub fn run() -> Result<()> { if !progress { println!("Skipping BrowserStack upload/run: no devices provided"); } + } else if cli.dry_run { + if !progress { + println!("[dry-run] Skipping BrowserStack upload/run for iOS"); + } } else { if progress { println!("[3/4] Uploading to BrowserStack..."); @@ -1222,6 +1342,12 @@ pub fn run() -> Result<()> { performance_metrics: None, }; + if cli.dry_run { + println!(); + println!("[dry-run] Run simulation completed. No changes were made."); + return Ok(()); + } + if fetch && let Some(remote) = &run_summary.remote_run { let build_id = match remote { RemoteRun::Android { build_id, .. } => build_id, @@ -1527,6 +1653,7 @@ pub fn run() -> Result<()> { Command::Build { target, release, + project_root, output_dir, crate_path, progress, @@ -1534,6 +1661,7 @@ pub fn run() -> Result<()> { cmd_build( target, release, + project_root, output_dir, crate_path, cli.dry_run, @@ -1544,17 +1672,29 @@ pub fn run() -> Result<()> { Command::PackageIpa { scheme, method, + project_root, + crate_path, output_dir, } => { - cmd_package_ipa(&scheme, method, output_dir)?; + cmd_package_ipa(&scheme, method, project_root, crate_path, output_dir)?; } - Command::PackageXcuitest { scheme, output_dir } => { - cmd_package_xcuitest(&scheme, output_dir)?; + Command::PackageXcuitest { + scheme, + project_root, + crate_path, + output_dir, + } => { + cmd_package_xcuitest(&scheme, project_root, crate_path, output_dir)?; } - Command::List => { - cmd_list()?; + Command::List { + project_root, + crate_path, + } => { + cmd_list(project_root, crate_path)?; } Command::Verify { + project_root, + crate_path, target, spec_path, check_artifacts, @@ -1563,6 +1703,8 @@ pub fn run() -> Result<()> { output_dir, } => { cmd_verify( + project_root, + crate_path, target, spec_path, check_artifacts, @@ -1657,6 +1799,279 @@ pub fn run() -> Result<()> { Ok(()) } +fn canonicalize_from(base: &Path, path: &Path) -> Result { + let joined = if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + }; + joined + .canonicalize() + .with_context(|| format!("resolving path {}", joined.display())) +} + +fn resolve_existing_path_arg(base: &Path, path: Option<&Path>) -> Result> { + path.map(|value| canonicalize_from(base, value)).transpose() +} + +fn cargo_metadata_from(start: &Path) -> Option { + let output = std::process::Command::new("cargo") + .args(["metadata", "--format-version", "1", "--no-deps"]) + .current_dir(start) + .output() + .ok()?; + if !output.status.success() { + return None; + } + serde_json::from_slice(&output.stdout).ok() +} + +fn git_root_from(start: &Path) -> Option { + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(start) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8(output.stdout).ok()?; + let path = stdout.trim(); + if path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } +} + +fn config_discovery_base( + start_dir: &Path, + explicit_project_root: Option<&PathBuf>, + explicit_crate_path: Option<&PathBuf>, +) -> PathBuf { + explicit_project_root + .cloned() + .or_else(|| explicit_crate_path.cloned()) + .unwrap_or_else(|| start_dir.to_path_buf()) +} + +fn load_layout_config( + start_dir: &Path, + explicit_project_root: Option<&PathBuf>, + explicit_crate_path: Option<&PathBuf>, + explicit_config_path: Option<&PathBuf>, +) -> Result> { + if let Some(path) = explicit_config_path { + return Ok(Some(( + config::MobenchConfig::load_from_file(path)?, + path.to_path_buf(), + ))); + } + + let discovery_base = + config_discovery_base(start_dir, explicit_project_root, explicit_crate_path); + config::MobenchConfig::discover_from(&discovery_base) +} + +fn resolve_project_root_for_layout( + start_dir: &Path, + explicit_project_root: Option, + explicit_crate_path: Option<&PathBuf>, + config_path: Option<&Path>, +) -> PathBuf { + if let Some(root) = explicit_project_root { + return root; + } + if let Some(path) = config_path + && let Some(parent) = path.parent() + { + return parent.to_path_buf(); + } + if let Some(crate_path) = explicit_crate_path + && let Some(metadata) = cargo_metadata_from(crate_path) + { + return metadata.workspace_root; + } + if let Some(metadata) = cargo_metadata_from(start_dir) { + return metadata.workspace_root; + } + if let Some(crate_path) = explicit_crate_path + && let Some(root) = git_root_from(crate_path) + { + return root; + } + if let Some(root) = git_root_from(start_dir) { + return root; + } + start_dir.to_path_buf() +} + +fn read_package_name_from_dir(dir: &Path) -> Option { + mobench_sdk::builders::common::read_package_name(&dir.join("Cargo.toml")) +} + +fn package_dir_from_metadata(metadata: &CargoMetadataOutput, crate_name: &str) -> Option { + metadata + .packages + .iter() + .find(|pkg| pkg.name == crate_name) + .and_then(|pkg| pkg.manifest_path.parent().map(Path::to_path_buf)) +} + +fn resolve_configured_crate_dir(project_root: &Path, crate_name: &str) -> Result> { + if let Some(pkg_name) = read_package_name_from_dir(project_root) + && pkg_name == crate_name + { + return Ok(Some(project_root.to_path_buf())); + } + + if let Some(metadata) = cargo_metadata_from(project_root) + && let Some(dir) = package_dir_from_metadata(&metadata, crate_name) + { + return Ok(Some(dir)); + } + + let candidates = [ + project_root.join("crates").join(crate_name), + project_root.join(crate_name), + project_root.join("bench-mobile"), + ]; + + for candidate in candidates { + let manifest = candidate.join("Cargo.toml"); + if !manifest.exists() { + continue; + } + if read_package_name_from_dir(&candidate).as_deref() == Some(crate_name) { + return Ok(Some(candidate)); + } + } + + Ok(None) +} + +fn resolve_legacy_crate_dir(project_root: &Path) -> Result { + let candidates = [ + project_root.to_path_buf(), + project_root.join("bench-mobile"), + project_root.join("crates/sample-fns"), + ]; + + for candidate in candidates { + let manifest = candidate.join("Cargo.toml"); + if !manifest.exists() { + continue; + } + if read_package_name_from_dir(&candidate).is_some() { + return Ok(candidate); + } + } + + bail!( + "No benchmark crate found. Pass --crate-path, set [project].crate in mobench.toml, or use a legacy bench-mobile layout." + ) +} + +fn resolve_project_layout(options: ProjectLayoutOptions<'_>) -> Result { + let start_dir = match options.start_dir { + Some(path) => canonicalize_from(Path::new("."), path)?, + None => std::env::current_dir().context("Failed to get current directory")?, + }; + let explicit_project_root = resolve_existing_path_arg(&start_dir, options.project_root)?; + let explicit_crate_path = resolve_existing_path_arg(&start_dir, options.crate_path)?; + let explicit_config_path = resolve_existing_path_arg(&start_dir, options.config_path)?; + + let loaded_config = load_layout_config( + &start_dir, + explicit_project_root.as_ref(), + explicit_crate_path.as_ref(), + explicit_config_path.as_ref(), + )?; + let (config, config_path) = match loaded_config { + Some((config, path)) => (Some(config), Some(path)), + None => (None, None), + }; + + let project_root = resolve_project_root_for_layout( + &start_dir, + explicit_project_root, + explicit_crate_path.as_ref(), + config_path.as_deref(), + ); + + let crate_dir = if let Some(crate_path) = explicit_crate_path { + crate_path + } else if let Some(configured_name) = config + .as_ref() + .and_then(|cfg| cfg.project.crate_name.as_deref()) + { + resolve_configured_crate_dir(&project_root, configured_name)?.ok_or_else(|| { + anyhow!( + "Configured benchmark crate '{}' was not found under {}", + configured_name, + project_root.display() + ) + })? + } else { + resolve_legacy_crate_dir(&project_root)? + }; + + let crate_name = read_package_name_from_dir(&crate_dir).ok_or_else(|| { + anyhow!( + "package.name not found in {}", + crate_dir.join("Cargo.toml").display() + ) + })?; + let library_name = config + .as_ref() + .and_then(|cfg| cfg.library_name()) + .unwrap_or_else(|| crate_name.replace('-', "_")); + let output_dir = config + .as_ref() + .and_then(|cfg| cfg.project.output_dir.clone()) + .map(|path| { + if path.is_absolute() { + path + } else { + project_root.join(path) + } + }) + .unwrap_or_else(|| project_root.join("target/mobench")); + let default_function = config + .as_ref() + .and_then(|cfg| cfg.benchmarks.default_function.clone()); + + Ok(ResolvedProjectLayout { + project_root, + crate_dir, + crate_name, + library_name, + config_path, + output_dir, + default_function, + }) +} + +fn discover_benchmarks_for_layout(layout: &ResolvedProjectLayout) -> Result> { + let mut benchmarks = + mobench_sdk::codegen::detect_all_benchmarks(&layout.crate_dir, &layout.crate_name); + benchmarks.sort(); + benchmarks.dedup(); + Ok(benchmarks) +} + +fn ensure_verify_smoke_test_supported(layout: &ResolvedProjectLayout) -> Result<()> { + let supported_embedded_crates = ["sample-fns", "basic-benchmark", "ffi-benchmark"]; + if supported_embedded_crates.contains(&layout.crate_name.as_str()) { + return Ok(()); + } + + bail!( + "verify --smoke-test is unsupported for external crate '{}'; smoke tests only work for benchmark crates linked into the mobench CLI binary", + layout.crate_name + ) +} + fn write_config_template(path: &Path, target: MobileTarget, overwrite: bool) -> Result<()> { ensure_can_write(path, overwrite)?; @@ -2110,7 +2525,9 @@ fn cmd_ci_summarize(args: CiSummarizeArgs) -> Result<()> { summarize::load_results_dir(dir)? } else { // If only build_id, we need results_dir too for now - anyhow::bail!("--results-dir is required. Use --build-id alongside --results-dir to enrich offline results with BrowserStack metrics."); + anyhow::bail!( + "--results-dir is required. Use --build-id alongside --results-dir to enrich offline results with BrowserStack metrics." + ); }; // Enrich with BrowserStack data if build_id provided @@ -2236,7 +2653,10 @@ fn cmd_ci_check_run(args: CiCheckRunArgs) -> Result<()> { let bench_count: usize = report.platforms.iter().map(|p| p.benchmarks.len()).sum(); let title = if has_regression { - format!("{bench_count} benchmarks \u{2014} {} regressed", annotations.len()) + format!( + "{bench_count} benchmarks \u{2014} {} regressed", + annotations.len() + ) } else { format!("{bench_count} benchmarks passed") }; @@ -2759,6 +3179,8 @@ fn resolve_run_spec( iterations: u32, warmup: u32, devices: Vec, + project_root: Option<&Path>, + crate_path: Option<&Path>, config: Option<&Path>, device_matrix: Option<&Path>, device_tags: Vec, @@ -2766,6 +3188,7 @@ fn resolve_run_spec( ios_test_suite: Option, local_only: bool, release: bool, + dry_run: bool, ) -> Result { if let Some(cfg_path) = config { let cfg = load_config(cfg_path)?; @@ -2830,10 +3253,25 @@ fn resolve_run_spec( && !resolved_devices.is_empty() && ios_xcuitest.is_none() { - println!("📦 Auto-packaging iOS artifacts for BrowserStack..."); - let artifacts = package_ios_xcuitest_artifacts(release)?; - println!(" ✓ IPA: {}", artifacts.app.display()); - println!(" ✓ XCUITest: {}", artifacts.test_suite.display()); + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root, + crate_path, + config_path: config, + })?; + let artifacts = if dry_run { + println!("📦 [dry-run] Would auto-package iOS artifacts for BrowserStack..."); + IosXcuitestArtifacts { + app: layout.output_dir.join("ios/BenchRunner.ipa"), + test_suite: layout.output_dir.join("ios/BenchRunnerUITests.zip"), + } + } else { + println!("📦 Auto-packaging iOS artifacts for BrowserStack..."); + let artifacts = package_ios_xcuitest_artifacts(&layout, release)?; + println!(" ✓ IPA: {}", artifacts.app.display()); + println!(" ✓ XCUITest: {}", artifacts.test_suite.display()); + artifacts + }; Some(artifacts) } else { ios_xcuitest @@ -2910,11 +3348,17 @@ fn filter_devices_by_tags(devices: Vec, tags: &[String]) -> Result< Ok(matched) } -fn run_ios_build(release: bool) -> Result<(PathBuf, PathBuf)> { - let root = repo_root()?; - let crate_name = - detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); - let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); +fn run_ios_build( + layout: &ResolvedProjectLayout, + release: bool, + dry_run: bool, +) -> Result<(PathBuf, PathBuf)> { + let builder = + mobench_sdk::builders::IosBuilder::new(&layout.project_root, layout.crate_name.clone()) + .verbose(true) + .dry_run(dry_run) + .crate_dir(&layout.crate_dir) + .output_dir(&layout.output_dir); let profile = if release { mobench_sdk::BuildProfile::Release } else { @@ -2926,22 +3370,22 @@ fn run_ios_build(release: bool) -> Result<(PathBuf, PathBuf)> { incremental: true, }; let result = builder.build(&cfg)?; - let header = root.join("target/ios/include").join(format!( - "{}.h", - result - .app_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("module") - )); + let header = layout + .output_dir + .join("ios/include") + .join(format!("{}.h", layout.library_name)); Ok((result.app_path, header)) } -fn package_ios_xcuitest_artifacts(release: bool) -> Result { - let root = repo_root()?; - let crate_name = - detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); - let builder = mobench_sdk::builders::IosBuilder::new(&root, crate_name).verbose(true); +fn package_ios_xcuitest_artifacts( + layout: &ResolvedProjectLayout, + release: bool, +) -> Result { + let builder = + mobench_sdk::builders::IosBuilder::new(&layout.project_root, layout.crate_name.clone()) + .verbose(true) + .crate_dir(&layout.crate_dir) + .output_dir(&layout.output_dir); let profile = if release { mobench_sdk::BuildProfile::Release } else { @@ -3318,54 +3762,16 @@ fn run_local_smoke(spec: &RunSpec) -> Result { /// /// This provides early feedback when a function name is misspelled or doesn't exist. /// If validation fails, it warns but continues (the final validation happens on device). -fn validate_benchmark_function(project_root: &Path, function_name: &str) -> Result<()> { - // Try to find the benchmark crate - let crate_name = detect_bench_mobile_crate_name(project_root).ok(); - - // Check common crate locations - let search_dirs = [ - project_root.join("bench-mobile"), - project_root.join("crates/sample-fns"), - project_root.to_path_buf(), - ]; - - // Extract the crate name from the function (e.g., "sample_fns::fibonacci" -> "sample_fns") - let function_crate = function_name.split("::").next().unwrap_or(""); - - let mut found_any_benchmarks = false; - let mut found_function = false; - - for dir in &search_dirs { - if !dir.join("Cargo.toml").exists() { - continue; - } - - // Determine the crate name for this directory - let dir_crate_name = crate_name.as_deref().unwrap_or(function_crate); - - // Detect all benchmarks in this directory - let benchmarks = mobench_sdk::codegen::detect_all_benchmarks(dir, dir_crate_name); - - if !benchmarks.is_empty() { - found_any_benchmarks = true; - - // Check if our function is in the list - if benchmarks.iter().any(|b| b == function_name) { - found_function = true; - break; - } - - // Also check without crate prefix (in case user specified just the function name) - let simple_name = function_name.split("::").last().unwrap_or(function_name); - if benchmarks - .iter() - .any(|b| b.ends_with(&format!("::{}", simple_name))) - { - found_function = true; - break; - } - } - } +fn validate_benchmark_function(layout: &ResolvedProjectLayout, function_name: &str) -> Result<()> { + let benchmarks = discover_benchmarks_for_layout(layout)?; + let found_any_benchmarks = !benchmarks.is_empty(); + let simple_name = function_name.split("::").last().unwrap_or(function_name); + let found_function = benchmarks + .iter() + .any(|benchmark| benchmark == function_name) + || benchmarks + .iter() + .any(|benchmark| benchmark.ends_with(&format!("::{}", simple_name))); if found_any_benchmarks && !found_function { // We found benchmarks but not the one requested - this is likely an error @@ -3375,15 +3781,8 @@ fn validate_benchmark_function(project_root: &Path, function_name: &str) -> Resu function_name ); println!(" Available benchmarks:"); - for dir in &search_dirs { - if !dir.join("Cargo.toml").exists() { - continue; - } - let dir_crate_name = crate_name.as_deref().unwrap_or(function_crate); - let benchmarks = mobench_sdk::codegen::detect_all_benchmarks(dir, dir_crate_name); - for bench in benchmarks { - println!(" - {}", bench); - } + for bench in benchmarks { + println!(" - {}", bench); } println!(); println!(" The run will continue, but the benchmark may fail on the device."); @@ -3406,8 +3805,12 @@ fn validate_benchmark_function(project_root: &Path, function_name: &str) -> Resu Ok(()) } -fn persist_mobile_spec(spec: &RunSpec, release: bool) -> Result<()> { - let root = repo_root()?; +fn persist_mobile_spec( + layout: &ResolvedProjectLayout, + spec: &RunSpec, + release: bool, +) -> Result<()> { + let root = &layout.project_root; let payload = json!({ "function": spec.function, "iterations": spec.iterations, @@ -3431,7 +3834,7 @@ fn persist_mobile_spec(spec: &RunSpec, release: bool) -> Result<()> { // IMPORTANT: Also embed the spec directly into the mobile app bundles // This ensures the requested benchmark function is always used, even when // the app is run via BrowserStack where file paths are different. - let mobench_output_dir = root.join("target/mobench"); + let mobench_output_dir = layout.output_dir.clone(); let apps_exist = mobench_output_dir.join("android").exists() || mobench_output_dir.join("ios").exists(); @@ -4537,11 +4940,12 @@ fn format_ms(value: Option) -> String { .unwrap_or_else(|| "-".to_string()) } -fn run_android_build(_ndk_home: &str, release: bool) -> Result { - let root = repo_root()?; - let crate_name = - detect_bench_mobile_crate_name(&root).unwrap_or_else(|_| "bench-mobile".to_string()); - +fn run_android_build( + layout: &ResolvedProjectLayout, + _ndk_home: &str, + release: bool, + dry_run: bool, +) -> Result { let profile = if release { mobench_sdk::BuildProfile::Release } else { @@ -4552,15 +4956,28 @@ fn run_android_build(_ndk_home: &str, release: bool) -> Result, output_dir: Option, crate_path: Option, dry_run: bool, verbose: bool, progress: bool, ) -> Result<()> { - // Load config file if present (mobench.toml) - let config_resolver = config::ConfigResolver::new().unwrap_or_default(); + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: project_root.as_deref(), + crate_path: crate_path.as_deref(), + config_path: None, + })?; + let effective_output_dir = output_dir.unwrap_or_else(|| layout.output_dir.clone()); // Progress mode: simplified output if progress { - let project_root = std::env::current_dir().context("Failed to get current directory")?; - let crate_name = detect_bench_mobile_crate_name(&project_root) - .unwrap_or_else(|_| "bench-mobile".to_string()); - let effective_output_dir = - output_dir.or_else(|| config_resolver.output_dir().map(|p| p.to_path_buf())); - let build_config = mobench_sdk::BuildConfig { target: target.into(), profile: if release { @@ -4687,16 +5104,14 @@ fn cmd_build( match target { SdkTarget::Android => { println!("[1/3] Building Rust library..."); - let mut builder = - mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name) - .verbose(false) - .dry_run(dry_run); - if let Some(ref dir) = effective_output_dir { - builder = builder.output_dir(dir); - } - if let Some(ref path) = crate_path { - builder = builder.crate_dir(path); - } + let builder = mobench_sdk::builders::AndroidBuilder::new( + &layout.project_root, + layout.crate_name.clone(), + ) + .verbose(false) + .dry_run(dry_run) + .output_dir(&effective_output_dir) + .crate_dir(&layout.crate_dir); println!("[2/3] Building Android APK..."); let result = builder.build(&build_config)?; println!("[3/3] Done!"); @@ -4706,15 +5121,14 @@ fn cmd_build( } SdkTarget::Ios => { println!("[1/3] Building Rust library..."); - let mut builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name) - .verbose(false) - .dry_run(dry_run); - if let Some(ref dir) = effective_output_dir { - builder = builder.output_dir(dir); - } - if let Some(ref path) = crate_path { - builder = builder.crate_dir(path); - } + let builder = mobench_sdk::builders::IosBuilder::new( + &layout.project_root, + layout.crate_name.clone(), + ) + .verbose(false) + .dry_run(dry_run) + .output_dir(&effective_output_dir) + .crate_dir(&layout.crate_dir); println!("[2/3] Building iOS xcframework..."); let result = builder.build(&build_config)?; println!("[3/3] Done!"); @@ -4724,30 +5138,26 @@ fn cmd_build( } SdkTarget::Both => { println!("[1/5] Building Rust library for Android..."); - let mut android_builder = - mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) - .verbose(false) - .dry_run(dry_run); - if let Some(ref dir) = effective_output_dir { - android_builder = android_builder.output_dir(dir); - } - if let Some(ref path) = crate_path { - android_builder = android_builder.crate_dir(path); - } + let android_builder = mobench_sdk::builders::AndroidBuilder::new( + &layout.project_root, + layout.crate_name.clone(), + ) + .verbose(false) + .dry_run(dry_run) + .output_dir(&effective_output_dir) + .crate_dir(&layout.crate_dir); println!("[2/5] Building Android APK..."); let android_result = android_builder.build(&build_config)?; println!("[3/5] Building Rust library for iOS..."); - let mut ios_builder = - mobench_sdk::builders::IosBuilder::new(&project_root, crate_name) - .verbose(false) - .dry_run(dry_run); - if let Some(ref dir) = effective_output_dir { - ios_builder = ios_builder.output_dir(dir); - } - if let Some(ref path) = crate_path { - ios_builder = ios_builder.crate_dir(path); - } + let ios_builder = mobench_sdk::builders::IosBuilder::new( + &layout.project_root, + layout.crate_name.clone(), + ) + .verbose(false) + .dry_run(dry_run) + .output_dir(&effective_output_dir) + .crate_dir(&layout.crate_dir); println!("[4/5] Building iOS xcframework..."); let ios_result = ios_builder.build(&build_config)?; @@ -4762,7 +5172,7 @@ fn cmd_build( } // Normal (verbose) mode - if let Some(config_path) = &config_resolver.config_path { + if let Some(config_path) = &layout.config_path { println!("Using config file: {:?}", config_path); } @@ -4776,28 +5186,9 @@ fn cmd_build( println!(" Verbose: enabled"); } - let project_root = std::env::current_dir().context("Failed to get current directory")?; - - // Use crate name from config if not auto-detected - let crate_name = detect_bench_mobile_crate_name(&project_root) - .or_else(|_| { - config_resolver - .crate_name() - .map(|s| s.to_string()) - .ok_or_else(|| anyhow!("Could not detect crate name")) - }) - .unwrap_or_else(|_| "bench-mobile".to_string()); // Fallback for legacy layouts - - // CLI flags override config file values - let effective_output_dir = - output_dir.or_else(|| config_resolver.output_dir().map(|p| p.to_path_buf())); - - if let Some(ref dir) = effective_output_dir { - println!(" Output: {:?}", dir); - } - if let Some(ref path) = crate_path { - println!(" Crate: {:?}", path); - } + println!(" Output: {:?}", effective_output_dir); + println!(" Project root: {:?}", layout.project_root); + println!(" Crate: {:?}", layout.crate_dir); let build_config = mobench_sdk::BuildConfig { target: target.into(), @@ -4813,16 +5204,14 @@ fn cmd_build( SdkTarget::Android => { println!("\nBuilding for Android..."); println!(" Building Rust library for Android targets..."); - let mut builder = - mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) - .verbose(verbose) - .dry_run(dry_run); - if let Some(ref dir) = effective_output_dir { - builder = builder.output_dir(dir); - } - if let Some(ref path) = crate_path { - builder = builder.crate_dir(path); - } + let builder = mobench_sdk::builders::AndroidBuilder::new( + &layout.project_root, + layout.crate_name.clone(), + ) + .verbose(verbose) + .dry_run(dry_run) + .output_dir(&effective_output_dir) + .crate_dir(&layout.crate_dir); let result = builder.build(&build_config)?; if !dry_run { println!("\u{2713} Built Android APK"); @@ -4833,16 +5222,14 @@ fn cmd_build( SdkTarget::Ios => { println!("\nBuilding for iOS..."); println!(" Building Rust library for iOS targets..."); - let mut builder = - mobench_sdk::builders::IosBuilder::new(&project_root, crate_name.clone()) - .verbose(verbose) - .dry_run(dry_run); - if let Some(ref dir) = effective_output_dir { - builder = builder.output_dir(dir); - } - if let Some(ref path) = crate_path { - builder = builder.crate_dir(path); - } + let builder = mobench_sdk::builders::IosBuilder::new( + &layout.project_root, + layout.crate_name.clone(), + ) + .verbose(verbose) + .dry_run(dry_run) + .output_dir(&effective_output_dir) + .crate_dir(&layout.crate_dir); let result = builder.build(&build_config)?; if !dry_run { println!("\u{2713} Built iOS xcframework"); @@ -4854,16 +5241,14 @@ fn cmd_build( // Build Android println!("\nBuilding for Android..."); println!(" Building Rust library for Android targets..."); - let mut android_builder = - mobench_sdk::builders::AndroidBuilder::new(&project_root, crate_name.clone()) - .verbose(verbose) - .dry_run(dry_run); - if let Some(ref dir) = effective_output_dir { - android_builder = android_builder.output_dir(dir); - } - if let Some(ref path) = crate_path { - android_builder = android_builder.crate_dir(path); - } + let android_builder = mobench_sdk::builders::AndroidBuilder::new( + &layout.project_root, + layout.crate_name.clone(), + ) + .verbose(verbose) + .dry_run(dry_run) + .output_dir(&effective_output_dir) + .crate_dir(&layout.crate_dir); let android_result = android_builder.build(&build_config)?; if !dry_run { println!("\u{2713} Built Android APK"); @@ -4874,15 +5259,14 @@ fn cmd_build( // Build iOS println!("\nBuilding for iOS..."); println!(" Building Rust library for iOS targets..."); - let mut ios_builder = mobench_sdk::builders::IosBuilder::new(&project_root, crate_name) - .verbose(verbose) - .dry_run(dry_run); - if let Some(ref dir) = effective_output_dir { - ios_builder = ios_builder.output_dir(dir); - } - if let Some(ref path) = crate_path { - ios_builder = ios_builder.crate_dir(path); - } + let ios_builder = mobench_sdk::builders::IosBuilder::new( + &layout.project_root, + layout.crate_name.clone(), + ) + .verbose(verbose) + .dry_run(dry_run) + .output_dir(&effective_output_dir) + .crate_dir(&layout.crate_dir); let ios_result = ios_builder.build(&build_config)?; if !dry_run { println!("\u{2713} Built iOS xcframework"); @@ -4899,86 +5283,21 @@ fn cmd_build( Ok(()) } -fn detect_bench_mobile_crate_name(root: &Path) -> Result { - // Try bench-mobile/ first (SDK projects) - let bench_mobile_path = root.join("bench-mobile").join("Cargo.toml"); - if bench_mobile_path.exists() { - let contents = fs::read_to_string(&bench_mobile_path) - .with_context(|| format!("reading bench-mobile manifest at {:?}", bench_mobile_path))?; - let value: toml::Value = toml::from_str(&contents) - .with_context(|| format!("parsing bench-mobile manifest {:?}", bench_mobile_path))?; - let name = value - .get("package") - .and_then(|pkg| pkg.get("name")) - .and_then(|n| n.as_str()) - .ok_or_else(|| { - anyhow!( - "bench-mobile package.name missing in {:?}", - bench_mobile_path - ) - })?; - return Ok(name.to_string()); - } - - // Fallback: Try crates/sample-fns (repository testing) - let sample_fns_path = root.join("crates").join("sample-fns").join("Cargo.toml"); - if sample_fns_path.exists() { - let contents = fs::read_to_string(&sample_fns_path) - .with_context(|| format!("reading sample-fns manifest at {:?}", sample_fns_path))?; - let value: toml::Value = toml::from_str(&contents) - .with_context(|| format!("parsing sample-fns manifest {:?}", sample_fns_path))?; - let name = value - .get("package") - .and_then(|pkg| pkg.get("name")) - .and_then(|n| n.as_str()) - .ok_or_else(|| anyhow!("sample-fns package.name missing in {:?}", sample_fns_path))?; - return Ok(name.to_string()); - } - - bail!( - "No benchmark crate found. Expected bench-mobile/Cargo.toml or crates/sample-fns/Cargo.toml under the project root. Run from the project root or set [project].crate in mobench.toml." - ) -} - /// List all discovered benchmark functions /// /// This uses source code scanning to find `#[benchmark]` functions, which works /// without requiring a full build. It also falls back to the inventory registry /// for any benchmarks that may be registered at runtime. -fn cmd_list() -> Result<()> { +fn cmd_list(_project_root: Option, _crate_path: Option) -> Result<()> { println!("Discovering benchmark functions...\n"); - let project_root = repo_root()?; - let mut all_benchmarks = Vec::new(); - - // Method 1: Source code scanning (works without build) - let search_dirs = [ - ("bench-mobile", project_root.join("bench-mobile")), - ("sample-fns", project_root.join("crates/sample-fns")), - ("ffi-benchmark", project_root.join("crates/ffi-benchmark")), - ("", project_root.clone()), - ]; - - for (default_crate_name, dir) in &search_dirs { - if !dir.join("Cargo.toml").exists() { - continue; - } - let crate_name = if default_crate_name.is_empty() { - if let Ok(name) = get_crate_name_from_cargo_toml(&dir.join("Cargo.toml")) { - name - } else { - continue; - } - } else { - default_crate_name.to_string() - }; - let benchmarks = mobench_sdk::codegen::detect_all_benchmarks(dir, &crate_name); - for bench in benchmarks { - if !all_benchmarks.contains(&bench) { - all_benchmarks.push(bench); - } - } - } + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: _project_root.as_deref(), + crate_path: _crate_path.as_deref(), + config_path: None, + })?; + let mut all_benchmarks = discover_benchmarks_for_layout(&layout)?; // Method 2: Inventory registry (for runtime-registered benchmarks) let registry_benchmarks = mobench_sdk::discover_benchmarks(); @@ -4993,12 +5312,7 @@ fn cmd_list() -> Result<()> { if all_benchmarks.is_empty() { println!("No benchmarks found.\n"); - println!("Searched locations:"); - for (name, dir) in &search_dirs { - if !name.is_empty() { - println!(" - {}: {}", name, dir.display()); - } - } + println!("Resolved crate: {}", layout.crate_dir.display()); println!("\nTo add benchmarks:"); println!(" 1. Add #[benchmark] attribute to functions"); println!(" 2. Make sure mobench-sdk is in your dependencies"); @@ -5019,21 +5333,12 @@ fn cmd_list() -> Result<()> { Ok(()) } -fn get_crate_name_from_cargo_toml(cargo_toml: &Path) -> Result { - let contents = fs::read_to_string(cargo_toml)?; - let value: toml::Value = toml::from_str(&contents)?; - let name = value - .get("package") - .and_then(|pkg| pkg.get("name")) - .and_then(|n| n.as_str()) - .ok_or_else(|| anyhow!("package.name not found in {:?}", cargo_toml))?; - Ok(name.to_string()) -} - /// Package iOS app as IPA for distribution or testing fn cmd_package_ipa( scheme: &str, method: IosSigningMethodArg, + project_root: Option, + crate_path: Option, output_dir: Option, ) -> Result<()> { println!("Packaging iOS app as IPA..."); @@ -5043,15 +5348,19 @@ fn cmd_package_ipa( println!(" Output: {:?}", dir); } - let project_root = repo_root()?; - let crate_name = detect_bench_mobile_crate_name(&project_root) - .unwrap_or_else(|_| "bench-mobile".to_string()); + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: project_root.as_deref(), + crate_path: crate_path.as_deref(), + config_path: None, + })?; + let effective_output_dir = output_dir.unwrap_or_else(|| layout.output_dir.clone()); - let mut builder = - mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); - if let Some(ref dir) = output_dir { - builder = builder.output_dir(dir); - } + let builder = + mobench_sdk::builders::IosBuilder::new(&layout.project_root, layout.crate_name.clone()) + .verbose(true) + .crate_dir(&layout.crate_dir) + .output_dir(&effective_output_dir); let signing_method: mobench_sdk::builders::SigningMethod = method.into(); let ipa_path = builder @@ -5071,22 +5380,31 @@ fn cmd_package_ipa( } /// Package XCUITest runner for BrowserStack testing -fn cmd_package_xcuitest(scheme: &str, output_dir: Option) -> Result<()> { +fn cmd_package_xcuitest( + scheme: &str, + project_root: Option, + crate_path: Option, + output_dir: Option, +) -> Result<()> { println!("Packaging XCUITest runner..."); println!(" Scheme: {}", scheme); if let Some(ref dir) = output_dir { println!(" Output: {:?}", dir); } - let project_root = repo_root()?; - let crate_name = detect_bench_mobile_crate_name(&project_root) - .unwrap_or_else(|_| "bench-mobile".to_string()); + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: project_root.as_deref(), + crate_path: crate_path.as_deref(), + config_path: None, + })?; + let effective_output_dir = output_dir.unwrap_or_else(|| layout.output_dir.clone()); - let mut builder = - mobench_sdk::builders::IosBuilder::new(&project_root, crate_name).verbose(true); - if let Some(ref dir) = output_dir { - builder = builder.output_dir(dir); - } + let builder = + mobench_sdk::builders::IosBuilder::new(&layout.project_root, layout.crate_name.clone()) + .verbose(true) + .crate_dir(&layout.crate_dir) + .output_dir(&effective_output_dir); let zip_path = builder .package_xcuitest(scheme) @@ -5105,6 +5423,8 @@ fn cmd_package_xcuitest(scheme: &str, output_dir: Option) -> Result<()> /// Verify benchmark setup: registry, spec, artifacts, and optional smoke test fn cmd_verify( + project_root: Option, + crate_path: Option, target: Option, spec_path: Option, check_artifacts: bool, @@ -5114,14 +5434,23 @@ fn cmd_verify( ) -> Result<()> { println!("Verifying benchmark setup...\n"); + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: project_root.as_deref(), + crate_path: crate_path.as_deref(), + config_path: None, + })?; + let resolved_benchmarks = discover_benchmarks_for_layout(&layout)?; + let effective_output_dir = output_dir.unwrap_or_else(|| layout.output_dir.clone()); + let mut checks_passed = 0; let mut checks_failed = 0; let mut warnings = 0; // 1. Check benchmark registry print!(" [1/4] Checking benchmark registry... "); - let benchmarks = mobench_sdk::discover_benchmarks(); - if benchmarks.is_empty() { + let registry_benchmarks = mobench_sdk::discover_benchmarks(); + if resolved_benchmarks.is_empty() && registry_benchmarks.is_empty() { println!("WARNING"); println!(" No benchmarks found in registry."); println!(" This may be expected if benchmarks are in a separate crate."); @@ -5130,9 +5459,15 @@ fn cmd_verify( ); warnings += 1; } else { - println!("OK ({} benchmark(s) found)", benchmarks.len()); - for bench in &benchmarks { - println!(" - {}", bench.name); + let total = resolved_benchmarks.len().max(registry_benchmarks.len()); + println!("OK ({} benchmark(s) found)", total); + for bench in &resolved_benchmarks { + println!(" - {}", bench); + } + if resolved_benchmarks.is_empty() { + for bench in ®istry_benchmarks { + println!(" - {}", bench.name); + } } checks_passed += 1; } @@ -5156,15 +5491,15 @@ fn cmd_verify( } } else { // Try default locations - let project_root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let output_base = output_dir - .clone() - .unwrap_or_else(|| project_root.join("target/mobench")); let default_paths = [ - output_base.join("android/app/src/main/assets/bench_spec.json"), - output_base.join("ios/BenchRunner/BenchRunner/bench_spec.json"), - project_root.join("target/mobile-spec/android/bench_spec.json"), - project_root.join("target/mobile-spec/ios/bench_spec.json"), + effective_output_dir.join("android/app/src/main/assets/bench_spec.json"), + effective_output_dir.join("ios/BenchRunner/BenchRunner/bench_spec.json"), + layout + .project_root + .join("target/mobile-spec/android/bench_spec.json"), + layout + .project_root + .join("target/mobile-spec/ios/bench_spec.json"), ]; let mut found_any = false; @@ -5199,20 +5534,15 @@ fn cmd_verify( // 3. Check artifacts if requested print!(" [3/4] Checking build artifacts... "); if check_artifacts { - let project_root = repo_root().unwrap_or_else(|_| PathBuf::from(".")); - let output_base = output_dir - .clone() - .unwrap_or_else(|| project_root.join("target/mobench")); - let mut artifacts_ok = true; let mut artifact_details = Vec::new(); if let Some(ref t) = target { match t { SdkTarget::Android | SdkTarget::Both => { - let apk_path = - output_base.join("android/app/build/outputs/apk/debug/app-debug.apk"); - let apk_release = output_base + let apk_path = effective_output_dir + .join("android/app/build/outputs/apk/debug/app-debug.apk"); + let apk_release = effective_output_dir .join("android/app/build/outputs/apk/release/app-release-unsigned.apk"); if apk_path.exists() { artifact_details.push(format!("Android APK (debug): {:?}", apk_path)); @@ -5224,10 +5554,12 @@ fn cmd_verify( } // Check JNI libs - let jni_base = output_base.join("android/app/src/main/jniLibs"); + let jni_base = effective_output_dir.join("android/app/src/main/jniLibs"); let abis = ["arm64-v8a", "armeabi-v7a", "x86_64"]; for abi in abis { - let lib_path = jni_base.join(abi).join("libsample_fns.so"); + let lib_path = jni_base + .join(abi) + .join(format!("lib{}.so", layout.library_name)); if lib_path.exists() { artifact_details.push(format!("JNI lib ({}): OK", abi)); } @@ -5238,7 +5570,9 @@ fn cmd_verify( match t { SdkTarget::Ios | SdkTarget::Both => { - let xcframework = output_base.join("ios/sample_fns.xcframework"); + let xcframework = effective_output_dir + .join("ios") + .join(format!("{}.xcframework", layout.library_name)); if xcframework.exists() { artifact_details.push(format!("iOS xcframework: {:?}", xcframework)); } else { @@ -5246,12 +5580,12 @@ fn cmd_verify( artifacts_ok = false; } - let ipa_path = output_base.join("ios/BenchRunner.ipa"); + let ipa_path = effective_output_dir.join("ios/BenchRunner.ipa"); if ipa_path.exists() { artifact_details.push(format!("iOS IPA: {:?}", ipa_path)); } - let xcuitest_path = output_base.join("ios/BenchRunnerUITests.zip"); + let xcuitest_path = effective_output_dir.join("ios/BenchRunnerUITests.zip"); if xcuitest_path.exists() { artifact_details.push(format!("XCUITest runner: {:?}", xcuitest_path)); } @@ -5260,8 +5594,11 @@ fn cmd_verify( } } else { // Check both platforms by default - let android_apk = output_base.join("android/app/build/outputs/apk/debug/app-debug.apk"); - let ios_xcframework = output_base.join("ios/sample_fns.xcframework"); + let android_apk = + effective_output_dir.join("android/app/build/outputs/apk/debug/app-debug.apk"); + let ios_xcframework = effective_output_dir + .join("ios") + .join(format!("{}.xcframework", layout.library_name)); if android_apk.exists() { artifact_details.push(format!("Android APK: {:?}", android_apk)); @@ -5294,7 +5631,11 @@ fn cmd_verify( // 4. Run smoke test if requested print!(" [4/4] Running smoke test... "); if smoke_test { - if let Some(ref func) = function { + if let Err(err) = ensure_verify_smoke_test_supported(&layout) { + println!("SKIPPED"); + println!(" {}", err); + warnings += 1; + } else if let Some(ref func) = function { match run_verify_smoke_test(func) { Ok(report) => { println!("OK"); @@ -5319,9 +5660,11 @@ fn cmd_verify( checks_failed += 1; } } - } else if !benchmarks.is_empty() { - // Use first discovered benchmark - let func = &benchmarks[0].name; + } else if let Some(func) = layout + .default_function + .as_ref() + .or_else(|| resolved_benchmarks.first()) + { match run_verify_smoke_test(func) { Ok(report) => { println!("OK"); @@ -6114,6 +6457,7 @@ fn cmd_fixture_build( SdkTarget::Android => cmd_build( SdkTarget::Android, release, + None, output_dir, crate_path, false, @@ -6124,6 +6468,7 @@ fn cmd_fixture_build( cmd_build( SdkTarget::Ios, release, + None, output_dir.clone(), crate_path, false, @@ -6133,14 +6478,17 @@ fn cmd_fixture_build( cmd_package_ipa( "BenchRunner", IosSigningMethodArg::Adhoc, + None, + None, output_dir.clone(), )?; - cmd_package_xcuitest("BenchRunner", output_dir)?; + cmd_package_xcuitest("BenchRunner", None, None, output_dir)?; } SdkTarget::Both => { cmd_build( SdkTarget::Android, release, + None, output_dir.clone(), crate_path.clone(), false, @@ -6150,6 +6498,7 @@ fn cmd_fixture_build( cmd_build( SdkTarget::Ios, release, + None, output_dir.clone(), crate_path, false, @@ -6159,9 +6508,11 @@ fn cmd_fixture_build( cmd_package_ipa( "BenchRunner", IosSigningMethodArg::Adhoc, + None, + None, output_dir.clone(), )?; - cmd_package_xcuitest("BenchRunner", output_dir)?; + cmd_package_xcuitest("BenchRunner", None, None, output_dir)?; } } Ok(()) @@ -7047,6 +7398,55 @@ mod tests { use jsonschema::JSONSchema; use tempfile::TempDir; + fn write_custom_layout_project(temp_dir: &TempDir) -> (PathBuf, PathBuf) { + let project_root = temp_dir.path().to_path_buf(); + let crate_dir = project_root.join("crates/zk-mobile-bench"); + + fs::create_dir_all(crate_dir.join("src")).expect("create custom crate dir"); + write_file( + &project_root.join("Cargo.toml"), + br#"[workspace] +members = ["crates/zk-mobile-bench"] +resolver = "2" +"#, + ) + .expect("write workspace manifest"); + write_file( + &project_root.join("mobench.toml"), + br#"[project] +crate = "zk-mobile-bench" +library_name = "zk_mobile_bench" + +[benchmarks] +default_function = "zk_mobile_bench::bench_query_proof_generation" +"#, + ) + .expect("write mobench config"); + write_file( + &crate_dir.join("Cargo.toml"), + br#"[package] +name = "zk-mobile-bench" +version = "0.1.0" +edition = "2021" +"#, + ) + .expect("write custom crate manifest"); + write_file( + &crate_dir.join("src/lib.rs"), + br#"#[benchmark] +pub fn bench_query_proof_generation() {} +"#, + ) + .expect("write custom crate source"); + + ( + project_root + .canonicalize() + .expect("canonicalize project root"), + crate_dir.canonicalize().expect("canonicalize crate dir"), + ) + } + // Register a lightweight benchmark for tests so the inventory contains at least one entry. #[mobench_sdk::benchmark] fn noop_benchmark() { @@ -7063,11 +7463,14 @@ mod tests { vec!["pixel".into()], None, None, + None, + None, Vec::new(), None, None, false, false, // release + false, ) .unwrap(); assert_eq!(spec.function, "sample_fns::fibonacci"); @@ -7126,6 +7529,8 @@ project = "proj" 1, 0, Vec::new(), + None, + None, Some(config_path.as_path()), Some(cli_matrix_path.as_path()), Vec::new(), @@ -7133,12 +7538,246 @@ project = "proj" None, false, false, + false, ) .expect("resolve spec"); assert_eq!(spec.devices, vec!["CLI Device".to_string()]); } + #[test] + fn parses_project_resolution_flags() { + assert!( + Cli::try_parse_from([ + "mobench", + "run", + "--target", + "ios", + "--function", + "zk_mobile_bench::bench_query_proof_generation", + "--crate-path", + "/tmp/custom-crate", + "--project-root", + "/tmp/project-root", + ]) + .is_ok() + ); + + assert!( + Cli::try_parse_from([ + "mobench", + "build", + "--target", + "ios", + "--project-root", + "/tmp/project-root", + ]) + .is_ok() + ); + + assert!( + Cli::try_parse_from([ + "mobench", + "package-ipa", + "--crate-path", + "/tmp/custom-crate", + "--project-root", + "/tmp/project-root", + ]) + .is_ok() + ); + + assert!( + Cli::try_parse_from([ + "mobench", + "package-xcuitest", + "--crate-path", + "/tmp/custom-crate", + "--project-root", + "/tmp/project-root", + ]) + .is_ok() + ); + + assert!( + Cli::try_parse_from([ + "mobench", + "list", + "--crate-path", + "/tmp/custom-crate", + "--project-root", + "/tmp/project-root", + ]) + .is_ok() + ); + + assert!( + Cli::try_parse_from([ + "mobench", + "verify", + "--crate-path", + "/tmp/custom-crate", + "--project-root", + "/tmp/project-root", + "--smoke-test", + ]) + .is_ok() + ); + } + + #[test] + fn resolver_uses_mobench_toml_for_custom_crate() { + let temp_dir = TempDir::new().expect("temp dir"); + let (project_root, crate_dir) = write_custom_layout_project(&temp_dir); + + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: Some(project_root.as_path()), + project_root: None, + crate_path: None, + config_path: None, + }) + .expect("resolve project layout"); + + assert_eq!(layout.project_root, project_root); + assert_eq!(layout.crate_dir, crate_dir); + assert_eq!(layout.crate_name, "zk-mobile-bench"); + assert_eq!(layout.library_name, "zk_mobile_bench"); + assert_eq!( + layout.default_function.as_deref(), + Some("zk_mobile_bench::bench_query_proof_generation") + ); + } + + #[test] + fn list_uses_resolved_layout_for_custom_crate() { + let temp_dir = TempDir::new().expect("temp dir"); + let (project_root, _) = write_custom_layout_project(&temp_dir); + + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: Some(project_root.as_path()), + project_root: None, + crate_path: None, + config_path: None, + }) + .expect("resolve project layout"); + + let benchmarks = discover_benchmarks_for_layout(&layout).expect("discover benchmarks"); + assert_eq!( + benchmarks, + vec!["zk_mobile_bench::bench_query_proof_generation".to_string()] + ); + } + + #[test] + fn verify_external_crate_smoke_test_is_unsupported() { + let temp_dir = TempDir::new().expect("temp dir"); + let (project_root, _) = write_custom_layout_project(&temp_dir); + + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: Some(project_root.as_path()), + project_root: None, + crate_path: None, + config_path: None, + }) + .expect("resolve project layout"); + + let err = ensure_verify_smoke_test_supported(&layout) + .expect_err("external crate smoke tests should be unsupported"); + assert!( + err.to_string().contains("external crate"), + "unexpected error: {err}" + ); + assert!( + err.to_string().contains("unsupported"), + "unexpected error: {err}" + ); + } + + #[test] + fn build_progress_uses_configured_crate() { + let temp_dir = TempDir::new().expect("temp dir"); + let (project_root, _) = write_custom_layout_project(&temp_dir); + + cmd_build( + SdkTarget::Ios, + false, + Some(project_root), + None, + None, + true, + false, + true, + ) + .expect("build --progress should resolve config-driven crate"); + } + + #[test] + fn verify_smoke_test_skips_external_crate() { + let temp_dir = TempDir::new().expect("temp dir"); + let (project_root, _) = write_custom_layout_project(&temp_dir); + + cmd_verify( + Some(project_root), + None, + None, + None, + false, + true, + Some("zk_mobile_bench::bench_query_proof_generation".to_string()), + None, + ) + .expect("verify should clearly skip unsupported external smoke tests"); + } + + #[test] + fn run_dry_run_prepares_ios_artifacts_inside_custom_project() { + let temp_dir = TempDir::new().expect("temp dir"); + let (project_root, _) = write_custom_layout_project(&temp_dir); + + let spec = resolve_run_spec( + MobileTarget::Ios, + "zk_mobile_bench::bench_query_proof_generation".into(), + 1, + 0, + vec!["iPhone 15".into()], + Some(project_root.as_path()), + None, + None, + None, + Vec::new(), + None, + None, + false, + false, + true, + ) + .expect("resolve dry-run spec"); + + let ios_xcuitest = spec + .ios_xcuitest + .expect("dry-run should prepare placeholder iOS artifacts"); + assert!( + ios_xcuitest.app.starts_with(&project_root), + "app path should stay inside project root: {}", + ios_xcuitest.app.display() + ); + assert!( + ios_xcuitest.test_suite.starts_with(&project_root), + "test suite path should stay inside project root: {}", + ios_xcuitest.test_suite.display() + ); + assert!( + ios_xcuitest + .app + .ends_with(Path::new("target/mobench/ios/BenchRunner.ipa")) + ); + assert!( + ios_xcuitest + .test_suite + .ends_with(Path::new("target/mobench/ios/BenchRunnerUITests.zip")) + ); + } + #[test] fn snapshot_baseline_creates_distinct_copy() { let temp_dir = TempDir::new().expect("temp dir"); @@ -7182,11 +7821,14 @@ project = "proj" vec!["iphone".into()], None, None, + None, + None, Vec::new(), None, None, false, false, // release + false, ) .expect("should auto-package iOS artifacts when missing"); let ios_artifacts = spec From 274420d0a1f2a2dc1cbf064095eb89833e8e467e Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 15:25:33 -0700 Subject: [PATCH 078/196] chore: drop tracked plan docs from pr --- ...obench-0.1.17-project-resolution-design.md | 145 -------- ...03-23-mobench-0.1.17-project-resolution.md | 319 ------------------ 2 files changed, 464 deletions(-) delete mode 100644 docs/plans/2026-03-23-mobench-0.1.17-project-resolution-design.md delete mode 100644 docs/plans/2026-03-23-mobench-0.1.17-project-resolution.md diff --git a/docs/plans/2026-03-23-mobench-0.1.17-project-resolution-design.md b/docs/plans/2026-03-23-mobench-0.1.17-project-resolution-design.md deleted file mode 100644 index 74c6d12..0000000 --- a/docs/plans/2026-03-23-mobench-0.1.17-project-resolution-design.md +++ /dev/null @@ -1,145 +0,0 @@ -# Mobench 0.1.17 Project Resolution Design - -**Status:** Approved - -**Goal:** Make `mobench` resolve project roots, benchmark crates, library names, dotenv files, and artifact paths from explicit inputs and `mobench.toml` first, with legacy `bench-mobile` behavior retained only as a fallback. - -## Problem - -`mobench 0.1.16` still spreads project detection across several helpers: - -- `repo_root()` / `is_repo_root()` treat a repo as valid only if it contains `bench-mobile/` or `crates/sample-fns/`. -- `detect_bench_mobile_crate_name()` is hardcoded to legacy crate names and is called from build, run, packaging, list, verify, and validation paths. -- `build --progress` bypasses config-based crate resolution. -- `load_dotenv()` anchors only to the legacy repo root. -- `list` and `verify` do not use config-based crate resolution. -- `verify --smoke-test` assumes the benchmark crate is linked into the CLI binary. - -That behavior works for the repository fixtures, but fails for custom layouts where the benchmark crate is configured in `mobench.toml` and does not live at `bench-mobile/`. - -## Selected Approach - -Introduce one CLI-side resolver that every relevant command uses. - -```rust -struct ResolvedProjectLayout { - project_root: PathBuf, - crate_dir: PathBuf, - crate_name: String, - library_name: String, - config_path: Option, - config: Option, - output_dir: PathBuf, - default_function: Option, - resolution_notes: Vec, -} -``` - -This keeps the SDK builders mostly unchanged. The CLI becomes responsible for determining the correct root and crate once, then passes the resolved paths/names into builders and helper functions. - -## Resolution Rules - -### Root discovery - -Project root resolution will use this precedence: - -1. Explicit `--project-root` -2. Parent directory of explicit `--config` -3. Parent directory of discovered `mobench.toml` -4. Cargo workspace root discovered from `cargo metadata` -5. Git root -6. Current working directory - -Legacy `bench-mobile/` detection remains only as a last-resort compatibility fallback when no stronger signal exists. - -### Crate discovery - -Crate resolution will use this precedence: - -1. Explicit `--crate-path` -2. `[project].crate` from `mobench.toml` -3. Cargo package at `project_root` if its package name matches the configured crate name -4. Cargo metadata package lookup under the resolved project root -5. Legacy fallback locations: - - `project_root/bench-mobile` - - `project_root/crates/` - - `project_root/` - - `project_root/crates/sample-fns` - -If the crate directory is found, `crate_name` comes from its `Cargo.toml`. `library_name` comes from `[project].library_name` when set, otherwise from `crate_name` with `-` replaced by `_`. - -## Command Changes - -The shared resolver will be used by: - -- `run` -- `build` -- `package-ipa` -- `package-xcuitest` -- `list` -- `verify` -- helper paths used by `run`, including auto-packaging and benchmark validation -- dotenv loading -- default output/spec path generation - -CLI parity changes: - -- Add `--crate-path` to `run`, `package-ipa`, `package-xcuitest`, `list`, and `verify` -- Add `--project-root` to `run`, `build`, `package-ipa`, `package-xcuitest`, `list`, and `verify` - -## List and Verify Semantics - -### `list` - -`list` will scan the resolved `crate_dir` first and optionally include runtime inventory entries from the CLI binary. The source-scanned list is authoritative for external crates. - -### `verify` - -`verify` will use the resolved layout for: - -- spec path defaults -- artifact path defaults -- artifact name checks derived from `library_name` -- benchmark discovery in the resolved crate - -`verify --smoke-test` will keep executing only for benchmark crates linked into the CLI binary. For external crates, it will return a clear unsupported message instead of printing `Available benchmarks: []`. - -This is a deliberate `0.1.17` scope limit. Subprocess-based smoke testing can be added later without changing the resolver contract. - -## Dotenv Loading - -Dotenv loading will use the resolved layout instead of legacy repo-root detection: - -- load `.env` -- load `.env.local` -- if `--config` points outside `project_root`, also try the config directory - -Later files may override earlier ones so `.env.local` still wins. - -## Default Naming - -Default benchmark names and artifact checks will derive from resolved values: - -- crate defaults from `crate_name` -- library defaults from `library_name` -- benchmark defaults from `[benchmarks].default_function` - -This removes remaining `bench_mobile::*` and `sample_fns` assumptions from config-aware paths. - -## Testing Strategy - -Add focused tests around resolver behavior and command semantics: - -- resolver uses `mobench.toml` without `bench-mobile/` -- `build --progress` respects config and resolved crate path -- `run --dry-run` with `--crate-path` stays under the repo -- `list` finds benchmarks in a custom crate named by config -- `verify` reports external smoke tests as unsupported instead of empty inventory -- artifact checks use resolved `library_name` - -## Non-Goals - -- Moving crate/root resolution into `mobench-sdk` -- Implementing subprocess smoke tests for external crates -- Redesigning `devices resolve --format json` - diff --git a/docs/plans/2026-03-23-mobench-0.1.17-project-resolution.md b/docs/plans/2026-03-23-mobench-0.1.17-project-resolution.md deleted file mode 100644 index e70431c..0000000 --- a/docs/plans/2026-03-23-mobench-0.1.17-project-resolution.md +++ /dev/null @@ -1,319 +0,0 @@ -# Mobench 0.1.17 Project Resolution Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Replace legacy repo-root/crate detection with one config-first resolver and wire it through `build`, `run`, packaging, `list`, `verify`, and dotenv loading. - -**Architecture:** Add a shared resolver in `crates/mobench` that computes `project_root`, `crate_dir`, `crate_name`, `library_name`, config path, and output directory once. Commands consume that resolved layout instead of calling hardcoded `bench-mobile` helpers directly. Verification remains source-scan based for external crates, with smoke tests explicitly unsupported unless the benchmark crate is linked into the CLI binary. - -**Tech Stack:** Rust, Clap, `cargo metadata`, `toml`, existing `mobench` CLI tests, existing `mobench-sdk` builders. - ---- - -### Task 1: Add resolver-focused tests first - -**Files:** -- Modify: `crates/mobench/src/lib.rs` -- Modify: `crates/mobench/src/config.rs` - -**Step 1: Write the failing tests** - -Add unit tests that create temporary custom-layout projects with: - -- a root `mobench.toml` -- a benchmark crate such as `zk-mobile-bench` -- `library_name = "zk_mobile_bench"` -- at least one `#[benchmark]` function or a source pattern detectable by `detect_all_benchmarks` - -Cover: - -- resolver finds the configured crate without `bench-mobile/` -- `build --progress` uses config-derived crate resolution -- `list` returns `zk_mobile_bench::*` -- `verify --smoke-test` reports unsupported for an external crate - -**Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test -p mobench resolver_ --lib -- --nocapture -cargo test -p mobench list_ --lib -- --nocapture -cargo test -p mobench verify_ --lib -- --nocapture -``` - -Expected: failures referencing missing shared resolution, hardcoded legacy crate lookup, or old verify semantics. - -**Step 3: Commit** - -```bash -git add crates/mobench/src/lib.rs crates/mobench/src/config.rs -git commit -m "test: cover config-first project resolution" -``` - -### Task 2: Introduce `ResolvedProjectLayout` - -**Files:** -- Modify: `crates/mobench/src/lib.rs` -- Modify: `crates/mobench/src/config.rs` - -**Step 1: Write the failing test** - -Add a test that exercises resolver precedence: - -- explicit `--project-root` -- explicit `--config` -- discovered `mobench.toml` -- Cargo workspace root -- git root -- legacy fallback - -**Step 2: Run test to verify it fails** - -Run: - -```bash -cargo test -p mobench resolved_project_layout_precedence --lib -- --nocapture -``` - -Expected: FAIL because the resolver type and precedence logic do not exist yet. - -**Step 3: Write minimal implementation** - -Implement: - -- `ResolvedProjectLayout` -- helpers for reading package names/manifests -- project-root resolution -- crate-dir resolution -- `library_name` derivation -- output-dir/default-function accessors - -Use `cargo metadata` when available, and fall back to legacy layout checks only at the end. - -**Step 4: Run test to verify it passes** - -Run: - -```bash -cargo test -p mobench resolved_project_layout_precedence --lib -- --nocapture -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/mobench/src/lib.rs crates/mobench/src/config.rs -git commit -m "feat: add shared project layout resolver" -``` - -### Task 3: Add CLI parity flags - -**Files:** -- Modify: `crates/mobench/src/lib.rs` - -**Step 1: Write the failing tests** - -Add parser tests showing that: - -- `run` accepts `--crate-path` and `--project-root` -- `package-ipa` accepts `--crate-path` and `--project-root` -- `package-xcuitest` accepts `--crate-path` and `--project-root` -- `list` accepts `--crate-path` and `--project-root` -- `verify` accepts `--crate-path` and `--project-root` - -**Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test -p mobench parses_project_resolution_flags --lib -- --nocapture -``` - -Expected: FAIL because the flags are not declared on all commands. - -**Step 3: Write minimal implementation** - -Add the new flags to the command variants and thread them into command handlers. - -**Step 4: Run test to verify it passes** - -Run: - -```bash -cargo test -p mobench parses_project_resolution_flags --lib -- --nocapture -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/mobench/src/lib.rs -git commit -m "feat: add project resolution flags across commands" -``` - -### Task 4: Switch build, run, and packaging helpers to the resolver - -**Files:** -- Modify: `crates/mobench/src/lib.rs` - -**Step 1: Write the failing tests** - -Add tests proving: - -- `build --progress` uses config-based crate resolution -- `run --dry-run` resolves within the temp repo and does not fall back to cargo registry paths -- packaging helpers use the resolved crate/layout metadata - -**Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test -p mobench build_progress_ --lib -- --nocapture -cargo test -p mobench run_dry_run_ --lib -- --nocapture -``` - -Expected: FAIL because the helpers still call `repo_root()` and `detect_bench_mobile_crate_name()`. - -**Step 3: Write minimal implementation** - -Replace direct calls to: - -- `repo_root()` -- `detect_bench_mobile_crate_name()` -- hardcoded builder construction paths - -with a single resolved layout passed into: - -- `run_ios_build` -- `run_android_build` -- `package_ios_xcuitest_artifacts` -- `cmd_build` -- `cmd_package_ipa` -- `cmd_package_xcuitest` -- `validate_benchmark_function` -- `persist_mobile_spec` - -**Step 4: Run tests to verify they pass** - -Run: - -```bash -cargo test -p mobench build_progress_ --lib -- --nocapture -cargo test -p mobench run_dry_run_ --lib -- --nocapture -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/mobench/src/lib.rs -git commit -m "feat: use shared resolver for build and packaging" -``` - -### Task 5: Fix dotenv, list, and verify semantics - -**Files:** -- Modify: `crates/mobench/src/lib.rs` - -**Step 1: Write the failing tests** - -Add tests showing: - -- dotenv loads from resolved project root and config directory -- `list` finds benchmarks in the resolved crate -- `verify` artifact checks use resolved `library_name` -- `verify --smoke-test` returns unsupported for external crates - -**Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test -p mobench dotenv_ --lib -- --nocapture -cargo test -p mobench list_ --lib -- --nocapture -cargo test -p mobench verify_ --lib -- --nocapture -``` - -Expected: FAIL because these paths still rely on legacy assumptions. - -**Step 3: Write minimal implementation** - -Update: - -- `load_dotenv` -- `cmd_list` -- `cmd_verify` -- `run_verify_smoke_test` - -Use the resolved layout for crate scanning and artifact names. Return an explicit unsupported error for external-crate smoke tests. - -**Step 4: Run tests to verify they pass** - -Run: - -```bash -cargo test -p mobench dotenv_ --lib -- --nocapture -cargo test -p mobench list_ --lib -- --nocapture -cargo test -p mobench verify_ --lib -- --nocapture -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/mobench/src/lib.rs -git commit -m "feat: fix resolver-based list verify and dotenv" -``` - -### Task 6: Update docs and final regression checks - -**Files:** -- Modify: `crates/mobench/README.md` -- Modify: `README.md` - -**Step 1: Write the failing test** - -Add or update parser/help tests if needed for documented flags and semantics. - -**Step 2: Run test to verify it fails** - -Run: - -```bash -cargo test -p mobench parses_project_resolution_flags --lib -- --nocapture -``` - -Expected: FAIL only if docs-driven CLI changes are still incomplete. - -**Step 3: Write minimal implementation** - -Document: - -- shared project resolution behavior -- new `--project-root` / `--crate-path` support -- `verify --smoke-test` limitations for external crates - -**Step 4: Run full verification** - -Run: - -```bash -cargo test -p mobench --lib -cargo test -p mobench --bins -cargo test -``` - -Expected: PASS, subject only to known environment-dependent platform tooling outside the resolver scope. - -**Step 5: Commit** - -```bash -git add crates/mobench/README.md README.md crates/mobench/src/lib.rs crates/mobench/src/config.rs -git commit -m "docs: document config-first project resolution" -``` From abef10560c789db7d3d67890c9145bfb1c87cc3c Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 15:36:51 -0700 Subject: [PATCH 079/196] docs: update release docs for 0.1.17 --- BENCH_SDK_INTEGRATION.md | 16 +++++++----- BROWSERSTACK_CI_INTEGRATION.md | 10 +++++--- BUILD.md | 42 ++++++++++++++++++------------ CLAUDE.md | 36 ++++++++++++++------------ Cargo.lock | 8 +++--- Cargo.toml | 2 +- FETCH_RESULTS_GUIDE.md | 4 ++- README.md | 18 ++++++++++--- TESTING.md | 42 +++++++++++++++--------------- crates/mobench-macros/README.md | 6 +++-- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench-sdk/README.md | 18 +++++++++---- crates/mobench/Cargo.toml | 2 +- crates/mobench/README.md | 45 ++++++++++++++++++++++++++++++--- 14 files changed, 166 insertions(+), 85 deletions(-) diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index 236dda9..f8c7322 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1" +mobench-sdk = "0.1.17" inventory = "0.3" # Required for benchmark registration [lib] @@ -79,13 +79,15 @@ cargo mobench check --target android # List discovered benchmarks cargo mobench list -# Verify registry, spec, and artifacts -cargo mobench verify --smoke-test --function my_crate::my_benchmark +# Verify registry, spec, and artifacts for the resolved crate +cargo mobench verify --check-artifacts --function my_crate::my_benchmark # (Optional) Validate BrowserStack device specs before running cargo mobench devices --validate "Google Pixel 7-13.0" ``` +`verify --smoke-test` is only supported for benchmark crates linked into the `mobench` CLI binary. For external crates discovered via `mobench.toml`, `--project-root`, or `--crate-path`, use `cargo mobench list` and `cargo mobench verify --check-artifacts`. + ## 1) Prerequisites Install the following tools (per platform): @@ -110,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1" +mobench-sdk = "0.1.17" ``` ## 3) Annotate benchmark functions @@ -641,8 +643,8 @@ cargo mobench check --target android --format json Use the `verify` command to validate your setup: ```bash -# Full verification with smoke test -cargo mobench verify --target android --check-artifacts --smoke-test --function my_crate::my_benchmark +# Full verification for a configured external crate +cargo mobench verify --target android --check-artifacts --function my_crate::my_benchmark # Check specific spec file cargo mobench verify --spec-path target/mobench/android/app/src/main/assets/bench_spec.json @@ -652,7 +654,7 @@ The verify command checks: 1. Benchmark registry has functions registered 2. Spec file exists and is valid 3. Build artifacts are present -4. Optional smoke test passes +4. Optional smoke test passes for benchmark crates linked into the CLI binary ### View Result Summaries diff --git a/BROWSERSTACK_CI_INTEGRATION.md b/BROWSERSTACK_CI_INTEGRATION.md index 46f09c3..4255bd9 100644 --- a/BROWSERSTACK_CI_INTEGRATION.md +++ b/BROWSERSTACK_CI_INTEGRATION.md @@ -74,8 +74,11 @@ Invalid devices (1): # Verify registry, spec, and artifacts cargo mobench verify --target android --check-artifacts -# Include smoke test -cargo mobench verify --target android --smoke-test --function my_benchmark +# Verify a configured external crate from repo root +cargo mobench verify --target android --check-artifacts --function my_benchmark + +# Smoke tests are only supported for benchmark crates linked into the mobench CLI binary +# (for example repository sample crates), not arbitrary external crates. # Render markdown summary from standardized CI output cargo mobench report summarize --summary target/mobench/ci/summary.json @@ -421,7 +424,7 @@ Invalid devices (1): 1. Check device logs manually in BrowserStack dashboard 2. Verify your app logs benchmark results as JSON to stdout/logcat 3. Use `client.get_device_logs()` to inspect raw logs -4. Run `cargo mobench verify --smoke-test` to test locally first +4. Run `cargo mobench list` plus `cargo mobench verify --check-artifacts` to validate the local setup first ### Build stuck in "running" state @@ -484,7 +487,6 @@ Validate benchmark setup: cargo mobench verify \ --target android \ --check-artifacts \ - --smoke-test \ --function my_benchmark ``` diff --git a/BUILD.md b/BUILD.md index 7d772d0..041ce44 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,6 +2,8 @@ Complete build instructions for Android and iOS targets. +In `mobench 0.1.17`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. + > **For SDK Integrators**: Use the CLI commands: > - `cargo mobench check --target android` (validate prerequisites first) > - `cargo mobench check --target ios` (validate prerequisites first) @@ -234,6 +236,12 @@ Then in Xcode: cargo mobench build --target ios ``` +For a custom repository layout: + +```bash +cargo mobench build --target ios --project-root . --crate-path ./crates/zk-mobile-bench --progress +``` + This build step: 1. Compiles Rust for iOS targets: - `aarch64-apple-ios` (physical devices) @@ -242,38 +250,40 @@ This build step: 2. Creates xcframework with structure: ``` - target/mobench/ios/sample_fns.xcframework/ + target/mobench/ios/.xcframework/ ├── Info.plist ├── ios-arm64/ - │ └── sample_fns.framework/ - │ ├── sample_fns (static library) + │ └── .framework/ + │ ├── (static library) │ ├── Headers/ - │ │ ├── sample_fnsFFI.h + │ │ ├── FFI.h │ │ └── module.modulemap │ └── Info.plist └── ios-simulator-arm64/ - └── sample_fns.framework/ - ├── sample_fns (static library) + └── .framework/ + ├── (static library) ├── Headers/ - │ ├── sample_fnsFFI.h + │ ├── FFI.h │ └── module.modulemap └── Info.plist ``` + The output name comes from `[project].library_name` or the resolved crate name. The sample project still produces `sample_fns.xcframework`. + 3. Copies UniFFI-generated C headers into each framework slice 4. Creates module maps for Swift interoperability 5. **Automatically code-signs the xcframework** (required for Xcode) -Output: `target/mobench/ios/sample_fns.xcframework` (signed) +Output: `target/mobench/ios/.xcframework` (signed) **Note**: The build step includes automatic code signing. If signing fails for any reason, you can sign manually: ```bash -codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/.xcframework ``` -Code signing is **required** for Xcode to accept and link the framework. Without signing, you'll see "The Framework 'sample_fns.xcframework' is unsigned" errors. +Code signing is **required** for Xcode to accept and link the framework. Without signing, you'll see "The Framework '.xcframework' is unsigned" errors. #### Step 2: Generate Xcode Project ```bash @@ -285,7 +295,7 @@ This generates `BenchRunner.xcodeproj` from `project.yml` specification. The gen - Source files from `BenchRunner/` directory - Generated Swift bindings (`BenchRunner/Generated/sample_fns.swift`) - Bridging header (`BenchRunner/BenchRunner-Bridging-Header.h`) -- Framework dependency on `../sample_fns.xcframework` +- Framework dependency on `../.xcframework` (the sample project uses `../sample_fns.xcframework`) #### Step 3: Build and Run in Xcode ```bash @@ -362,7 +372,7 @@ This makes C types (`RustBuffer`, `RustCallStatus`, etc.) available to Swift wit **Code Signing**: The build step automatically signs the xcframework. If signing fails, sign with: ```bash -codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/.xcframework ``` ## Common Issues @@ -431,17 +441,17 @@ rustls = { workspace = true } brew install xcodegen ``` -**Issue**: "The Framework 'sample_fns.xcframework' is unsigned" +**Issue**: "The Framework '.xcframework' is unsigned" ```bash -codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/.xcframework ``` **Issue**: "While building for iOS Simulator, no library for this platform was found" ```bash # Rebuild with correct structure -rm -rf target/mobench/ios/sample_fns.xcframework +rm -rf target/mobench/ios/.xcframework cargo mobench build --target ios -codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/.xcframework # Clean Xcode build cd target/mobench/ios/BenchRunner diff --git a/CLAUDE.md b/CLAUDE.md index 5887562..3ce208c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.14):** +**Published on crates.io as the mobench ecosystem (v0.1.17):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation @@ -180,34 +180,38 @@ cargo mobench verify --target android --check-artifacts Use `cargo mobench build --target ` for local or CI builds. The CLI handles library builds, binding generation, and app packaging without extra scripts. +In `mobench 0.1.17`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. + **Important iOS Build Details:** The mobench iOS builder creates an xcframework with the following structure (default output directory is `target/mobench/`): ``` -target/mobench/ios/sample_fns.xcframework/ +target/mobench/ios/.xcframework/ ├── Info.plist # XCFramework manifest ├── ios-arm64/ # Device slice -│ └── sample_fns.framework/ -│ ├── sample_fns # Static library (libsample_fns.a) +│ └── .framework/ +│ ├── # Static library (lib.a) │ ├── Headers/ -│ │ ├── sample_fnsFFI.h # UniFFI-generated C header +│ │ ├── FFI.h # UniFFI-generated C header │ │ └── module.modulemap # Module map for Swift import │ └── Info.plist └── ios-simulator-arm64/ # Simulator slice (M1+ Macs) - └── sample_fns.framework/ - ├── sample_fns # Static library (libsample_fns.a) + └── .framework/ + ├── # Static library (lib.a) ├── Headers/ - │ ├── sample_fnsFFI.h + │ ├── FFI.h │ └── module.modulemap └── Info.plist ``` +The output name comes from `[project].library_name` or the resolved crate name. The sample project still emits `sample_fns.xcframework`. + **Key Configuration Details:** -- Framework binary must be named `sample_fns` (the module name), not the platform identifier -- Each framework slice must be in `{LibraryIdentifier}/sample_fns.framework/` directory structure -- Module map defines the C module as `sample_fnsFFI` (matches what UniFFI-generated Swift code imports) +- Framework binary must be named after the resolved library name, not the platform identifier +- Each framework slice must be in `{LibraryIdentifier}/.framework/` directory structure +- Module map defines the C module as `FFI` (matches what UniFFI-generated Swift code imports) - Info.plist uses `iPhoneOS`/`iPhoneSimulator` platform identifiers with `SupportedPlatformVariant` - Framework bundle ID is `dev.world.sample-fns` (must not conflict with app bundle ID `dev.world.bench`) - The Xcode project uses a bridging header (`BenchRunner-Bridging-Header.h`) to expose C FFI types to Swift @@ -216,7 +220,7 @@ target/mobench/ios/sample_fns.xcframework/ **Automatic Code Signing**: The build step automatically signs the xcframework with: ```bash -codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/.xcframework ``` If automatic signing fails, the script will display a warning with instructions for manual signing. @@ -590,7 +594,7 @@ The workflow supports manual dispatch with platform selection: ```toml [dependencies] -mobench-sdk = "0.1" +mobench-sdk = "0.1.17" inventory = "0.3" ``` @@ -658,7 +662,7 @@ The mobench iOS builder manually constructs an xcframework (not using `xcodebuil 6. **Static vs Dynamic**: The xcframework contains static libraries (`.a` archives built with `staticlib` crate-type), not dynamic frameworks. This requires a bridging header in the Xcode project to expose C types to Swift. -7. **Code Signing**: After building, the xcframework must be code-signed for Xcode to accept it: `codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework` +7. **Code Signing**: After building, the xcframework must be code-signed for Xcode to accept it: `codesign --force --deep --sign - target/mobench/ios/.xcframework` ### Gradle Integration (Android) @@ -702,12 +706,12 @@ devices: ## Common iOS Build Issues and Solutions -### Issue: "The Framework 'sample_fns.xcframework' is unsigned" +### Issue: "The Framework '.xcframework' is unsigned" **Solution**: Code-sign the xcframework after building: ```bash -codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/.xcframework ``` ### Issue: "While building for iOS Simulator, no library for this platform was found" diff --git a/Cargo.lock b/Cargo.lock index 509c454..09e2f96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,7 +1017,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.15" +version = "0.1.17" dependencies = [ "anyhow", "clap", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.15" +version = "0.1.17" dependencies = [ "proc-macro2", "quote", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.15" +version = "0.1.17" dependencies = [ "anyhow", "include_dir", @@ -1522,7 +1522,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.15" +version = "0.1.17" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 46c29e2..7960d32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.15" +version = "0.1.17" [workspace.dependencies] anyhow = "1" diff --git a/FETCH_RESULTS_GUIDE.md b/FETCH_RESULTS_GUIDE.md index a708ee9..c006b72 100644 --- a/FETCH_RESULTS_GUIDE.md +++ b/FETCH_RESULTS_GUIDE.md @@ -343,9 +343,11 @@ Supports multiple report formats: Validate setup before running benchmarks: ```bash -cargo mobench verify --target android --check-artifacts --smoke-test +cargo mobench verify --target android --check-artifacts --function sample_fns::fibonacci ``` +For external crates configured via `mobench.toml`, use `cargo mobench list` and `cargo mobench verify --check-artifacts`; `verify --smoke-test` is only supported for benchmark crates linked into the `mobench` CLI binary. + ## See Also - `BROWSERSTACK_CI_INTEGRATION.md` - Programmatic API for custom workflows diff --git a/README.md b/README.md index b414533..0b2b8ce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # mobench -Mobile benchmarking SDK for Rust. Build and run Rust benchmarks on Android and iOS, locally or on BrowserStack, with a library-first workflow. +Mobile benchmarking SDK for Rust. Build and run Rust benchmarks on Android and iOS, locally or on BrowserStack, with a library-first workflow and config-first project resolution for custom repository layouts. ## What it is @@ -91,8 +91,8 @@ mobench supports a `mobench.toml` configuration file for project settings: ```toml [project] -crate = "bench-mobile" -library_name = "bench_mobile" +crate = "zk-mobile-bench" +library_name = "zk_mobile_bench" [android] package = "com.example.bench" @@ -108,9 +108,12 @@ default_iterations = 100 default_warmup = 10 ``` +Resolution precedence in `0.1.17` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. + CLI flags override config file values when provided. - In `cargo mobench run --config ` mode, `--device-matrix ` overrides `device_matrix` from the config file. - For regression comparisons, `--baseline` should point to a previous run summary; if it resolves to the same output path, mobench snapshots the prior file before writing the candidate summary. +- `cargo mobench verify --smoke-test` is only supported for benchmark crates linked into the `mobench` CLI binary. External crates discovered through `mobench.toml`, `--project-root`, or `--crate-path` should use `cargo mobench list` and `cargo mobench verify --check-artifacts`. ## Project docs @@ -199,6 +202,15 @@ fn db_query(db: &Database) { ## Release Notes +### v0.1.17 + +- Added a shared config-first project resolver across `build`, `run`, packaging, `list`, and `verify`. +- Added `--project-root` and `--crate-path` parity across the main CLI commands for custom repository layouts. +- `build --progress` now respects `mobench.toml` instead of assuming `bench-mobile`. +- Dotenv loading now follows the resolved project root and config path. +- `list` now discovers benchmarks from configured external crates instead of only legacy sample layouts. +- `verify --smoke-test` now reports external-crate smoke tests as unsupported instead of failing with an empty benchmark list. + ### v0.1.14 - Added CI contract-oriented commands and workflows: diff --git a/TESTING.md b/TESTING.md index 5e269ef..7090060 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,6 +8,8 @@ This document provides comprehensive testing instructions for mobile-bench-rs. > - See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide > **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see **[BUILD.md](BUILD.md)**. This document focuses on testing scenarios and troubleshooting. +In `mobench 0.1.17`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. + ## Table of Contents - [Prerequisites](#prerequisites) - [Host Testing](#host-testing) @@ -216,20 +218,20 @@ cargo mobench verify --target ios --check-artifacts # This build step: # - Compiles Rust for aarch64-apple-ios (device), aarch64-apple-ios-sim (Apple Silicon simulators), and x86_64-apple-ios (Intel simulators) # - Creates xcframework with proper structure: -# target/mobench/ios/sample_fns.xcframework/ +# target/mobench/ios/.xcframework/ # ├── Info.plist # ├── ios-arm64/ -# │ └── sample_fns.framework/ -# │ ├── sample_fns (binary) +# │ └── .framework/ +# │ ├── (binary) # │ ├── Headers/ -# │ │ ├── sample_fnsFFI.h +# │ │ ├── FFI.h # │ │ └── module.modulemap # │ └── Info.plist # └── ios-simulator-arm64/ -# └── sample_fns.framework/ -# ├── sample_fns (binary) +# └── .framework/ +# ├── (binary) # ├── Headers/ -# │ ├── sample_fnsFFI.h +# │ ├── FFI.h # │ └── module.modulemap # └── Info.plist # - Copies UniFFI-generated C headers into framework @@ -349,7 +351,7 @@ The `verify` command validates: - **Registry**: Benchmark functions are properly registered - **Spec**: `bench_spec.json` exists and is valid (if `--spec-path` provided) - **Artifacts**: Build outputs exist and are consistent (if `--check-artifacts`) -- **Smoke test**: Runs a local test with minimal iterations (if `--smoke-test`) +- **Smoke test**: Runs a local test with minimal iterations when the benchmark crate is linked into the CLI binary; external crates report unsupported for `--smoke-test` ### Android @@ -395,10 +397,10 @@ cd target/mobench/android && ./gradlew clean assembleDebug brew install xcodegen ``` -**Problem**: "The Framework 'sample_fns.xcframework' is unsigned" +**Problem**: "The Framework '.xcframework' is unsigned" ```bash # Solution: Code-sign the xcframework -codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/.xcframework # The build step includes signing, but if you built manually: cargo mobench build --target ios @@ -440,24 +442,24 @@ xcodegen generate **Problem**: Build fails with "library not found for -lsample_fns" or "framework 'ios-simulator-arm64' not found" ```bash # Solution: Ensure xcframework was built correctly with proper structure -rm -rf target/mobench/ios/sample_fns.xcframework +rm -rf target/mobench/ios/.xcframework cargo mobench build --target ios -codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/.xcframework # Verify structure: -ls -la target/mobench/ios/sample_fns.xcframework/ +ls -la target/mobench/ios/.xcframework/ # Should show: -# ios-arm64/sample_fns.framework/ -# ios-simulator-arm64/sample_fns.framework/ +# ios-arm64/.framework/ +# ios-simulator-arm64/.framework/ # Info.plist ``` **Problem**: "While building for iOS Simulator, no library for this platform was found" ```bash # Solution: Rebuild the xcframework - the structure may be incorrect -rm -rf target/mobench/ios/sample_fns.xcframework +rm -rf target/mobench/ios/.xcframework cargo mobench build --target ios -codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/.xcframework # Clean Xcode build folder cd target/mobench/ios/BenchRunner @@ -471,7 +473,7 @@ xcodebuild clean -project BenchRunner.xcodeproj -scheme BenchRunner # Check the iOS builder uses `dev.world.sample-fns` for the framework # Rebuild: cargo mobench build --target ios -codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/.xcframework ``` **Problem**: Simulator crashes with "Symbol not found" @@ -479,7 +481,7 @@ codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework # Solution: Clean and rebuild for simulator architecture cargo clean cargo mobench build --target ios -codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework +codesign --force --deep --sign - target/mobench/ios/.xcframework # In Xcode, clean (⌘+Shift+K) then build (⌘+B) ``` @@ -488,7 +490,7 @@ codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework - Ensure proper code signing is configured in Xcode - Select your development team in Xcode → Project Settings → Signing & Capabilities - Trust developer certificate on device: Settings → General → VPN & Device Management -- The xcframework must be signed: `codesign --force --deep --sign - target/mobench/ios/sample_fns.xcframework` +- The xcframework must be signed: `codesign --force --deep --sign - target/mobench/ios/.xcframework` ### UniFFI Bindings (Proc Macros) diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index 978b118..a408ad7 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -4,6 +4,8 @@ Procedural macros for the [mobench](https://crates.io/crates/mobench) mobile ben This crate provides the `#[benchmark]` attribute macro that automatically registers functions for mobile benchmarking. It uses compile-time registration via the `inventory` crate to build a registry of benchmark functions. +In `mobench 0.1.17`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. + ## Features - **`#[benchmark]` attribute**: Mark functions as benchmarks @@ -17,8 +19,8 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -mobench-macros = "0.1" -mobench-sdk = "0.1" # For the runtime +mobench-macros = "0.1.17" +mobench-sdk = "0.1.17" # For the runtime ``` ### Basic Example diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 296c051..90ee17a 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.14", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.17", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index f2231fb..250c1fe 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -15,6 +15,7 @@ Transform your Rust project into a mobile benchmarking suite. This SDK provides - **BrowserStack integration**: Test on real devices in the cloud - **UniFFI bindings**: Automatic FFI generation for mobile platforms - **Configuration file support**: `mobench.toml` for project settings +- **Config-first CLI integration**: `mobench 0.1.17` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root ## Quick Start @@ -22,7 +23,7 @@ Add mobench-sdk to your project: ```toml [dependencies] -mobench-sdk = "0.1" +mobench-sdk = "0.1.17" ``` Mark functions to benchmark: @@ -62,7 +63,7 @@ fn main() -> Result<(), Box> { println!("Mean: {} ns", report.mean_ns()); println!("Median: {} ns", report.median_ns()); - println!("Std dev: {} ns", report.stddev_ns()); + println!("Std dev: {} ns", report.std_dev_ns()); Ok(()) } @@ -85,6 +86,8 @@ This creates: - `android/` or `ios/` - Mobile app projects - `bench-config.toml` - Configuration file +The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.17` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. + ### 2. Add Benchmarks ```rust @@ -102,6 +105,9 @@ fn my_benchmark() { # Build to default output directory (target/mobench/) cargo mobench build --target android +# Build a custom crate from repo root +cargo mobench build --target ios --project-root . --crate-path ./crates/zk-mobile-bench + # Or with verbose output cargo mobench build --target android --verbose @@ -191,7 +197,7 @@ impl RunnerReport { pub fn median_ns(&self) -> u64; pub fn min_ns(&self) -> u64; pub fn max_ns(&self) -> u64; - pub fn stddev_ns(&self) -> f64; + pub fn std_dev_ns(&self) -> f64; pub fn percentile(&self, p: f64) -> u64; } ``` @@ -366,8 +372,8 @@ mobench automatically loads `mobench.toml` from the current directory or parent ```toml [project] -crate = "bench-mobile" -library_name = "bench_mobile" +crate = "zk-mobile-bench" +library_name = "zk_mobile_bench" # output_dir = "target/mobench" # default [android] @@ -385,6 +391,8 @@ default_iterations = 100 default_warmup = 10 ``` +Resolution precedence in `0.1.17` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. + ### `bench-config.toml` (Run Configuration) ```toml diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 2ab70b6..e309168 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.14", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.17", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 065a2e9..db5b6ba 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -45,6 +45,8 @@ This creates: - `mobench.toml` - Project configuration file (when using `init`) - `benches/example.rs` - Example benchmarks (with `--examples`) +Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.17` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. + ### 2. Write Benchmarks ```rust @@ -130,7 +132,8 @@ cargo mobench build --target [OPTIONS] - `--target ` - Platform to build for (required) - `--release` - Build in release mode (default: debug) - `--output-dir ` - Output directory for mobile artifacts (default: `target/mobench/`) -- `--crate-path ` - Path to the benchmark crate (default: auto-detect) +- `--project-root ` - Project root containing `mobench.toml` or the Cargo workspace +- `--crate-path ` - Path to the benchmark crate (default: resolve from flags, `mobench.toml`, workspace, or git root) - `--dry-run` - Print what would be done without making changes - `--verbose` / `-v` - Print verbose output including all commands @@ -154,7 +157,7 @@ cargo mobench build --target android --output-dir ./my-output **Outputs:** - Android: `target/mobench/android/app/build/outputs/apk/debug/app-debug.apk` -- iOS: `target/mobench/ios/sample_fns.xcframework` +- iOS: `target/mobench/ios/.xcframework` (derived from `[project].library_name` or the resolved crate name) ### `run` - Run Benchmarks @@ -167,6 +170,8 @@ cargo mobench run --target --function [OPTIONS] **Options:** - `--target ` - Platform (required) - `--function ` - Benchmark function name (required) +- `--project-root ` - Project root containing `mobench.toml` or the Cargo workspace +- `--crate-path ` - Path to the benchmark crate directory containing `Cargo.toml` - `--iterations ` - Number of iterations (default: 100) - `--warmup ` - Warmup iterations (default: 10) - `--devices ` - Comma-separated device list for BrowserStack @@ -194,6 +199,14 @@ cargo mobench run --target --function [OPTIONS] # Run locally (no BrowserStack devices specified) cargo mobench run --target android --function fibonacci_30 +# Run from a custom workspace layout +cargo mobench run \ + --project-root . \ + --crate-path ./crates/zk-mobile-bench \ + --target ios \ + --function zk_mobile_bench::bench_query_proof_generation \ + --dry-run + # Run on BrowserStack devices (use --release for smaller APK) cargo mobench run \ --target android \ @@ -309,6 +322,8 @@ cargo mobench package-ipa [OPTIONS] **Options:** - `--scheme ` - Xcode scheme (default: BenchRunner) - `--method ` - Signing method (default: adhoc) +- `--project-root ` - Project root containing `mobench.toml` or the Cargo workspace +- `--crate-path ` - Path to the benchmark crate directory containing `Cargo.toml` **Example:** ```bash @@ -327,6 +342,8 @@ cargo mobench package-xcuitest [OPTIONS] **Options:** - `--scheme ` - Xcode scheme for UI tests (default: BenchRunnerUITests) +- `--project-root ` - Project root containing `mobench.toml` or the Cargo workspace +- `--crate-path ` - Path to the benchmark crate directory containing `Cargo.toml` **Example:** ```bash @@ -370,8 +387,27 @@ Show benchmarks discovered via `#[benchmark]`: ```bash cargo mobench list +cargo mobench list --project-root . --crate-path ./crates/zk-mobile-bench ``` +`list` uses the same config-first resolver as `build` and `run`, so custom crate names from `mobench.toml` are discovered without a `bench-mobile/` directory. + +### `verify` - Validate Benchmark Setup + +Validate registry, spec files, build artifacts, and optional smoke tests: + +```bash +cargo mobench verify --target android --check-artifacts --function zk_mobile_bench::bench_query_proof_generation +``` + +**Options:** +- `--project-root ` - Project root containing `mobench.toml` or the Cargo workspace +- `--crate-path ` - Path to the benchmark crate directory containing `Cargo.toml` +- `--check-artifacts` - Validate resolved build outputs +- `--smoke-test` - Run a local minimal-iteration smoke test when supported + +`verify --smoke-test` only works for benchmark crates linked into the `mobench` CLI binary. For external crates resolved through `mobench.toml`, `--project-root`, or `--crate-path`, use `cargo mobench list` plus `cargo mobench verify --check-artifacts`. + ### `fetch` - Fetch Results Download BrowserStack build artifacts: @@ -438,10 +474,10 @@ mobench automatically loads `mobench.toml` from the current directory or any par ```toml [project] # Name of the benchmark crate -crate = "bench-mobile" +crate = "zk-mobile-bench" # Rust library name (typically crate name with hyphens replaced by underscores) -library_name = "bench_mobile" +library_name = "zk_mobile_bench" # Output directory for build artifacts (default: target/mobench/) # output_dir = "target/mobench" @@ -478,6 +514,7 @@ default_warmup = 10 ``` CLI flags always override config file values when provided. +Resolution precedence in `0.1.17` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### Run Config File Format (`bench-config.toml`) From 4db84f3b89dfc89882d4b02ed64736bdc2719ee6 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 16:08:35 -0700 Subject: [PATCH 080/196] fix: address code review findings for PR #16 - Restore global dotenv loading so fetch/doctor/ci commands still load .env.local (regression from removing the top-level load_dotenv call) - Eliminate double resolve_project_layout in resolve_run_spec by passing the already-resolved &ResolvedProjectLayout instead of raw path options - Remove misleading underscore prefix from cmd_list parameters - Replace hardcoded domain-specific humanize_benchmark_name (pi1/pi2 prefixes) with a generic strip-bench-prefix + hyphenate transformation - Change std_dev_ms from f64 with exact-equality skip to Option for robust serialization - Hide --token CLI arg (env-only via GITHUB_TOKEN) to avoid process listing exposure - Fix workflow shell injection: pass crate_path, repo, and SHA through env vars instead of direct ${{ }} interpolation in run blocks - Update workflow mobench_version default from 0.1.15 to 0.1.17 - Make summarize module pub(crate) to avoid premature public API surface --- .github/workflows/reusable-bench.yml | 11 ++-- crates/mobench/src/lib.rs | 77 +++++++++++++++++++--------- crates/mobench/src/summarize.rs | 53 ++++++++++--------- 3 files changed, 87 insertions(+), 54 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index be5d983..0617200 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -66,7 +66,7 @@ on: description: "Mobench version to install" required: false type: string - default: "0.1.15" + default: "0.1.17" mobench_ref: description: "Git ref for mobile-bench-rs (overrides mobench_version when set)" required: false @@ -578,16 +578,19 @@ jobs: PR_NUMBER: ${{ inputs.pr_number }} CHECK_RUN_NAME: ${{ inputs.check_run_name }} REGRESSION_THRESHOLD: ${{ inputs.regression_threshold_pct }} + CRATE_PATH: ${{ inputs.crate_path }} + GH_REPO: ${{ github.repository }} + GH_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | for dir in results/ios results/android; do if [ -d "$dir" ]; then PLATFORM=$(basename "$dir") cargo-mobench ci check-run \ --results-dir "$dir" \ - --repo "${{ github.repository }}" \ - --sha "${{ github.event.pull_request.head.sha || github.sha }}" \ + --repo "$GH_REPO" \ + --sha "$GH_SHA" \ --name "$CHECK_RUN_NAME — ${PLATFORM}" \ - --annotation-path "${{ inputs.crate_path }}/src/lib.rs" \ + --annotation-path "${CRATE_PATH}/src/lib.rs" \ --regression-threshold-pct "$REGRESSION_THRESHOLD" \ || true fi diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 6fd8d56..38ea7cd 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -139,7 +139,7 @@ use browserstack::{BrowserStackAuth, BrowserStackClient}; mod browserstack; pub mod config; mod github; -pub mod summarize; +pub(crate) mod summarize; /// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. #[derive(Parser, Debug)] @@ -764,8 +764,8 @@ struct CiCheckRunArgs { #[arg(long)] sha: String, - /// GitHub App token (from actions/create-github-app-token). - #[arg(long, env = "GITHUB_TOKEN")] + /// GitHub App token (from GITHUB_TOKEN env var or actions/create-github-app-token). + #[arg(long, env = "GITHUB_TOKEN", hide = true)] token: String, /// Check Run name displayed in the PR. @@ -1055,6 +1055,9 @@ enum RemoteRun { } pub fn run() -> Result<()> { + // Load dotenv globally as a baseline for commands that don't resolve a layout + // (e.g. fetch, doctor, ci run). Layout-aware commands reload from the resolved root. + load_dotenv_global(); let cli = Cli::parse(); match cli.command { Command::Run { @@ -1097,8 +1100,7 @@ pub fn run() -> Result<()> { iterations, warmup, devices, - project_root.as_deref(), - crate_path.as_deref(), + &layout, config.as_deref(), device_matrix.as_deref(), device_tags, @@ -3179,8 +3181,7 @@ fn resolve_run_spec( iterations: u32, warmup: u32, devices: Vec, - project_root: Option<&Path>, - crate_path: Option<&Path>, + layout: &ResolvedProjectLayout, config: Option<&Path>, device_matrix: Option<&Path>, device_tags: Vec, @@ -3253,12 +3254,6 @@ fn resolve_run_spec( && !resolved_devices.is_empty() && ios_xcuitest.is_none() { - let layout = resolve_project_layout(ProjectLayoutOptions { - start_dir: None, - project_root, - crate_path, - config_path: config, - })?; let artifacts = if dry_run { println!("📦 [dry-run] Would auto-package iOS artifacts for BrowserStack..."); IosXcuitestArtifacts { @@ -3267,7 +3262,7 @@ fn resolve_run_spec( } } else { println!("📦 Auto-packaging iOS artifacts for BrowserStack..."); - let artifacts = package_ios_xcuitest_artifacts(&layout, release)?; + let artifacts = package_ios_xcuitest_artifacts(layout, release)?; println!(" ✓ IPA: {}", artifacts.app.display()); println!(" ✓ XCUITest: {}", artifacts.test_suite.display()); artifacts @@ -4966,6 +4961,14 @@ fn run_android_build( Ok(result) } +/// Load .env/.env.local from the repo root (best-effort, for commands that don't resolve a layout). +fn load_dotenv_global() { + if let Ok(root) = repo_root() { + let _ = dotenvy::from_path(root.join(".env")); + let _ = dotenvy::from_path_override(root.join(".env.local")); + } +} + fn load_dotenv_for_layout(layout: &ResolvedProjectLayout) { let mut directories = vec![layout.project_root.clone()]; if let Some(config_path) = &layout.config_path @@ -5288,13 +5291,13 @@ fn cmd_build( /// This uses source code scanning to find `#[benchmark]` functions, which works /// without requiring a full build. It also falls back to the inventory registry /// for any benchmarks that may be registered at runtime. -fn cmd_list(_project_root: Option, _crate_path: Option) -> Result<()> { +fn cmd_list(project_root: Option, crate_path: Option) -> Result<()> { println!("Discovering benchmark functions...\n"); let layout = resolve_project_layout(ProjectLayoutOptions { start_dir: None, - project_root: _project_root.as_deref(), - crate_path: _crate_path.as_deref(), + project_root: project_root.as_deref(), + crate_path: crate_path.as_deref(), config_path: None, })?; let mut all_benchmarks = discover_benchmarks_for_layout(&layout)?; @@ -7455,14 +7458,20 @@ pub fn bench_query_proof_generation() {} #[test] fn resolves_cli_spec() { + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: None, + config_path: None, + }) + .unwrap(); let spec = resolve_run_spec( MobileTarget::Android, "sample_fns::fibonacci".into(), 5, 1, vec!["pixel".into()], - None, - None, + &layout, None, None, Vec::new(), @@ -7523,14 +7532,20 @@ project = "proj" ); write_file(&config_path, config_toml.as_bytes()).expect("write config"); + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: None, + config_path: None, + }) + .unwrap(); let spec = resolve_run_spec( MobileTarget::Android, "ignored::value".into(), 1, 0, Vec::new(), - None, - None, + &layout, Some(config_path.as_path()), Some(cli_matrix_path.as_path()), Vec::new(), @@ -7734,14 +7749,20 @@ project = "proj" let temp_dir = TempDir::new().expect("temp dir"); let (project_root, _) = write_custom_layout_project(&temp_dir); + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: Some(project_root.as_path()), + project_root: None, + crate_path: None, + config_path: None, + }) + .expect("resolve layout"); let spec = resolve_run_spec( MobileTarget::Ios, "zk_mobile_bench::bench_query_proof_generation".into(), 1, 0, vec!["iPhone 15".into()], - Some(project_root.as_path()), - None, + &layout, None, None, Vec::new(), @@ -7813,14 +7834,20 @@ project = "proj" #[test] fn ios_requires_artifacts_for_browserstack() { + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: None, + config_path: None, + }) + .unwrap(); let spec = resolve_run_spec( MobileTarget::Ios, "sample_fns::fibonacci".into(), 1, 0, vec!["iphone".into()], - None, - None, + &layout, None, None, Vec::new(), diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index 7ea9de6..27140f5 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -5,10 +5,6 @@ use comfy_table::{presets::UTF8_FULL, Attribute, Cell, ContentArrangement, Table use serde::{Deserialize, Serialize}; use std::path::Path; -fn is_zero(v: &f64) -> bool { - *v == 0.0 -} - /// A fully-assembled summary ready for rendering. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SummarizeReport { @@ -55,8 +51,8 @@ pub struct TimingStats { pub best_ms: f64, pub worst_ms: f64, pub p95_ms: f64, - #[serde(skip_serializing_if = "is_zero")] - pub std_dev_ms: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub std_dev_ms: Option, } /// Resource usage metrics from BrowserStack session. @@ -169,7 +165,11 @@ fn parse_benchmark_entry(value: &serde_json::Value) -> Result { best_ms: ns_to_ms("min_ns"), worst_ms: ns_to_ms("max_ns"), p95_ms: ns_to_ms("p95_ns"), - std_dev_ms: ns_to_ms("std_dev_ns"), + std_dev_ms: value + .get("std_dev_ns") + .and_then(|v| v.as_f64()) + .filter(|&v| v > 0.0) + .map(|v| v / 1_000_000.0), }; Ok(BenchmarkResult { @@ -180,19 +180,15 @@ fn parse_benchmark_entry(value: &serde_json::Value) -> Result { }) } +/// Produce a human-friendly label from a fully-qualified benchmark name. +/// +/// Strips leading `bench_` and converts underscores to hyphens. +/// The raw function name is always preserved in [`BenchmarkResult::name`]. fn humanize_benchmark_name(name: &str) -> String { - let s = name - .replace("bench_", "") - .replace("_generation", "") - .replace("_only", ""); - - if s.contains("nullifier") { - format!("\u{03C0}2 {}", s.replace('_', "-")) - } else if s.contains("query") { - format!("\u{03C0}1 {}", s.replace('_', "-")) - } else { - s.replace('_', "-") - } + // Use the last segment if qualified (e.g. "crate::bench_foo" → "bench_foo") + let leaf = name.rsplit("::").next().unwrap_or(name); + let s = leaf.strip_prefix("bench_").unwrap_or(leaf); + s.replace('_', "-") } /// Load all summary JSON files from a results directory. @@ -513,12 +509,19 @@ mod tests { fn test_humanize_benchmark_name() { assert_eq!( humanize_benchmark_name("bench_nullifier_proving_only"), - "\u{03C0}2 nullifier-proving" + "nullifier-proving-only" ); assert_eq!( humanize_benchmark_name("bench_query_proof_generation"), - "\u{03C0}1 query-proof" + "query-proof-generation" + ); + // Qualified names use the last segment + assert_eq!( + humanize_benchmark_name("zk_mobile_bench::bench_fibonacci"), + "fibonacci" ); + // No bench_ prefix is fine + assert_eq!(humanize_benchmark_name("my_func"), "my-func"); } #[test] @@ -556,14 +559,14 @@ mod tests { }, benchmarks: vec![BenchmarkResult { name: "bench_nullifier_proving_only".to_string(), - label: "\u{03C0}2 nullifier-proving".to_string(), + label: "nullifier-proving-only".to_string(), timing: TimingStats { avg_ms: 1204.5, median_ms: 1198.0, best_ms: 1180.2, worst_ms: 1298.1, p95_ms: 1290.0, - std_dev_ms: 35.2, + std_dev_ms: Some(35.2), }, resource_usage: Some(ResourceUsage { cpu_avg_percent: Some(94.0), @@ -597,14 +600,14 @@ mod tests { }, benchmarks: vec![BenchmarkResult { name: "bench_nullifier_proving_only".to_string(), - label: "\u{03C0}2 nullifier-proving".to_string(), + label: "nullifier-proving-only".to_string(), timing: TimingStats { avg_ms: 1204.5, median_ms: 1198.0, best_ms: 1180.2, worst_ms: 1298.1, p95_ms: 1290.0, - std_dev_ms: 35.2, + std_dev_ms: Some(35.2), }, resource_usage: None, }], From 092071bdb82452a3eeab67b61b7cab6db26cb676 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 16:53:12 -0700 Subject: [PATCH 081/196] chore: add mobench logo to README Add Nokia 3310 "MOBENCH" artwork as the project logo in assets/ and reference it at the top of the root README. --- README.md | 4 ++++ assets/mobench.jpg | Bin 0 -> 321734 bytes 2 files changed, 4 insertions(+) create mode 100644 assets/mobench.jpg diff --git a/README.md b/README.md index 0b2b8ce..8acaf94 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ mobench +

+ # mobench Mobile benchmarking SDK for Rust. Build and run Rust benchmarks on Android and iOS, locally or on BrowserStack, with a library-first workflow and config-first project resolution for custom repository layouts. diff --git a/assets/mobench.jpg b/assets/mobench.jpg new file mode 100644 index 0000000000000000000000000000000000000000..647b612614cac92d1ae3c8792ada16c2f4cc52b5 GIT binary patch literal 321734 zcmeFa2|QJ6`!~J^?8rQmVVg5-?QNdtd7d+*Y}>{*&qHJkC9^0(<|z~jsZ*3OGK)|W z5;7*C!G9^|ROj?O&pGEg{oeokds}^$y@q?;*L4rqeXaZZz3$cS=KQ0HJffqtRY{2gsVBg!su@YEG zGzujwDJF(O+S?+3w}C(qKnuuTmLMZZ3E*g7HVTQ{d!7IU-Pa$`3&aS<|D-c5P@qHm z$1K3{;r%oaAPpGuSxaujS$2U; zz`&z00vPFm1Eeg}6x1w#dE0FQQ5*o}f^wl?b`XRD45a|?wt$#{VnKmg*(=BA4W9st z2LnS6fCz!xYUCgY7z%|z2>|tBF#Np=f#Ts)fGF7rP*jSBFlu(RXSh&u9u0?5b@KsE zqX=4ITd(~7H9F&zf|`LTJMYMQt3W=7zC za2n>*+^<^ol4)ok|Cp&DEY7RJ+m+zAK(licO^m4Sz|**8<&16MkW=>aUYFu?p3+#L zVM?WpOJnHdOpK9T%iRb%^;oIYvEM?21I|0+@KTLzM8$PORGe_Ss38AjY_um6vL!q_ z;|_T_EMtT5{wR|fKRpAt+O|okqeg^Xf;+8jl#S0ZK`%Ms-ivcfSIe((UWq5T&q&{t z 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

R$hV)6l1~z)PtkxAr9#} zDS^a^HeO4DN&pmdCzgCYbhm)mn`Jin-!p(}4%2NbPbrc3cK|s6IRXB8({MB`b4ijW z-H}S4pQ!iIcJv`2BV-U99p>d28lu{!&yLbrqm`q%a6K}`mk18zY=_Y7_?&XaFAXlrd*_X(jv%FxFc^klxl(uy(m^@y$?mGI!(X-2 zQDmK1MZA%yKrjmss18c1u5i1|Las(gt2b;q8Ps)1ex~p(g^ZXJyU4Om@#Gdrk}c@a zxErQMnYTeCMotK}Croxs;!QTdclsuwlS@bmzo;id*H+_E*0mHdU+OY;osl{0p#_>X zKcsib$K6nagKrAfKvGRE#ZeU$VL-qh0CE(x3i?)}>HusuA4N;lo3-6~D|p^%q*!4< zS(&f^1DPc70;yeVt!1>axPr>+bYN6SB2-h6fIyZ=+^-zuCoWm1UbdPyZKtGXiW`f4 z;o_o2I6heeHCmf+1_e;aT4@4D*2trAz+<|k`lhmz#kf>5wl|cFk5L``YAd-;T2gSV zyvEy7kP_D#PJ5IfwaD6x3t3j54@QTof9WdGg~o%Z1I{N&SN3E8kKJbW;;v{^VyZu+ z8Bg>Ut&auwc8QvQ1d%I#p zn-v(^3xD)TPlKWNxA@m){%LAO>ZRLwo|<~U8jTgj$n$`FBtOok8+e|QdXLVbf%9|f zhyMVmDMGXv>YgTgjOg;_;_uXh_;dKwnW=|QwB37${N@<;C?7o4Savps>$JJ($jt=M zqe`Q;2*Z@e(q*A|CH)*XxZwQh)2jX74axwfcwn{);J>f-^h{{Rqwy;9U# ziR;jISGMoVv_cQ8WDoAtz9r$|O2^zI%h;k_dGVxM+;Di(sVN)NAZ&uUaHAuIHS5d8 zSabeV?61ci!{cz}~A@&%F4@kdlX z%ggAg{{TEH%j$=D^=+cJCxXu$&>u2T$e#8{Cx zT?lH!wZnax5NFxM{4-h$>5bX77Me}?i!Tkv<2dFLMc&;$M8(5Ak1w=qF7#)172$5+ zN4W6$8qvjexKi&Frj;9yPDw@Ep_sw6<2ikXNhg({N*Zrgc1F!&mC&C{@YHwUi3wA`wR*G9M+}j z#@rit+r1r6v9b@x6-Diykh8-wbL|n3{?d{7)~-9Y2w+iJyI^$=(%J^+nAhG0>G+De zdM=;0v}oIi0ps=@{syy;LArr9n~lylbpH3^YW?c{Dc-nF?cl@PTQhFd+-JH@cCRIZld zxY^Av>D&$2QWriA7vzq8WpnB^pFWs9lMQF4USo<(@Sjwvygo5}Y6tXq^noL37nWe< zBbYHB3nKSpET->(LDGAn)LL{X#^euSyx|Hz0r4`qvr@dy404tvfB3>#}j^-P2bkXIEd)tn9V=?N3AVn_d6STN&ue?_c zFl;W}MSBEy5S+&2JVqHvBr-7OuB-~Oo*{u1AvN^i=DoHSSrmi4UKAYAfu+ISO6U}m zRd30;{S)$~pweAIGLAft%84^=g?YTHh7m>sK!#2fB5Q+??zdaGLn~dya0=jBkX0*m zGZt&-PNi~1_PhuG0BziS&3zlUubqEV-@e)w?iaTM<650MKl=~M`ls*2re8QMS{8eX%UWC=ipAzzjtZ$@2Bql-D}a+DDk;M${(c5(F+Y=O+l1 zc#`})DxF5$V*~#HAL>=|MxDp1onFY>kGuA_PCb3qfVv0OM6plW1NE^ioxXn2lYO#V zzyAPFD%F~H-|B4W&t(*GF&R-YG9Wz3^a3_Jz&@gCw&@3;nIXNOQ`1pol2!b*yEfo= z9Hfo0g>(0nh8U7}fi#pF9>*}6J6GGX$(7+V$rqIX)PGIsb{BI5%51Jj28Lm{;Q42R z4+5m-q*%zvAc|G^@-Wka5=~w$K@UJl01l{&Bu?#%NxL0E3MyaoAI&=_<2&6eAqRUVigFt>2O*bnxC>|u91J-H`PuNoN z7P+e51nhYIx7{DO&G5xCZC2dJY|-|IbxVaOxpEE*9_7vkPYxoZmRh&hZqiuJZDGia z_d6VKxcwdh7st{)^*g$=R}2|L0x)tjk>F{K0fYyA&>SeGwtB=a?HWj~>H#~XFafz_ zju|-Roa2r$O47cF!g>gl6jMr9#5~UYnrW*?<; zM%&BFhmB*&6XP`Sud*D2g-tpu+Juc>Ct#<0coV}QfuCr_LW@uYbfmGF8>Jozd^hS- z&XqQ<-eDR<;ztCMM<590oy|bk}RE zSi?28R0Z8A-)c5bMWb=t=y)*i;KPFo&&wY$X%A2@$!hyeY@_mGOL>QYX`^n_+DfTD zpkZPE z^ks#`*iiBf*y2KPB$`GE8WZ4MhVkxD$GWn9&GcEnBv*IRz3uQj=1CpOE1&jI=d+De z5)dk64mr;n=;SS7DQRD%t7~hzV4nR^eySjYcs?X-&FKA{g#4Um#dqZJB~Ji_RFPeZ z)Cy3KS?+mpw63D<%5IVxlGyC)1skJyW4fcn@ik;@oyPQP!^4lPa5ascBb!v4#$#Mf zsG&F{5rPg6;qRjZ(#@!l85?%E*q=3QJ{@kIMs32QMdFxX-{-aeky9i z2hO0qY;A*S8rc5;?^zGIb6WUgOs)E>N6Pur)wt%;FD?H7?J@jnFVZAimMDA-XW}YF z)s+7LqNx2R`O|+(aRFlB{($!H3hSJF{{VHm!Q@X}Ab(iCs)R`Y0Oat1GBF&yt)*QsXUtbDJ@_R)qbM2(E$4vs1L5_bOR9)oRQ%6da zWHz91J@pO>!Fy%rz$4E-`gPT|`|4LZRoEOMxk(!x;RqyqS9p7BKcF@VALoS`(glZU zXCUTK?+<4`hH9A&xYeOF_4SJ62Ky&?9$4=Oh(6Izy%%40S1}X79I5@Gf$_yuT~{`* z>$+Z#K6i|)KmwgRtyY{d@cpInIryn2>w!YfS0?!x>H=~g^Sa_?95$yzu_@A-e zAIo7rsbMGmUg_co$9E$>rV6Y)b5-dMB)NrqCtwP&dhTrpWpSWth%?!?N&QBSRUX{G z#zv{oYNs>kb^47uXa6><9yx_|KHWBV1PI=gL$*7Vyc$;%sf)SuFe z@=SfEGJUj6mQr2&fK`y2+foH#sfTJqsv&xX9lkDQ?7d$Gwu~-EM8-hS{A~N8FHZhT)y|VylCKN$$;0 zyE~O|wgMV5Axu{K>Rdx3+RT!0V622V4i|?kfXRfN9JKkR1OK*8MYnZp!(M7`} z0N-U-m~Vv*krD~?QWm6&j>RSqSj(qQkuGfY;8x;Z#551NzQH$0P2szp-P4B1#Z{4O zBr7dn(sh>owBUCp_wM1p3ZMgWHK!zuKJuPK5Pe65W|0Y8t8)a8am61SYYLCqWNjZ$ z>@T%zenDp6AF5f$$(FA!f$w*2cMm58eZ0k19Wz^Y8tj9Ro)vFy;-BqAYH`ug-MN7G zZx4-h4LX>ZX`%|Tdb#RdhSciNuLZfZ5Bo@C&%{5rx3@vF?#Rk}*W#pqGhaRSe^D0O zQ>HU;?V`AV`tZ!Y@>Evy=+65S#C%B}^*@d)Cn#3w;w`EX^q6dq*0y@|ftG`_oqV@s z3g5ubUq1SS?bp@s=C)&mxrLN}#EfOQySL_ZKi@i z9nmgnH1|W>PWm24tNray(E9fgKeTHvRaVDwmvgmY;{15#t$SUdU{rmf-{b47{{YcmvpuLbvRlaUM$ECvyrgz{ z%wq(G%CDe!ecHD)eR4U2ZXDyEaT(?B@#S4sCVP&+p%w=fWTBx{4`g+g4poWWB=hd6 zq*{U#fWV&&*JF({T;2Idy@jPJ0)sOEIPe~HpHjTsMq$K&O;uWj!$@-MTg$k5g-%vu zVin4~P?w-yTj7`vK%9EB!nhWKt41nU80T5KTPze1S!l!_HOhf?gxcvXtnMBYiHS&~ zJ`W=*DfSl6vaQ7rRW=gMU!_z_g|~t;aO%UhD~;Hc^4?>>x$Z%h#}WYJSXX0p+}&&W z@4dJ__BF9KKWAgLhBy<59N7J*WA>eodokiq;flmt9-rxY@BaXW&HbjW-jqX|C)ryW z_aLQZ^t)%;Uu-DuF>&xdgjomHD^pEpyTEW2n7T=9Uu+-zGyRCGRq6}Ilj~x{RsR5w z^(c7<%zmgEb?6)0!+4Rv_-Ck@m@O>Gt40o@X!whwj* z86Lw}x1!y$hSuGpkceas`{;cg#w>E6cLB%`0b7bIvMCa5#gUZE){!*F@03z~59l8DKBjy91#I$ zC&PD`K0DaOGt1KZBta&Ci`E!Oz$F;>9sWQLY%`IAjPR+y)YCiLqkG2#G>2(+YAcxc zBpzUn1y6BP+(k1RqX*&J>X>Yey~2#?8ccVPeO~bvS>nJa9KriCA?5>)IUJ5b;wlxk zy${15X5hOby5(Kn*L0kHmLzaE_EWB*6xNQNsW}-D&IWQ1HFfXL-d;`ikcS*)7TZFX zQM57ulCqF6a88?dB+7S9`_)b z-pgZousLxutt)ONo<-cYcwqqVnLrF%90I5(faX}9Jb>?}U2BEaksLDazQY?Inu?uC z7AkTW#(>b#!p+Nz!gU1_5!Xct#RLlJSIz)I#dCYvT;HHMsIpXDB3-Gz_hzD(JMpU$ zH%TLo?>rOXp6Yak0W`;}?U!V3FoIQxDPpG^na1#a>-R1`}Y9&>GlAkQ} zWQ{_`ii_Z&06lNv>-*W?M-@+S`eX3y?vS#lO6Z8jb^}Y&Pn6X2NZM8>**jRhb(i?b#Td2 zj*fL$mP6VQ4-6B^k0E=Q0)ThjOZrXLO|6voTf6NXFDCCSl~UODk&3kXiga1^9W>7K zi<_Ir7gEXw0Wu<$Mdo)SUqyYS0i0HC>l@QVX%v<=z+GL)rAnyZ5TlLLB)H{(NmTnz zae`qS(JCOf!|eSwb;PpQ|+wlToyML_HxNF7>_%F3myQu z50pX8jtK)e1mIR(hoD<&b0zlm<--!p&dv}M%taVrv7qFt262p%c-Ewl844Wv40hpO zapsf=W)BOZMg!BPP<^KGi%Z)MH2i}|4@jQ;tS zLGO(EsS}L$osb(KQ&Sl^G_Oh3oj%K8R1ac$O*b-21H#s~F@Rb&A+n8-vLg%!jj%>= z3b&%lr-e;y)Z;V>fNOlg6|Uc8KR8|ew&kn2K^kM6Nm^4RNXvfvi0gdSpeCY12 z0~V-!T2H`!6ds($MWFGWKN{>DJodHN?W&Eh}AB{(rpEl}D%Jk5wSj-A5^AdHejUr0Y`ZVl?^1WZ)24f)l+=uc2)KK>Pu-5 zV94A-829j|9btHe^%{TE-_jliuHkK^c906!zeUE-nRLl*nC0#6xOpUs7=IecS}oGC zKhcM^%MPon*`M{X8CUh8bN$I!`j)ojja}#Ws!!lY#UfeS&OsZQ@dR)_-Uf^6sb*~UlT))kf=v@jgT#gs z06$esqu6UxDYt1syJ>8=#UbSFCH#Mz1#VnDWhDT-fZn0X{om)C+f_Uw8 z@*G^OWMqN3LS*o4=R3IOJB=0b_X_2zebT>3Yh!CPqC{E37L6Q?xdaXikX!a@CDps8 zX|*q`iNbG3xL0vX`YpYj}gbUF5`ui(b48BDbxP*)#VE|?P1-dLcy5#J8a3yikw5J}>te$O60&NWIc_R_fJ zbM6cH3NNmHox7-`at{h0XfD5ity!>ow{2-_DErYy-t-yx*RM-$K@=8Hy|>y6axSKM z))>mW@5l=7h8YaI{6Xc6ch-^U%cdJ$Nu?I0{{SbtPWtZfY~YS3^G7QM(&UZ3=mx~<%M+q=>NGEM-HD`(UT zcXIHlj9o~tKK0Ba{_y_*a8X@$H0{kr9Dl5Z54B4n_yJlYX8L5YYj_?@*ip=ASTu+T zVxu68`mDCBsJGZUtoreOXB@y^)OLmw4mcZ~lQgN^MHn zrIi&VS17@Ax_uQO@yQ&>=6PnKJvHs^g~pwA8eT&36bz~)QcEia01WXb7{xSoGt#ai zj_At^#}oE%8!#bT?E+Uk_->r|P|cRvLJ5YsONQ&dIRZ-;@W##R+uH3knaK% z0l&J1D<&rvbqN8adi1#OP(ex^_NwXAyq*;r+a(5DjOjEBw5n3As~ zl^A}jhF|Y3K^wHG1zCLrZ6LVQtlz(~PZN-ssoM#6ifyPE~ zKse1+{Ytg1{WKvwW0BL(NgJ9JS#DnGwm(lQ z%YLao2C;Wuy$r48tiXM^O8aAT_&B01`lf3#fLie}=QOALwHoPDt8Lz$ZZ2n?CL8L6 zF8L#P5)N1n85s78ym2*-88_mcJO6J_->!<(tv4@g;_XRGp-$;=A)$CW1eDfP%1~l zLq*$Mlq$o4QV+P(=U&Sb^;l&bzZ~)$?Pm6y#V~+zxTc8~aE2*U`Hx!0Em@FbqAT zBD1pxlr0$htsmL(uZ7)bb~ox$+$?^Hx4WJXP!HO|eX+R5s=n!1+=l^&5I{cuHS&k) z2iVZMrnwmBBo?=-JmWuClODs9>dj<8&C&6l<}Fw6M*B=JV79m;`smhAYbYdp4o9q0 zl(JPz)Ej7feJ0o*CK)7%`m@?E!c2QA*KYdd^I{Rm=KR)l!%Nb6TO3CNV6yTwH_$ z>=kkbPb`uY4{0C|GfNGvX0VbI+$5_VFzp_@C7LYou2LQ@gYUuqvmd7z{Gxd7mB*!0 zgi0}NgAfSM@~sSK>2M)bg(|eZR|?2ODH#NEAn^ow5lQac_c2r4M=?;IuTyVn`f={; zfFG*6%D=(|OyuTku@W6Ngf}~)^&M(g7%p*57y9IIp^|A9D91Kp6-TU|0-W9<#GXK8 z)E@U_A!hBaM{h1kMQ$<1JGW5q-^Vu~wtmhB3iNQgQ)|*r*!BtYL)*Jq_e4{x7W+KL z_v+*g?^@=c%vB53?Gk9(DF9{1579lvPcXpo1Ym>8r28oBB8^?Q+$aS1k_Qv(Jk5G+ zSI4@fc_Hb_JAOu}PO@Dp*h>@HpliGR^ki58V;rdJ%F-|eP$?deW1M7Sru{eSEX{3U z42+L3XPKAnBq@MEAmvPqLIckm^!}wAC*nU;oV5#jfL?>Y zSYT|kLh&>m>Huo;O+z-U3d&nks~_q6?cyTDP=!!2+b#(*vHQ%8n8D^0ZueB3Z-f7%!IN{oLvXN0hOsA-!lt zY`#WJ8bhdn1s|88{^}eMh@jA|x)If-2eg_CGz0FInf;jvquZZVLi#D24S5{XJhR6u zCL}yU3~rs!02|y+K*w;Z82qKyHtoudDqSd|S*R&`t)0>iI2?B$16<#sw}i2hicu^( z%eufa_z{qO^$^##1qNCr03LCkDBp^=gAO$^?JI^X_t8VCH4aIQz=Z>YW7cRMBxX)I z3dbfX0p0`?$AJLglaNjh4NdkJX$;A`Al#V_>#u_k?vaK=-lQKIWZDMof6@mX-VzVa zkMqA;K7MPwJSjfXU5LuxMd@yUB?FH1T*HqRi#x-;kh1twL7ppgpR(xZ4a$pCc?svF zJ)iC1xP)_LAbzmozr+PawntIliWl(%H8`}^R#2{_5Jo`oiplur}u?EbfK=8ZKW&5LN^l}%fF(y_L^wv zB?-2Z9Kgu+k4St)GijRV*X54m{X}3D{V9s^K4LI&`o#EFgv>52ao{I1F+692rP!_m zUS||rn%QWwzAjx$rkN~urXvi~O2RaW-5maI>u}HOF6#lz?+j3Ot7uvvi5hm75*^sn zJpTYWnD-(MEZy7(GAbFe8z*gYP1VFfrz}G*^LBgDu|P^>D=^0fU`=Vq80E>lOjD3) zKU?&aC07=ZmT;hklx`=aNCzKnxgSS$bBq!>(w&*pmN@$(`Ap}0k@ne%F3dT4M3P5= z$01QIm!B7NN8h+lx>g@1sV>ktMMxuTT+NWaF`)Z$k>G>u__{(~+#4O# zw@I~$ruwvFFk6KU=O;8w_m%#!{8+Ag>Q$;~i6XMF1Re+5`1y`CS6fDve4^HkArENw zqBxx)R^OeNdJDvb&!p{-vY=$-<0k-TH17*%pDJb0HhCaGk*cWu5EnjY9M74_r@Rd;{>l$~ z(;;&qvw&Sfm0!5lIPb#^?=Wh@Q;OS;7Bhf=_qcGI_IqYwwoS-m5wDQv=6p|? z;%Y;(x?C~bc7iaiAi->X;5ZEUu=kY|h2Moqc0SQ0Pe^ZJf%Or^XwQ>HQbLQ0ntfi3 zR<4GwsI4B#<7L7tf$PfE$Et(sm=Cb3?wpz9Ya6&h62U67xaKX%3p%%o6g$j3P%z>v zKwDgb0aK88jB(GZp|e1&j2LE;bx_pVpuCO2F~e~zcIZR{eFgAr_6T zGjcH$_PZ*NiK{;5*M|zE{*nbNplW_^2tL{4r*QAaX!(Rjjn)|I{BdV(=4Afc55kJ+ ze~W2@#&q~n{)eO8wXz?fpL;Raz<48)Z(4D zsym^+CAg2jA;+nQACJzY3TTgNhB4m7)5)4NL<*ToqA z@1#Cd1|1J+`KAkXF~j7WItRTEVI*hnvyXTTJ~^PtJuvlazW)HF#CyWGxRpQJ6!9ux zkZZojHqf|fn@>f|b5OU+U-y9``}lt~sn+d8HsLp9wU@!lpH@L1-m7Gdfmef?QZjka z9METK2K7`bJ7R`UeRZVIeHYqSB&#VTQuX_A9#rwB%aEp-B^(VoZ2XyAcpA2(yS7sP zi1vXr?SDqP2X!Sa48QiQ@UcFnksH}xN+*5>zEJd~Zho&E=y343V0@?gvR+(*O;k#)Q1^ZG+X)@=gs zyFk)L2NGn631b7yx&iH|1Tq-PvYZvc9?mtTw2R>3Bpu)%e;ViVypL ze|{MA%}Gyc;IHtIJ(V=+%UKpWriZJJ3LK=Ls2DR3Y;#Dy%3{@aOzC>wyQgDtM?Y@} z<@r7At~`u0D|(m8o;rQgez&5^!;3*8%s*7&Ti64Ve^$CjpAn66ND3q^#~{h{eIebB z@#E^n5W=h8soP#|^xZO2a>g#rkBdZFBcC(68{1Y&Env7&q_eqLQ>Bd^BGx8Z7crTY z*dFzAuHQ3O2Uw1ZKBQ&UZ6|0LGAKyYAV9!I#aT$kd5{6p(LEZ?(%N&x&CqCYJ zs{5;@{{YqXcoDcA=ZN~svhn?(D`~+1U$Ox50~Ln0*5ui1a=7G|A^nWu{zj#zRfPcB zvwHPd9NA0<56U6_79ZZLPpdY&+Ua(~nLyG0>zscHT4C#bq;{Hi`2PS`DmSq2A^5PS zzNI>bZ7yQ`$wPY`2jYgS{EOepv^QcH6+DhX^&S<5I=)?teO}o~{{UF=AKF$=?P9l5 zaI71*@_kQQna2&wkteXYljLe6;Drr6m7&+SO31OB&O|=l)*mLQKSdo_7W&pJpRR5O zEDDJ8x>S|McgA@G%f7E3xw<%gI5_*0A@(q3`H@ULPc;B+WLP ztbf*A#Dsoyh(2vtpQGEc7PT8i=|(O{%H)EMp zVQ*n?16;;s!p+9;{T0EZIooWovYO(LJu8+6!peH}So^%-P+}KG8Vl zd2^|XFLhIQJ#sX`#0j6(7#}wkBj~%V6}F=@!whmp%Dee+yg(sNH(8EGLF3$MXEEHB zLKc7M5)o^yc1MR9qW5oO;)1W5>NPE)ED*Imlk`8ld_|y!xiS z+GG#Q$0xD(OMKe4Ysu$RD~O`4Yv;d9yHj&D$&T-I%Lp*#R|5#gkBP}7f^c~9tzoW{ zd$X*=iyLKc+K2`}6g30uDbSPxDfI}q&B2ii4CQddDZ+t}bA!*utUj#MZ|PCRBA-Ob z83bd2B;yCk>Rj+z2<)XmI*V&~7qDl&Bp%SpeoI=3WUQIC{TSS6uN1mRZ7awfBqbJH zC@Rb7i1=jj-Z|xgjyYh5Y__HOM>zigutoWxph*{qbpoyyn7ir9(J#|ZJLch-FWJI^ zS9l%bMnM=H0pe+PW7tfw98Bxl$|5A8KRbQ{rS%-0g$YWD7KRK#L;Y#grl-Hot! zaKC7NHDYy*=(b%>(ISU;wpkU|c;I6Ut;Ywr=a(E*GfNy5tw}l#*SmWQIWv-BO#ZL| z8~BvdXIX6zw$iUxnN$)F`!-iU!VN6w8)7A~u!`Dprd2PU)0M+HU;)5A{v7IMt?7#_ za>m0d>dJjgq#uE$+(3Q&)T$4tuG5CLxwj;5B%9!QNTcyX@2e}PXniOf#1ZZwkC~{y zSKhA;q{}R(>$l%Pr9y6uh6FZC5^=<11MM1|^wkz(jN_A!h(F4`Bf*lwlfupYY3#dg zKJ)>d)D8QLt@u?N(XOk%1+o1F9^t$AR-xKmAWs#{N0gCBzVtPX`W(9d07p^YuFLVy z<6AfO(e#$^i*)|Ovi=oJ!R7m{2#VP~9Z8rVJ~6MrWALme^qK5GYpZEYr*-GG zx`ZDS80{5Lh;8z?^R2(o-QNA2EIYT?_hfbaD=PgcQW#rjFGu>T;HT8#SdVCo)?PDg z5UmTe%ob+)F?Nu4TCKAlDk7PGi5njDtvIPzo2!Z)FKF&8$=wh2n_@iCi!Z-$W7k`M za=9L|)8pzK5&ji2+fKBI*B(mt^#oQn=}Z-(NQLNjx??-3g+79d?&JW`lpQ# zQc2LVg19&!j1Y0i`o1+9=@VC7*zE*{DXtx1Qm4~U*)HR}ZpIjc+}Ni*BJCrQfjG}U zz|?!B&5DvqE$=N=TJqV8#v6jlth=uh9^%XPAMUmfB5CMY*1Q*N6t(uOtusSNBaRrC zawJfQ2Xd&u9>MLUG}EDN#8ca?z{-L>C2kIk4jCMGIvj#O3}uGvC< z>h;lMz+-0XE?Gm}VI1p9^!-tjl^|Iwz*XJF6gHjqL0hO=X>JsHaw5jZ{u4F^4=fVd z$2x4fyY8lK4&w%R^Tj57jPb*fBA;k(9;4w)HSJRQS%?F6TNgvMYC6hU{cN-F7M`qritI7D^*>x zvr07*j#=o4_|R_2MJ~g20Lf#>4-!uRJk3?0lRzRR(OZ+qRCj8+CAYgdp4K^7a$+#R zcyB2Uk1XPg+5VL5=YGl?_$8C`5>#gP07*W~)X+@BzM3&fA0UNoD;%woSzX=RvWSby z(nJRZXz&yT@hQ4hhdla6g92RR)rN9U3ea0=(LJ^BDLiWGPDo+j`M``7Z#5v)PSdGg zjy}>z@4J4q8tCK}>j&w7)da=~Rol;c_jmm z?pM)NBw>zA5x@bGYQivR-ruSTi+z$CeQGHe?E<8G$MUG4Rko2492lEBgFHuhkIZ9= z9}KDFGi7VtP~v$D+21v!MCY<79ar@UAgcHSkUO|=sR#c636@_}ngo%gy~k?>3w+Iz zJ5#&aV>}-K@17rX1CI>jjZU=vNhMwA*gE8{M~a*<^@1qoin<8iV>Cke0bsQs*g5ms znG}6xPUF9M0AyqHRe=l?aO2~~p?f88LAe8x+)O?1z4+9ZS=7b7)K13{&G{qcAOyyL zXm2O1D5(C)+u?2r;k`V^*}g#c;CO*mQY3~kAQ^>L{G07pAKF2&2d9i5zoOlcs=V}rjY>tw3G zqq+}0-RunUaJcIAU`aiSDg6*r`z2_@7?6Zq&}lr}>an7iX9}a4CjffSbwhOfIrSB1 z6UlFCTM4)Tl0*ZG?~qC>AF+T5QJnDto^;NItRp!w_JnfZXOIt2c46?T{Xdx8(pkp-ku@^h82XWwk z3TjlR{*Sc6&RqSK`S*W1wQ5RtQ>fO2qJ#wi(|{G~T-~k|p(9s8t0@~IuA<1NZi&Lh z8p6B+62G#i-HZ#LmB6ar}Unb^(Y7WOc7niIkmBLkPh?MN?1Mz5)y6W_f?Myx3sNhIpbLo zgDU{aM;C}DtUT^>?uoyhKhs9JTkGnQNF_j%vY&%kii5G@D$s-;PD*N{~Hx;qj*B7SxnG zISWp_FB&=Hl~e5_kH&#LHFSA>ziy+wa=(c9AB8*0IMrcwwH9rPC99j%VKiMZctHUU zN;g5D1dsE=iOmYo;fJn()It-+siqmBnm^7$2kR~wekupHwwKY}xBmcI(j$qE^TM*g zeM&^JpWKsley5dVy&h~4R`=13uL$)mTF9JlMA9Jhjfxz4)6<9{qP`?=ZX4 zTbS;nJ>V(alxM(3%*WN`SSz4irQcg=nB+L!e_s`Eh|OqWzr=*M(N*G{tZ$<`Xjnsg zV=o7`oIVe5kQH(2SA=_8&b3^vSg)e3VCj2PJPy0D-$Vfa0ExV>pW2cs7yi<=jEBOM zoNsA42J13)+0(MNGUJhvN%e(c@-=Go34gz)S)AmAEBlOBjgjyTpd9F;%-WcUnFi>;t4>e`ZqYQ^1cc72ARStDb=8a6;Dh{_&A z^hIp*URCp`W3-jvX-C&dj{3=CnNMddXjpxg90Pb{5JhM0y6E2bOqfTc3HvD#OpLyn zF08-;+qhf$Er3zGPQH`A<%#HTFyIu+{jlxj^G=+ z-=x{j_m(Qkb*}+lFC|*aVB5WgiuMP#n;{zqD~;e)fdxqi)yVK9Ra*yBivi9_+xC=v zqpQm`?3$o)94gsWJ&#o#EqTK4`qDSyikEDMLsnLA+S}Xqa$v6Z^OJ+jdQ3+g4(gPH zLJd$}d#JR@t#p5DG@Y!r(a9$dNtH?A%ZVM`)cF+$HPJzJl_>NSX_ATtx*CL08^Sn9 zXkgI*QHs@7aEy+Rib_t6Mj@eG{xM zm~)2m7x%1-^I=!_S8L7ar(W>?05Ct5SD#51&HZC$H9_8s9HaiuR44dzSn!%~`IUYo zUp%_x-7SvO!+jqFcDA|oi4hC?$ZvgYSDV9^;aHom6h?a;oW2A4%{~QV`f+7j<||qB zWv>~m?IdnH@Wk2kC^PZRWA%uEV_rKF5wbS{NM3}LJ`~Zd>!uy%bl-=E6&{jz62tE= zc_RdOf;iV8Nm+qDGSGri7T24@BbRrp#*{`X+I>;&Ic%QPn3SWg^5l?v$MY2Oy(8o9 zR76)23KOx|?deXk0(ZQ+?WE5lNEKz?UL+~MBZNgBlQF;mcamzTw2*=b;CNKWrCq6t zJ2uLFVo6lSkq&Y@3<2b$n>oNh3C}E}la5`aG2h0%)#Xdq1bX~3Ih@AIW(-xUDhmG7zfE`%Kb!? zQ&6LPP&cS0sm(}NK^>Y$t?Ti@6U{5`M1w5HxDTI(Ir0OID@uTLmtot@r^0`$EcXsP zL|EJh%d~ZDa`b6TmuYD#BJ(*Nvx(bHbA0ibo^cwYHwIXu1Sa_Ic4lW`%|j}lDr!ig zi*b{h7?Qa;33;-%2Ei@UzS8=|VBUqM$i4pn@Tn_#X=Bn~SgaS&L^&6q{u3wRT^CAi zG5-K$_9w)xj+d>!(l#Btgqyzij~^=0j@6zzV7bJ*7N`BV_(_Vbm_tSnMnnBrl~Gpdp61msi)L)uGQ`>CY4WxIwcL{VFoR)YaSX{11LBNA|%!If2V z2Jy(2XggNc(LtqYwy~R@#blm&zpFdn>tx1cmPucDsNNh07Em!wx?e{PWh}Q-80Ip7 z9K&v~gd^`2QpJ?yIs6mGMsBbG>yZsZUZIP?7D z$ydajV0)=%#dJ?B7X6w}6G^ypzY3LR;(a18#Akpco*mpVPnexMG&_ZNhO4JS)z)NL zV`Y*v$&rI8$Ac*#`{~e%&btMFXCrAhw$8Q}5+g0l5hz&ZV5AV}Frm^>m6LQc4g{VR zqo9j5iII)&=7#AP>{4k(0~A$`qOu^7c^vx)BoXTb5Pa#MWG)Fat8pnTi|ja&eahep zTTrV)QVzy~R=p+A&hAEWoOk1vap9UANKGxO%>@@rF?F2T2DZPo)Kk%dDwQZp#{0ZUBK#?W1- zvy*CZYM1S+EEdl){n(42G8aEQ3X*g&&}_oO(-&QYjTm-Tj5nnxrpGx`oVGaTqdQe( zvyCpzxm?X}8-K$DJNv@!{?%&TMiIPlG2K>%JxtwG+Bt}LXi#k%tJE+_B>k)0PRqN! zAzwjz)-_NSRgMYf$Gwdew0#xEu`@$zvdp6@0-z-4BnDtTEOO(-@W`p46ru~VIa2}^ z$M}S*cE?>=22}TuK?E>i%~?(gusjGL5k^mQ!lyfRvMVfdPZEEm02qP(urGI;0rpQm z8@#bp{?OPC*23Tuy8Wbl_#No`Mabom*_na`m%wCy?L=B1-a|woxQn z9G=69;;v2yIwmaw>Q6;1%zvjVBCoFqWOM4>Us$P@#dLlkJMzqDm(&(D{Z-<9tjv8> zCPw0h=Msvt=;XHPb}GID?H?~MiS<;z!pTuWBJQZ}{(dLUm6>ablMVlvwfh3+Bvq-|xr~;L20zn4{mjg>>!{RY7 z426!hX0^GjiS)@zeygOfahgN?2tOLv8Xnd{*xx&c&-wDKk@Ux7>iQua@=ASpSh)Oa z8>K?_%Vl)&NY|Z;q@`SYD!l0(zbZdeRhg*~yxy}_j^3~3Nb(iY%DMX&Resmalv}uoJBqEaDFDh8^(mUngDwk^D_Dwre zXWB<9_wJl}#}V#XfDLCK_L6UH}5Dk z*VeC1%0nl5o!?_Ff4f}h_Cq$Bo+*+0rz7}CuA8FCsv-1T_g0Tgr8kj5NjT=cn&4XB zCe{EI!i;QD+>-JxSZt40Ncj;`E$WWkOWEG?{A_0vU2Dz{2`PuBBtBAvQ7+rg)6Iz{i;UH5`x$Qay}W> z9$6mhL(+DU+FLms(1#z{Z-LM3(DECpmahw`)V{a1vExfNX86M-Za(P*`Huepg;rfw z`a$~sout9sY@j2ijh z^q%YkS!=p}sUHZg&_ySKmK-6^cNp3{XY&mw(;wl;<{s&Z_W*@db-UEwGo$L!4^Alu zA23!#2lkQu=C<~gtt85hhbZbDPl`AUzrsaiEvL|ay`x@RV=ovko;gFlJ>W9_)mr^Y zHr!ol5QpO=A)r67K~HpUgV|4Hw!X;;cNVWb(M7n;4W5DpXC>MZd$qtQO+d{^G`(oe z+7MddHPx$byu7PHYVuc^YKj*qd9GI&)URy1YuT=f2^9)KTmpn>IiY5tDO?7;rn&`r zMNpLsz*j|byNZGrf*fr^0=XRs&`3lEkhiF6)Eeroc(r=UH5QCVq7|sbG!6;?;3bVu zuh2&R(i+%%M{Bt(%6py1@fynAX{~p4I~P2pgs-)C$M=PFIQ)XC38yJnLBH0w=|;oe zD&hJh?s0FaN8Yy|Gz0s!O!P+hnLkwgs^HOG;<7KegYpzC<`+W$0Fh1329>HDS7V_C@uN@y#PY5r ztnk&)C_-b=bF z+kV2IJE39ZHO6o>Wbt9;gvL3X(AvmbFMjN8~`4yRt>NA{{0f}r5KVilnzL7usU%_TqKd<$+Kp&>+;c{fMB+qYkjg;>c@ay?Q$(E= z!X)jX#7~@(zJ_vm?*Zt(e@RL3H6Xgv@GBx*7c_ZOt(+Z^wm)foVz6&Pm$-&sJBdDZ zytLuzFRrjRK^FOjPyYZ2llxVn)1xh8@8$hgwi&l8gQlp7eT#A3cf0=pia(L91GSq# zE#i4y9npBU;NBs7+jMatb2!Pz9B`^1rMX5nJZJvAKl5a*9U#m}%uQ zXquu`*+1w774y$B^<_J^58pyK4YA@RH%UpVkq^IP=7w zbz{R%bo^2A5VLcgpy>%h+k#a$0DWZ_lH5+C)Fpb{ z`fMCUVy=tq#L0Ik;u_}Qk5_8gq%#50WpxOsIbmE0G5XQ7k8N2{_f$Kq#Cg-TPyYZ1li->BDup|U+jvzL+Gt_sv5$1PY19=2 zjHc_BIZ_Xz2Jpe*ioNtLpGXiSOS+@Pl25Iel6|JDO+B-Fr4z{Zhu?14`PIuhRKhG; zcc`=n1ckF+71nA1YAyCyT9NRcAQO*`GHqVp*;-@k7tugZAndA7m;{55I&0}yZvLaE zU0o`JDBVc+xptB;M9+{c8Ha+J$~uQcK?#yTE@gj7rGH77o@A4pc>%x-3QHp-LM_Xs z=)DpBMa10zwZ_Qwmt}UvEw7@uf6Z1K$J9BI>J=*@vIU@9dpqsa9lTc0alSAN3G5C;;9*&C;h!L^XGs~!Y^PppnLOlch_n2HPCZ=|!QxHYb|)mo-H?d-JE*tcbl z&Itt4p>E7e5-G}WaN;w(pztR+%LNs7kx*W=8?C2mAdK&zMajVJBy>p0Jg^;GU_^iW+8^}OIRY20gN|iAOjf+-SNIVjt>tU(6n8kO)aCjv)+XSk%^+; zW{OCif;jBSDy*Tmk=Ga_97hV&9Vhfrb*S6h#%|Uf(8gWGEF_czxIqjEK-_RRky$4~ z=*Cd(VD4@tl4V?vxQQq-$VN$2MU9Bwn6B>0nFjApi(CuQ*oh}3Bm07|omy3hxIX5;wd{${oHn$|4(OYSqN z=&Ruv_{gL1rlVtd39gubikmO~(LQxvbb}Y=LH!Z<)s=FCHD2_PJv9Aa#L}|xyU2M_ zx@WQUuAe-TF^^lY`~^MR8bPtphLZ98Trchq=4erkiA+4(f_|7%JH+%wEOSXN529#F zd!)z(GwiE?ZdbxWec&2+%`<3FZqIZ)LNNG`n4>p>ppP(1HTa2*!0`V78j`vnvdUWagPwlN|BZ*Dd9?l2gPr9JJTXj9??qNN|H?e_J z@)c`b!X!y!ZcMB_;EJL;wp;;v(UXn%+z(pehxScqbSIGAKR?}P^`=?X@=|xV-8iEZ zQ=ZnryCj3on~`g@3y@(@ZjjikTg*P`Bjdb1{Ay8kO?!E3m?iSvVeiMtnpLG-@5dQD z_eaWtJgzC*wxtZT5NJ}h7RIbipdgb$Cc4$!SBi$Y17)~&tKvcY56Y$5R4Q_eGN&Mq zBf$JBBct$>9p@wQsg9J`VOJhz(x5c!S}W+n+Mmf^*fGmNGRmK+C!qN94H_TY^R0Oa z;@$jD&c1MUX4*leZ3U5L#8*i6&QC0}en|Lzp${KjeH>{*lb#u_YIf6>vki+=uCF?% z4Kzo;(jLQu^B(H0ev*ES7XJWeZ(ARhv}L-Cj$?PxkZ|rXGoK8oCzU$v7O~k#t~fHW z&zhco@melRD5g>!M3G3PSP_uO!vqoD2sj}3Rt%-l%-4k5cWo8&PpdxF36>^qB2>c< z(j3RMsUF%<)Z{{X_9AF*8=zHxq`BH^(MX)4!((WW9Y z4=`|jV}Y+Mb&JAKY?F~)0)f3;->)k0k7c0ZjAKBns{{aRrh?QVRXZ;)5nL78_9_&v zgF`e0uI8me@ip4!E1=hvmlnD!nXZ}>l~JTn6i~OTfLDr?GeX~>?^ji-mZTsGG(7{I zbPDkTmC?J@DA>>vhK7Yp$iy7zXtY-K4X7^VSt~}RF+C)Wsa#D$J0gOqo|cz(;^WMN z?@((i_2#zSn@W&y!?^gyf1PWNn(hAp(p$|C{{Rj@3d?VhYW@GnSCX zfXL&0i34>55rrUe0;XLa>yb6(yey%_u*)LkWB{QE;p%P`X4`48#xW8sYv7u+oxRv88Bbi7o6k&p_d%PK#kp>9i z+@&!feo>~Wu9LHoTd!vfYYOurKp7{QJ}1oaqs>;`6YBEEcX6lgTy_H8E{E2fNh}?t zjENYNd6|+$8{3i+@iLX(`*@sMcqNc<&aK5J;dO7)RuJM2;_MB{Ku6EuGPXTqH_h1lbp~&#dox> z_LXsJjdCw`xkCnk%TipetRte5zI}Ckm+~jhX+O4W=vAt4ub*9A?KiE-AMK?6*`{J2 z!*|(k^w(RJr_-IW-PtA`*S|U3kCCku+J#bGF3bd5Eua41kN*HRZ|7PwSJ{Cg{{W-R zu*u;f7eajZSs8s`oOl|_&xpv_!0bP&Z#01NR8}d@Y8AKEtt#BapJ||B!_PlDo9cU4 zZEJ+ol-M^e)6#opKcg^Q4E}7QHXa9^Fqo09URY*H;AoR|<7H(hdGb9HaBxLpoOd&H zwmhJ^f@2g4il|Pke z-GH}21{H8i634urSw5kFJOHfa)+)LT%O=~a0Wd3!zgBh{(DDD3Mu@%v#imwjL zN#bN-}%@09S=xcl#l4Aj8|lFZ4!0QHFf z0R6_};Hh1C9#6u-%HXX94aNvHN9@c%Tat>)38IdQCM*XkesZ>5pX>eA=c+AVC$xfK_A*edv%#eH)1HJ@6r z+#%FjHxc4y$Kg%bv-Cf*W2ChB!7%Lc!%vP~tiRheWNiJ{C>pFt8b+vIXGN`3M*X#JtuNcSa8;lJ6j-N@b`0&|Xdsz;)I8*<9m z{u-a&89zFfLdH0Q1&t(QnK%V z0cxA;8m-$FG2QNu0}M@%QQ`3huZt&^kbR?w_@IS3VO(KVl5OSGIcW&FRpRTo5?#^F zqbPSAfyZ#FmIDgg*amj{M;Bw;i3el3zrWqNjPH7}{&P%;7ac??J-w$Yi}i#b=o+Sf z`(AJSrl%RU;Sos+T!HpR0Y1_|9@9`Rm-eblojUq47AANkRZ!d6B?tpJ13linRUGhB zn2*x*k7I8X+-aqV0Nb${@hb=)GgeO0?Pa_LMDrv?vPSX7K*mp~WMrNKsM`UFbjAMw z?^hn-Z&T?dCj{{z5DDjl%zBS_pv5Dbve3_Idx2A^+%OEJGLeu$$RP3{_lh&Js>f$7 z&7s_Y1%Tv~@2m`hht#ucXbPd;9H5X$B>ZU#lX2vw}VHsHWJ=65U}cUF2j?iZbj20^6(1@E-ChJdv%yQu?1k0tjyTTU(^AfpoiVpS?%7 zSsiWKLoxon=L&aZFZPQWND7(V;t&nq6(iY;X;R+ZW;vKg7<2kTKyQv}s_27QwAJn| zp8gj4`p6zQE)mh-woC|jRN)vF%gDfgSVnjRtKV5rqbxwDV2rW5u2&$ z6m(j8NDOkulSJGZBv9zXz^eiXr10ITDi6@EgG}K)^ht9hRdd#kX!tK@ZvO7s_c!i*o45xMDkxDu? zGq~u$GByY}BLs0$M;do)QoP10h1a%&v%1r@4daQ_S={(|$8|n|s}Ox7lIL2{*ZtIA8BJB3|qy1mTfhP;Ri=C4Vx#B6WqhGMk`R^h4x?c-#-Floq0HhXd-R%0G0~Af~p97b<*WKL#JpolQ1hJ|?&G z5)Xhsg%zq@u}@c~mvnq_z2*nQg(GpAaM*;vkol4EG?eGcj}_|*W1b+^a(P9Ts-$pL zm-tx!0Ls;*t(Y33`psnA$x==Z7l8#8dW*ca>A(1xsB{LU7p^(-w};3e2BwKS&YG4jIRo;MwlMucQ45 z^n$vDg}uokyI6yt(U5vFuk6UK2bK+B$>djs!*TRTKCEE;4PE~LM$XcaZ>ZQv*?1%p zC_kAX-EH43jS?8#^=P8 z3_Oe}-fEEPmqYsgfxbA)ZbKi~^k3Lemh-?h^PvV+`5Cz1DNH&@D7ptU(U444ipI+ zrOP$qib;z0?HDV@h7|${HOlA|9omwvHeCr^4_>bUP@>mJuQhpSMQRCE1lkRFn&7VD zyj7Pg#8-f?8kHz$v@u14Mj8{8Am)M^hd{4h%2Bzl@7FV2D^;~2cYcMM15m-L9kx=8 z#Tcf6LZOu?$justh)Om(rK+1o-jY0gPske9#&-&g@fBUd~{dyky6@NAs&MPEH_s*C}@(M^tW#NEnx-9%ev@ttf7aghDX5De$WiXGWes9dYXdu zTiRc&X*xWl{R<|mN=wzP%=v@lA|5` zM=t(U^oEXGVTN|#stG5+0g_ECH8rG_pNuX>kD94=B5gi99?-OM!pIELuvYC@jJrt3 zJIutK?`NLq001cdtJitccQW0YPjHck1Tw@VJ(`f*eH9=8c?=4ycGF~Urc2XhWD+@U zBD#gUL|?Fr_M#&hc3u6_MstIMSIMWxoV{ql1%IuqrW-d&jlkOFKuJ0TGIG$%G z1W|XI?Ee72y6!r=2Y}iNNh;AoN>dfNZbLO)bdwuRBH990Fayq1kVrpb5JwTe#iYj_ zz!xKaot{w;wZz9Bw<)xHLvAul@8k15E+e}mhxzfSt=CI~dv+}{;z9Y$(Z)TS%um2l zEi+G8)FP-=9Dv{ls8Ztfj&h#u!gJlgPP#_bmtE$Gp*`xwPsK?Fu{Tx=-}F#_-#_-N zV@EeXSK(OSs>}oGK%e%H{i@mN?k|n^^8Tw!38AjpT75ghOnWBdkiI~DW@aPgYgApF zR%Yq4P%U;?9)Hp?d?lCpiqp5qIMY*)BR}5iy+HZ4NUslR8^p&b!vs}%sZQefM!|Sn z^HmGI`9Lyo22aYYKx!SR#;vH6-YtfGvheviT*A0PkrB{xVVUTtkz!Q&KOXeFhtyD^8B$5h|=? zu1f-^EC~fjBiL$LdC)zp;H1}30ryhoQm4p=X2A6k>!z-)$^QV$Y!CaU{@Ycib!1WA ze1e?(sT_xvxYF7oBiZejir5~=5uOB>k;(SR%|&fB*W_pa0EZ{`4JVgv2YtQ39zBZ@ z{5#YgqK|qBSa-(#*gRD82bZ>i1($+XgDFtkC#dhf&XLGMKm81fw0e*0m~C<*a9dEg zAo<}qdqq_J6Iq2(Rr{<=fJj<>kdG>> zG%KPmbpkn;L+%BC3Yoq-R7!zwnpYzuWmo2*1$+Wg%UKBlBDj*F>pXrlbF*7 z0A=&Su(AM{3Mo7v;XIP8u8AYflq7M+`1eTQ)WsMsSnva%s8t8Cx6;RGEvIH#qLxtR zHe?KU*@Gz6Nb*^PC z$Fm$1d3gHGW}cQ)I^uqfs(fa1_*RY6WCURFHJ$du5^GmY`H{e%+keDVMK8s_e zGx(u8PW~J!RXl+{r6J_9L=UOKi$3=i8!?V~sV)AA+u4|j#{6Uh;C|`$5D&7I$TS76 z+aGr&rE^I&{f)D`P`6g`&w+qgdQ1AOFw5*BlInX;3{0ymsT(&S%ojeak(z1QT^z{Q z)BSOynmNjdUv$J{F~-U0<9Kg^*mYs)9wC6iAPyd8njw6D9~shL^+#;CLY55oM)D-N zd1NFzucaJeLPr`tcb9Mvw4@d%CaT@F=sEOgzOfFWG|{V~q%r`()to7fLi$C*lFC#M zv&$8rLIcXA8#Sz~ZW+`Pzhse;xfnRlEQ5oN?iF~m=3~6ugw`%dfSr{QwHL8m+AxjP z)<~HVfy$hz0O!CSeibF@26=SradCAM2+0T;Juq1C10(G3AF^8j_|mPM>Y=Biw05_1 zM*}k#KvA-+a#cx9=*B%9uN-;tts`?H6x`hfFb3%v7#z4C3MBnpKPafT3+JXC#0OPZXFZ^UAhRc(EI!#+Gd+)6b{J3Dj)OrMtIgB@#z0O5|iLo47IO z2L!2K(ya-sU$*i_fEgbXJt$ z{{Wz@WLV@v10h)00CK~IGs349Hdz(gvEo6grozN9nBPTL_cpk@)4RVRF#iC^KZQ*g zQf;BoNP9QO*;ilQQU3rfDH7G#X)!bGqKy?SIpIgd*`rx#muUh)RBu>~l(zc3*HT-n z&asfM=__Z^M9U#k*k)V?BzJPA-3l1Ah_h#H^_$@f%OjKN2%`YYBRR=b`>KnL=D^}X zH8krnuG{@W_SApU652VtumfC2f%?QDY<_PZM!D7YB)XGOGI19h_Nq@2*`gTuc{Ggd zd`>8jbu!n`y_k&2W4~o#^p&*5;Iq|Z0j{j_SY!sC%0>LNK;#1GF*HEu(pCx!HVl>Z z6_r+4-?WXuWp3tH0I43ZL9ENSsAbvcS@HsiQb2fM#VW%fKBA{Jrn@i(=mAu40B`^f z00G>}J&4O{TrRp>me&(Xu&q0hUPHbV?WPWnET9Aa_;S(XQPlp{LqjD+YF5 z$#KX^oFCXvq`#udd+M#&eShlPND+YCxwTNFfyJU`%K7>^BzN$u3!_bdk4~Fh)8Yg} z9n@3AzndM*A(Rg#Do}o}jdQsI$!YA1k%Suuhz;N9rM;bEp0-V$pt2qjq5jeQD{H8% zMb@n`v{t@wr~d#@P_ahkBhyFmRFi9_!@=h!KY`ETQ67>=-jCJz)T5-_539F?@&5o2 zW&CP0(n|VK{;%Ujlu%uq2Hc^#6z|rg|nNytsyep1VOL3&PHrbLB;7`R!rK?G`Irn=%H&O33JB}6h zz0;hcoHjrGVDqNdJSpp9;K*~|pTd;L9#q65%%mtOldV3>Z|T;zBb-vSZR(H$3H21m zwxp4u;Zoj~c5{D8zK@wIWgoCZ58+eqq#>Kam51kFl=QIpjBxoXQ=)xCwbJ)b9m47A z$ELs9#W77S534?!~6fZGE3#JfC%k&`tR+L^a5ZRGJA zt>=S}ksj)nj_Qo`nQ8eYam^SXVgCRSDkmOQF;%DAl{8T_ahrGoID!El+5t+d9brR; z=#xSQxK_NYMM4XtQmvVrDo1!Eej<|4^d`iO<^lIrNUhYBGqcGDz>HIWMVn}J&0fm! z+j1EXVKMIAI6_a7`VxB5opc#~(Du}0q-~$=Vv$D^_LtT_n8^0jWHVfA&^t@1eKn5b ziTlk;^zCq~aUSZWdYJTvZ}gjXX5X43V3R!k3=|~u^rI`p^I+9*%K;4e@vdR*?6vJ{ zPK`F7>o)4mg5xUK@f?rsE)TZ8VtpY!7q-vqFza@Uiz~L>xKYVxTr&~iWl`F%Ji5!= zaa(t*?%VD$h0ZsQ==F{(?*xxv6)EdQ&>TA6nRjI;2a-R@w{l9SdMEvt;Mn40XJ5Tp z(Z9ld6jCfNllx1xAJIESr|R*xEiTjvNc$u(5z2pb$2k2iJ=MEr%95v;I5qQ%$4Qd& zN4?T6kjJU%&d#XcZ+0?PM8|niyrd5Zv6|Z5S^8OBPfE6##%>WrGN03o1>Qbxo34Dx z@TaF60+5(@RuQ6%bYzmzYH2G#q!giIx)!_*Kyr;M#cPPPW@vl0<>G>s-P#(O;Huhy ziF)y`R~71YD_R>0@iox3%Jr{W;u;#)BVIhHY$~o^3%H=QbTtW5pkJc3BFuCmmDwf> z#h5RdAbjyr-B+gj7(cAkt>ZooG+Fm=X1o|l;dnHs@F-zw6LZ%4O^tHewY~@Dvbpyl zCZhM>NWPSr#p}uXu|MLXq{8#rUr8$ANDUmY)@#&$lfs%5^QZyL^aX z)0F;qV4#2AywZFsp6c%8E4CLN%VNd>rG1VDPolmjfcMrt9a}c8Wz{_S13jqc>Y#&WT`e(9S|jz!+&aHfjhnV$MzJl(A9W*FWBXhq z1NKqpUJ+56mOJuE97;TZ zGCB5csQs$?6VO(|n=Lm?fbeMGj4=Hop`ZAPtTVToSM-*zv)X6cQtdpW^b6&){{UwL zd;II^ozEJHj?__E&8ZhddM{;c2j@JG>@9)tQB$iX^u4r;L({h+i98iS_2ZAmiP}5f z$}5R8jmX`z`l?QT70v0nHuX;uH157q^)1?f`j(|QnI~devqgxPr=muxaeK_<0OJ zI!482xXPw}j~$|EG#FDOG0!p#_hg9&#m!!uMYRsGOl4onD!y^Z`X>Y*16cd$&)Sdl z9YWtcQdE!+CqktA;}xj3j_b8a6^?R@zVJn6XH51?=Jr`KFlx13^j)U1Tj-N2=duyA z57B5{SdUirYS`DhOzc#+w3c8wgeXota9K(944*29>|LK{Z*LwrtVq7)0;Et1%o$H% z*;gW@BGYG1@cm?3SYt8D;z_g1u!jy*kJFQJJw>Xl>Y#bu0P+JBQfvmRGd0Pxh>Peh zrn$C_xGmDBn2r$FL6>2 zDs{O^VA&`kZFu3V zPtXi`v_txD{j*&MM>f~}lX(2A$E+=$ZT4Z8n7@zXB!}iJOTn$9^YmN1AHddW>8fnn z?B5^$$VLAEohYm6tql_@V%D?ny_~>am1)~i6oYvic@98f$l#EEAk-rJPz|X|xO0nS z2iMxR0rpeqj3)p99$YFvu#X#E>Nyy7Fs~l*+3ydF8pp_(xrm)Pph%yR@S3W2`eXeq zX_L<0Ks}PD^A&AR8iT^CzL+{ElJ%`_Ev`ki)CIRoF7i6K8_3`galjBpamR;voEL!M zsM0n|!zQ&h=o4%$W7F+aN{aDDBasUzWRW;JuqP^W{##PUE(p9r^xsQ+Ouk)rYV{PqwIZIte7jd$CMf}s}S%qhawUV}pYz@;QN5ya% z*LZJ!Pk_fW#}z%3p_wBgzo0DK{{Y2L#wvH+TNP?nc3T2_=)Y%~BG7>t#j`Q;{E8_4 z)jV}u)c0i9$A#;+y;u2B`gYQ4JNwgE#<)_Wa^V<(z~BOk0N^|OsNRWoT-rx=&PQ)^ zGfe8IAvd@x1H*fDK*_+%6OI_D$;fM?a6F&xvn6;tg*2Xh?mhHCUn=uZl`AQwA>uDj zDPl5nzz=72c&<6uOH_Yt`bCP{vWIlfcPYeBui9hY%ufW3dAyClcLI1-c~l0j*5+)JrD62)iTF_s{W_Do^e4DOc58ORyIq+8W6;%kfFXV3$D{V8kWJPm;U|MQ2}nsUI4Z?VnM*cAnWZ?dw}f_m_TZ1ND_%V3 z-;`3vc3WTA%%$y;Yj;*lfS*6Zhw z&DR)V?4;M4&t$nL96%iSvETp$j!EH4^y}%0ERuvVG@X!j-clcq7D(g)l(y>yAiA*} z#zhZQ(W1DFscqcx3=RsgIaVHOz#nE03Jr$Htg@xu-t3o1s;CgRW}CV6!k@JxWcp2= z(05c0B%)f;$W({JEIPUDm88uX-c!1Ks2Tv*Kq$Yy-%cJe8Qz_LJAURCeyh2||`&LNIk@Y%)6XWo0>59$nc7fgpkKGC@uXu)HzC_*OQ^ZP>W& zt?y!uSvLt@F29srYWQk61$tJ6;VquBoL~jC5}gM1oNra zK|5OamNr)|H(pp$Mji#1Cmvblf#W_LlGX26S=&-rU2r9C%?0Oq2o>{!cgm(TH_c9xUf+>-AkZ!59pK?I;a0atZE(&fS~m2~kiW$dsDkVXj$!ydXnO6i6;qhGDt&24}IJ(-q1Uv@}?jC%J^ zm3?{!Iz|)9>G~#2IPdegHCx(F$H8?zsiMrmA=Dykt4BO{WMB=#4>;uuFj4mlc++n} zofBQkrYWvuOS^aMu3=|97!O61<2{tgi6e3Lfgm0P)jO;7`yA?t6n7C^MIn~LWr@#g zB7hkca2s`jD(XQ6NGAkhu5O0S-G$4w;c;;T+pMcAZ#l5PBeTmNxAjZ2=%2W)B5(u> z%ggZ_3xj;F8~s&!#8BirQyr*(yDP_5>f@#=_Lp$4B1sZV2moY~-KKB=$u*pIUr1P? zkZH(c)OC48R#6vuUf#`e1o9VVbybYL%Vh9pjX?BHE^fcQAbnQBAXE8Umsk}+!79fx z0qaq@`(mS?N5@5LXrob__SduJ8bWus6S^qRA4G89BY_|&;GA$Ux{1ceGvzw$(?_rP zSWshz8)BjQBiOampINbpmW~_w;zHkOss&I^JIDh#@2d*R=AvC`bcfURtwt5SHllfj z%B~_i-Lfu7?GcX$R$d1j1xB=+ZA4(Ept5;MZinL;jMveyx`Q7WD=Q}r&UTDzfZg{JKMGYx+eQH3F-f?Z zrJ{MXvUa5RQgA-18#NJheG}@cH-!j=ULuffO~_S@{{Xy?`$C-7qx*h62>$?GK6Ge> zl`?D;hG_ZI{>ev$K4zHi#}n%``>~e+qR*Rr2_J=L<)12R9Ql>C(1Z&qxeTFzBbd+5 zr&kauZN?sh0;G8`AdhjV4_3)@^}p7s&Z{U6i{9JBe$EN`*CRWKeXfrf2W+sfMUl0( zr!Bj1Ka`C6(QT}F$c4uu2={Rx z17Bk4)A*3I`hZu<`jlQA#C+5}pN$~eI}Xw}9prHJjw9kJbBYv3v}6`3BN?MaqeL~$0Of^_*8*~4h|8f8|=6RaMwiynla@J4VR=8Uj7*y z*+*uOYPUP6Tb?-o06LeEOTccY8Wrae<|$^U-A#Hi`wTg7Bh+e9u3F_cyei|&)CYN7 zlyyPcCewAFtGOv_cBCOcWXQQ_{h;qh`(wC+TmJx6x#7TMc-PGCl7kyKWQdV8O}q&E zN>t=9@8z8Ns-EiIy#e&5H9H6{2^;LEd=I+naO2gVNqC z0G|=&D_V4Cwc_RlVshhydH6TC=aE?=-xxWm{{TdrarJ3HV~EcN^?!lm@2t7w)GxYW zhL&{tQhL74XLSzL>C1u&rPxv5!DE$&db;8nGs7Z2WtfbU(Z5TV8YHWzS*e>!n+8~c z;~R0|o*_z-LNObxJqF`~KILsTg&o`y!7{A2@}WmkG94IUf%0WM#}Uj|#|^sZ?tPQh zUdCU_SjtS6azEL0Ck7`x7kP3O#s{T`z*z+s7G}+HyOmiPLeMF@Q+ac02*ZU z?&8uqn6K%&>S+G}{nB4z%l`o4=`rikqmTOdKlXC}0QkC~zooR#{@};%AI_CSu4$kB z#fW~wpT>qTPoY-OeoV!kVP|Ppro)37htiekPNr zP0@~4DnDgEm34Z+DCJ~;{;2#Gt}uQ`nplju`bS-cVbNxPTmJyd3sOs;q#se?xwFhO z`NmV9bW2Jk>n%5Rykpq^0L)Pd)<$mV<6qc6iK+K5{{R&<#F6^tZ4|%M=pjd(Q=gey zZ@=puwQs}vIv70JG5xuJ3X-0)H$R-C)U=-KtUc2Y>#^!l{Hi_F`UN({8jou>wtxE7 zydJL4@JBSN$D=)|nB}#wnf%ir{>(}#Lv+V-f9ntIIQa@G`bV9)+(>_EznxFJpFp5F zzKG$}`&^LbU7&DJ>CS(=v_Jj=ZY2J{XdS<0kM^gg>GqmK&SpQf-_DV(#(wsFX07h; zNAVfxo>S=TvganSSo)(MH)`x%NOX9PUhSqI@lk)Wzl}5o6rw4rrWg9F_>JrolFy+H zp10y`1n2amxNq#RT6(qG4H6&f^cWi+Fb4R^lU3A@=S2#z#wU`g<9aK%Nb4_cZ~aD( z1YfHZYyFt%(&Mi^&=`)^IQ@Ia-~!bP6K0ewb?pHZoKo1`iDP&s>`d#MzQG{b+(=W z-bKueIXtb!+tf+I=aPjT)pm3*Vhc>??)oe3;(i$S)(%9$#HJeB3d;+|s(!Zm@wd>m z$w9#ua{zqaGK1pPZ-lI;^rY=a)wW{zXAL{S2=>S%Vf&G_DU8ieA64lbu>Sy64J)&- zw()4$z4AHvVz(zlNH4T=3vtYIKz&3qkAbX-(FKQNX=0~=7V+oSP!sVrs(O##Z(CfU z=ChORU_TIQol&Ha;p((--J~C)`SJ8^@10Ka+jm^K8%En9Zb4NSvs;nkk~1QoQ4Fj-hQ6zjgNouktm<=NOvf|=92E{b2voFN6LNTc zS^dNL(zyCBoc{o;Wc{!|y;j+{J~VnTt`XkT%0|rye(KeeI^Oh1*0$2!K_CM1D{DtW zNp9P<07$35DZB{s&wd2sPuAKU&^WIFQqHv76+P93w2&Cj)u#H)f>tVwu%w3@%B(^3 zvJNqk#;F}AZSH`KTxqV3@U-f&%M^s7fXqnB6~JNkhckhbUsmWHKu?Y&aTnj@x}Ke( zk&u*ht)$M{-h+2&itfvG2@Knzza`o#lY_^DkUPk!jn>L)EhwDnaT$QXe%Uv%o>|=l zk0X&$or=D`)NZY9q_JB;3tQi$tuS(7-YR=FMIpOIEgJ&Eb9@t?0P4nC%=VwI9BCRN zqqge{HWt1@RUKia>dZ#0`)T zETv~o1O4DHgXH3+9S7->-P_*U$o-A&wb#~Qo;4w3d4~9PQcs}AeI$&X)>bQ=;M6}} z(C2t=Zs2kwun@e-jF}^pvc5bFNWHI~D%0xUa-#a}H@Hh}8>sQ@WMyo7B}(o3!E6}s zmH-2W=$QEuxzyufcV@Z*yoIjjid%T3xR}i7yZxFm9*~TYy}Hf3_&5Nu0C>x7dN9An z8fZOx&+^@?JV!=~`3%0&WJqn?ryUDPx>XFl3u;7?FKvMEG01*9;=0`p>LVZNmZ$#8 zPyYZL_*K2y(Vd+w{{TNZ*;Bx8qNtmro5c)XRBS2xD}lgyu{DD_xdLqB{dfM^ucmz? z?X|9-X*Jd4%LE}v$>z*K!l>rXdz5iJPa&H5o6vsHTUpvhmzNPj+sxrhg4?Xw8CA&# z$nmeOdn+unW61djBv;RsT>-8Xt^TsQ1g~kck;L1wHSkp@kYhZdN%97?oySI6igF14 zsWI~vnmS79(@nTt3OH^R{;zu!vN#R6iQBstcE%J1V0~Vy-A{HH2SyBy#aT{NgUEt7 z5POK>Nc%&ZCM;3YdzCXr9{W`#hQ?|@;!7xG9wOs({{Uo)NjFCKW9u8bT9Y>KX`8j; zP!&==45`7v$3As)EXIvXgI_;Aer_pwkp}J+1s28Xr~(hTLQC!gORWE0}H z#`1&g<5~vN7RWUR>yu@j6I$W^rJLQlYoO^}Um{u93xAjdxW{QLA!?dt_0G(w9PVxZ z0P^cr?Pkl}s39%8Z`GDH_wTaSZ%D`1wrl?Y!flWK6IRRDwGg@9V7{^k&bn`DI)tz} zfr9LRRUQx{bx7Itb9UThia5NxZR7kYk8w2YIy#ss#^(2SU9q1(Lq7_eCbt>oBA?nH z&YZ1)JcvHp;D6=v$@qUoT`g5~T^rsWa>S?0eZQ3>)Os@1zt!AZpVnDN$&bpdol^NC zMv>Se07)d^06CI-fbXhIb%4He+goWy=E(h$vW1=0aNkIZ<8=MdSThVB`sgroGN%4e zKD&PEz6(lwkE6@rqcXx<^7@J3rXNRA5;fMjEw zk-~MQb%WHi65MDKK(ptuaVoyy9KN)>puz~_o+r2|JScq^Z0lOuL2n@i)lTtWvR#<7 zk>7&MPa4w7bK-@~lC8i2L>;f?^i-yUvT)iRbU=p3OK9T1Y|LIo?GRvtyDuH(Gap4y z-CgDbfT`ApwI*0&k>l*$lom{e-E4YGyf`oiI3t+jRmQ*3C6&vWCbm|PkV}Hf#{&Qs zQl*ACVDPD~ooJD&qNr3*00V#kasYW(DKleqVT~L5*8c#NpkC6^T%xwVS&S3*1c|pF zv%vuG;Nt}F2Rw=34;rpIB-;j-bT-zv_EAQpN^YcPSsahD%Ogn4!iS^6 zMe{vI)LQ;hGu=pf4$?*4<@6FN%{r9bhE`yr+^F7`J>GA>kWMlPPzcQ>eX8GMh?!To zoM4XQz~pDZ@uXAfX!0bxr6gSLy0asId4T9uuzSXEd+Nc1aNxEur))0{aJ4Y#{_aG+ z)vP0vx3z{)w(jg-49UFEvyr^QC&HcVvhf6(X4o{kn9tkC7?u#dv56WmuH}`Wa7!yZ zdx3+g^pSx)abD3OSQIQOBT#dJ!-?Wa@g(si@h6>1EH>-2idd2r9i)awVZ(XI0JC}E z0qm%eyP6)Kr}sgcR=Lh_6=)riQV8H`NpH1k%ZK23AM@Q+29tMjHS-!$(KmN7 z+HeWlhr=#OOq#Uzd!-nxQdgc9amBK@&j3z1kUlk9>AInfhIpu}G94i%k9z@;hc0~i zgIv~rDX<^1o?eMBSn{}>2jj=4X?S;H9aEPC3}r7 z=>1=ITr)u+z)i_87@-Alu3Lh(4=U7ixl@tA)fv|pV5b(bO$E50u((TrclUcC5N7QN z!7i)!)qO48;CG8>XEiZ#Zw$gklYJGpkmVaAIaA)G^6$qwSE>WyM<^8GVZ?VMgY~c3 zIT5l#rn=5`SFqM0y1A0nCGx>1XDm!mv!XLbBvI(e=>RgrdmICB6^^l_cpzM5%QexBoa;=XY7GqKA8+{4`!#PJVOO3V0H(*1(!56ENVk;LisSe;>%z(=Z@ zXd7Zt()E24Wq%s6{{V9`Br|^u`90o%HKqD`=s|UKwYZLBYjUHqk<@3kw=Nz?TyV}A zM3Eo5;PB&`$Xyj~+}Z0ZoUGDZfPF|654L&xy^P?aDZ#+Vt=wmv8P?_8Y`WmSqkqo zHc%rlHKdrQgD zf8pDNw~yFFWczBM+MOTkQ^9I|DAL|(Nai=?-i9psucX4iN@B)Y0|D&Nvzp@fP+O~E zIvTW-M>=5y+e>V_$3B}hsvYIeA56%`Ku9=jaKHkgU0?d1L8MA99(TF8LnuOWA`2n= zVlrD2mGoGK-6QVjjZeC-Z78|ZG}&{V>r_+v@s7>MtU540bzwY**ihKN92cucrERe5>Yn(nd<{O`-n)ac%v#718?# zW@nS=mz-TFw^+N3+Dtpf>GwLCi*2;|eE5Yv@C{8mc8nWY{{T_GS*rI`yx;84KS4jT zE9{f*5iy44B@Ih$B*@JoFG=(X^7E&GRvyvE;(Nl`2Im(@whvUeiDAbM8`q3M?BH67Hlj5t{Etml=jL`p4eQNw=v_ zM38@@#p?NzX>69h$W_ZH2l0dUM(>paTB@Opo)7`SmxVzYu8`SBX6flm79C z%A${P+UpR1yJ(l*So20-@-tTVTm1*Nw7WRTjf0P23V(%-QJn$-J)F>OBF&4~%4yFyGwc#7^Tpi!KH4#+Af65ECZ9zB5Ao8j2QqGYDrR2Iyo{V!pYi`Gwa4^yOr9}(>01_o1dZ8j5HV=+IG|RVJ zI@^Nc0#@b|+94dq>B+~Kz;*Kn#+AvBP_%q^FQzvUXOQ7Zqk4vQcj=63wlG|b2~VLY z9F|qa_YUB|hf(>g2XU#?mZTCZ6OERFySlb?hG(`A%feO3T=%9<2cGXQRy^ySYdX8z z>zZt~lP9O0=wtvEhF(HIIkI<1ne>pHdO=8~c7IxE3c4M~Q^5ZKp5xv;D^_d{+NIPf zAmH4XertD+QRX~FVa*YR{v%TjcA-n+Rh^Z_pK&&msHbCl0o}pQBu-(FoNo=>G9SK# z5yTpKnLEcS`ajo)skB=`ZXPT_szM33n~-if>}4QlaGogK;$m2EF0Jz8X7yR)noA4# z+TQ*mRpW8GSdQ`Cy^bUf4hVF}Cc3N~=FHmevnPU$g#JYfdXzkLm`7U4Fest#*Rfs( zx}vFN7!(De6I}^j#ZKyN04)qHc}*IIte`J74Mhkgb8>=Lk(;y<)Os(K4qI#to&(Q- z_|KRTF;x|4D>QA^F z)PgvudXa(}3Nb|pEpmC46`jab10PcB<+_F7!GbvjX_w z_Y(cY&oD8TIO2$ZK0w3hzLlfJZ5l-a2xC9X^w=EzZJ)C==IfZZ7K(ClzNrtvAwx2|@9-6@t%HVO`FDYZ6 z46I4d^M{RUbmtOCOG-Bjqy-^IB>)dl7^}0W?dI&)QT}qPkJ)S=?NuV_&aFjbKcOF(b4dCW(k6M^Q@Pw9SwvXk`_Wqh#x-x#lXyuWi4*|YMe1s`(WM+}-9?$#jJi3V4T4@0w zl$ss4wI>4RHa=1*`5MffX?D48HBCAclss`qH0^_&G2Mv??%+F2duwjNpx9fFL1Ku0 zj^g~Q5%jguYbEv7J^=O!wN=!0n5+V4l+=N-h>4s(p} zD@M7`i$smazKp}tHJYdkCjbBdD{upVA7-$>P90sQy45Ez7hN{uWV?A{2e*c0Tr#2& z_uK~#I4(|jjynSxrPBD1u>SzlYD9)GD`lm2gIc>o`70jScm7#a`--2PCAQY@oBpBM zAJTpC{3?%`@u&I5?=y4_>F2>%4D$Axs?OL< z01T@s@g9zSgb`Xx=@ZgopG>vC6LGYiB*658qm@VH$f&~u%K?XdXMHN!Tq`LIr-&nw z2bFyzrIvfUTOR{tl%q;~>iyP+;?_%>dy4@jguBY)coWEs?vc7wjtr-mJSxfhKXz{R zO$IV=3^AD`?17CRbvLnqTf2BM1CZ{j3ioI&tYenq<*=LVpaGJ^=L!e;0C6?6I+Koi2-Q9!2!*Wgnvbf%!_+ zzzOkyrv}&1T4c4Ls71b)7ykfuE@U3Km*TLGohXw&-)tb`KNTi|&9OxCxDqS=8Y>Qa%O07nSn;r0%3T773aC}eMl z+uLR4yfje@pZn@5z*r*thxcfk-n53@8d+`QiY4M$k^mm>{d=iatFopvXL$it1hX&z z;0%+)haurqH%vEH^XZ+q?HoBqwK0v?X};72LvY6S7?vBMLlerC!_0>vz%`p)T3$(- z(ZcUdTXBE1;yYY6lc9sXD=^)-x+{VdMtG!M>%Io@!?K(O3-;SpXyLbt5j1w8(Iqzd z?Un^oj?V*ldO-sf^nzy|AnhMeW`+-~GJR;G_S>v5B;6bM?#6S=hldPPl$x?H0OQ|7 z%gT|`2I(4GbO-LU**24DHpgm{c5WJ3WruwBCV%VuJPZj5;z(`n(%zot?)MsU*7lVx zt>?U#A_#zK7!2-$K;_@W@*I4)Q+3|9ilvFqmwBr1ZZ<(R1-F)BRc+?&cNdDf@W_xJ z2LuD0Z~%__N;bAxE^V5;`7PEI%UJ6%Z*K&*5?W5S+l0d@Ktzp?L^NRbkt-Z-yQl*dBvj2=A2GtH?-`T6w%enN`q zp>s5~o8LbqV(RXtT27^P6Wm9L(Sjod455WwVu1~^gHQl8OIJ|dD> zYHHwk(&&?jHA2#A*QK1u04Ie~?E;gAQ@X>xtIeC(go;kkGZ{CTi;{PndpTD;nRxOe zh5;BgYifN>?>P2a)Z_Kzjs8VxwK|__pcDNr&Ez19KNqfvy;l?sr5~^MT*tc<+j4v9 zGF*yy(R!-tEJ{wzaHqaji4=K(7F#}f_Ed^Kqb<+tJBPt#n;#N>R0s9EW;%IO*wdDC6y*jmTM?jv7YsQhYgf7C!Z%$u{H7nWA|9v=#jY|m0iq9KHEAszd< zKiT|i$J#7P%exRHZd+vH_0GxD)T#_^5!YiP3eLsz**ye zFzfQoYYT3#95~#MVt+QK9bc)9jKs9Po41LD3HP2f7W+kxc5pvXt}#gLsMXtOhZmI; zd9shjjDx1F)h6Gynl(6gg-nQ<83cjp5#mu>9r*9MwC<)ld`ByVKcgIA`|wRmG`@l? z;yc59w=D1D<%%4=e;nf%o=J(zC~e6V z2OejDCx_ZT%F{O;7}=k+g^!!J^QUbWLWU8$3~Lzgz~X&pnfBI3Pg{(NV?QX2!>3xL zKlYPcc;$ukW|k=tk;B}@#a&9b-Z(fXi1tx^7HfLVw07Ej8b{RaVgUn5G;e{NlEq0Z zNi0t29C-|ItrW16N(P^v>dKR;I%v#(5To_y*OjEm!+23nl)6cZ!WnMm`w+%tZ#;5Q z`cBwU=EATQrqjy?oiy*p=0fd(%+EWMfIIy+<=`sP>zTHO=IT8n6TGNWYmz&=;Dq_H zJ*y85&hTmTswYD$sa>t~<9koDd_)N0J=%^kdB<;`N4cdu2?|AhH`$C~(y*PAF3x}L z*=6Q1o!z&g4 z0K;^u>#m)kwbra}2*g)WJ+q$SEN#6JK4o8Yc@HXi>JzG#lU=pPq?1E%zJ0VsU*izX zG1uGw03p-0H@4|~Mi{ajJyxCFqBhTH{-tksbN<39CH=_D{uJeHV#k`AtGeuKA6LN;F-ul2G+6|xTevH-`}yWY|(ZEbHs zc2_zrzL4`Rw2((1VKzW$XN^D-4j|!U`cFz06rD-pGvrCR_4O~FfL>K zox-UE9`v3j95-76NZfsv10C7c-3N84vU@dySH6bdQG@bQ8J%J?sQ#|uH;CR6rVH-m ztbxG;g>atM`#tYfm5eO+1Ha8N6X?-bG!ZcF74rA#C@22_Y3)DYTmJxVO8O~l)6Sgd z@^^5$_^g4yv!9)O`T9m(fA*hGlQ~AVi2nc_!!?WSJQ78dBVGvGy0qhk3hAO;+e$oC z{HnOw+nmP3rnkx zQ)8_(YZ)_J0o){G>`*@%ddCVwXAbnzAI*n{t)KZV6`NUCcIkE8ybdYHqFKKq5d9HN zQ#~u_;mXGc>WUoO$4X&8#8=-}Z?SXw=Ayk9O}(l+9zW?n1<3gdlXYsFDRuhhqMZk> zZo?jJB(3mz}0e^{)?>Df$Hmf#i65 zDj%?*sGcLuA3rreAwhn`CKf@T|tEvG=IIf1Sfk8DOs@h?mP0Oesxq(CR z1Li%|bo60zM)K#*sa>i}Ch+ErtUdVtRcmy6sEy@e$o@496ymP^+LrB&uI!DE9FK1r zCOOE#6z$Ro`y}~~cla7-)8opDhblx;zO5T}bbVAoD|U7lX8`@wa7c~3w^;UWe=)-z zLcWLG0gpfPn)$D{yCg1cSx5DW=_jwee$e6T-96Q>`jvF(x}Kld%t5}l0VK~iPEpI| zNE?oM?~I?eD-$9;Cc@KW^#yA`enm(&BW+&VK~_`1bqY-%*)U;c_=VR%jq#LkR|X!7hEhTKTh;@pR;{47)-8KYQ%FWG zLS#=0jy{^GBn*A2BL*{q(jssb(BXAfyUPAja^rUj2yJ|*YYjJS*GRD3fw=g7^*kHh z$on%-rZZc9Aa?6J!yuvzbQgSAHGM&0LnMq52Qq#@b0qfx#-Y18{UxipgGtk*xxpE7 zxeMXENfAf?0BFQj9WZJuVzn|i>IX4Q8lOp4@efL6^(o#y;h&Bw``90%6IZ<5lTy^6 zvj>SpCU+iNs$+c8&JWpev&Np;8bpuxjw|bi#^~0T9*v%zZDWoZ zi6R8~?;?VItW8VXZ)7~&`wb!Q<=v6ICWZ3{Wd4Z#rMNrSmyp^6%rvq}9Dk_~)bP>n@0B5a#?px(bPgOkx{{Z_vYySZ7=9{*Ity^o_re~09IVXBawkoi+46m zEIlCV2o!{v7Hy{0gu5-NCBPnDzY6%XvppdlS6W0`4Z=HQF z>(kRf(=<&&;ad_kU6e$3$v>c^cScxm44jJHF2L$B!u$efeL;p$jEAY=*PhOAMczBDcB0rluCsjUOq~ z>6X#=?R1Iz(n+H~wy4AV2DGnJu9c!~nbIYdzzYb7mLmhQW{-(HxFD*N=}^!+T^dLK z0A+Ct`xr7G?TR<(zqwnTpD?%%Bc4>j85m%%$o{#mTd8Edn0nYOwbY+vEal4I+b*_x z97Kt3?bwdx{Hgawtx@Z4x*A&yTiVZc;32b=k9#7Dvc|;re`eWGeu@F|74FQA>{kH) z0Bao{#us1L{4aTU3a33`)yXmJt<(9uFvtC-%_+9&TH(%9WWt{rB(_g}0~)fX&2l1X z2YYEb8bn}zRrEK}R+@&XtIfn|8e|LMA$f@X$kBc@_V$#Dc!yfrACv)+VoGiMvX{(R^T^aR)-`XausbitSl&iYl zOU~~08_kkQ*N`RtjRPl z_9RA)h~@*SQb(U3eK!YEl35zr9%}ti$ffhQtoK?AjdJ2$GRe$(OlJ}}`&S#M$Nip1 zqC5W6cPM1guFPUMmPUppz{;wz3OqNmJ@pFL_9$9B*0-QTacaUBaK!_sIG-SltELFe z3ep=gKj}aJ05IS4SLsYBV~!@aFNV4G`Tf>xgz{FY=*Oy7nW+g(+MM^1y0_$=VpfgY z7?ldIqUu1#2I(1A%91Ks(eFsN8cp&PX(Sl?yATS6__7HgUsWJ$Ss%p zKDx4kRcUV|5od@?$B*J18lG)WLwYW$d1Sgglig|tXx?c%cbYgMQ@rgYkG+G)APRDI4VLXBfP!f{d@UXw6GsFzacX)6I2aQRg z(UL9zwXXoz8*qYOO2XS- zvN9N?TU&A36=0>#Kw=K*BOS-M^Q`I7m5s!9FhMMg(Sf`plkG((5hAM&ag*sF`@6>n z2A;M@Nt!6PZY~Yfgy(porZe0E^8oTEjXJe&;$j?Dx-mo(cl1liAUvdfg)O$Y@un@Y z+BLO>rf^tgQ~}MIjul6lCm;G#9rnyiGowi}M7Z>lCuPrvq%p|z$2!t6>_IsITT8`=#kE--fr3(lmn=XGRYiWV`z1pp2n0C?9-)LON7a=h5| zgvP5%qq$|`2?mvoq>z7~@+mxvNnRY)ivi^dik<3RYHi~5g`M2P!ENN;_cDR;_EJcC z>p->ts)Edx_O{_20`r62rB z$lqsYbtd(|7v|NWGHAs$bI|cGXund3G5%{l$J0&W85=Ww?J$pleA3M;rYqLqc53y1 zZV$#mSHLtdD5Ij{_K1GJ-Cw~Tt2|tEg{rf9J24?2rF8&5GxMi=ZKTzhaM{&E`d5*E zbxmF3k3&`e0EC0H0Q{=>AyBDxulH$oiR-y?ei0f;BJCR-3vE6x)|PhpAgu@{q&kMz z#S#3Z%7?(7KJIjH_=tN(KVR;z;J4LM5^TQGxBX*HkJOh9^CFCgqy3^h^*aUiTgrZK zR(7S+b+mtdpY>6{5C_JaW9unz`H~M>kNK#r=~crU_zT_(6gy7nV_hwm+6b4)EYy$U zY|>twb!(j#!LDW1jm^Wbj@~7=iF;YtdI`rkUIe>$C?Ihnr%l#kEdKyh@ z*DM;+Xxt6p@w)JM-E)UiyUzrIITa_r6zm->q1NUP!d^zUwpi<d|x0sjDaKb2|c zYj6!{6csU{XtJ}{UVectV$t66O;YCn0P532H)kSSg}`u({{WMP92Hk5JJ`l@4|PG& zC7p=Y;TMhmjduZwS!Z~0JWbjlW{O0}kqaV*I4yzBwI<&5?k_FwEhK_79I?D@nKOXu zRGblmjOW$`eE#U0p}WBw*qt)jnO%cVaU!HlGIv>d$shxfkB%@12Ca+)$$$JAyg&U>TtnzQ%PFVXg$XQCoo+blEBa|B?sv2wXA*xzzU^gv_CWM{gN zY>vrmI$n(>o|&uLwd|8`wpq6zi+3HJqfNhtW*KI6P(J7kH;#1LPJpCmdG%;-Z)N7w z83oGwS_hH9_23UGTOSFLKV97vXE3-`DK^w>gm=2;&e+JJNM3elUSW|p7=k}Es<1v; zucP`;LYiWIojTcn>?8jGmcDj<8T%&+>KeW3LE9T;RJSfzZV4Jd+qoZTqVDk7XjTmDKPQ*&PM-$ZO^NNFT2@${JVHiJ)xQnit6rGpR@jCOsrb2{#z zC;`eaECJ!VUy&7;dZ<`WYphtMzxrJBI7!k#vM<&7$me-UkF)z~z=BwA@dpQN^|9!8 zS=Um*EmjCE(aX9@&%UyKm5|{Gbwn+?gAKgdJ=n1N1?tslqC!?2&vf|ht{i%D9|8H1 zkGmS24rH@&q8YZM2I#Wis5$Kc2JB1tTD=*{-9)AF8yxB-`93#P3>cHRa;h5oJ|AB_Q=fSZ$xQk}|g*4ONNY1U@q zeP)_<^Y%Tzg#_$XLTZtzHk1bZ=gCL76Zq1jN%X<#Pv*n>D^fUK?v#ejDcDJK9AByj zxKe0SjVRbiF&YEipMf+fUFAfGd6nzce0nz{{oIF0?<}X;Rp~D9atF_3PNV!-NL22ner_!7 zCFXZPC(jr^#HOM{!kxMkzY^HIP4`O=R(&Nuy_#$zjeU+YserGRyJwJzm2**;RL6U} z-5~rw=3k9V6-7n1@wYn(huTK?a~~t`p-d=REL_r%I$)7qQAS;kIUJ7>>O4gf1z2ts zL8Cj};@^%zSCvqTOFB8SvnJhLVaw(cD;R)AL(C!=L3< zd8Y!r&Duh{dQG*;BMxAL>|ulWRChp|ffX`}$Xr?$&RGW^@%7XjN*H21s;&hJGg~J| zdSKEvo%D;?H7jJwucI=K0po529|PcfoFj7wG7u}$>FVmr<0?i7@y#zJdV)J2q_3bm z8MOBIu_eX3Z^a@9Q_CRym~jj{m=WDhu%EP*?U(nZW{);PS+C%pp&)Gkt zBXIp0IP!8v>AD~vU1AoNnkz^fNLoDe6yc(66tFh*Oi=s5^^Qb(={oVDL2r|cW}vh< zQBn;p1F+pDCBmx9i00*wy549{#ZRO>#~PP25O`P3{+7D+X?kgEYh-Ow{2krc*a#7` z$jgQ@K7Q;^w~w~Ifc0|g$);;?#IeXNwF8xVNgML2>-M)Y2WXr!8Ak#3)ff;jNU%7L z2ga^)q=(8?d@$|T$G(^Tio2^zE1QV*AFkd)ouPqXU>q(Cf;kL?vnu73c3{5l00fGi zbv^3?6qbexePt=2UhCJ>=BlF4}VhJ4N6>rEnI3_S#dhllJI%HD#w%J_z4%(w&FznyYk@X+I z?y+=_y4ZR)N`GnN-HD6Rgn@ z=$wXMV*~h{Q_g@M5@n~dxZ{w~%YTZkEV_A!`Z5NIyho(I@yFgOL20*<%l2hg^^u>B z4F%CqQY}Y1OTE>*fAnx#({y0OM)+^Nybc)g<-3uNd}a=nCuz|WvV61GehWv+XY=m0NpHS2v@7=eyN-l1U^$9t}a{mCV&-k@!$ge!9Y1+YYCfFOBmAUaYbBEl6e3L&#I7{s^nxewD4BE9RnX7!QgHbwXU1rn z$N|ig+yzs;BK6ABbk)Vh*5u#p)kKo`@rV#tJ>9EB4~eXAtnKd5ZCsnS!L;2en6GNv z+w%8rF5nF_9{6Bg#V7LYKbxv;jRXBNS(hl%MTrvI#Udjr z^MR7`1KGoa#IQTEPNRKC+fR;r+qM(xQfGbgTX!ccipM*|x1}Tf=D-{VTF&Z+)Q;Ch zw*{9s4~_P1-qnZZvo1m|f7M1xj1A|MfYqp>vEg#(ZS0DGuYOaRc%7n(P+s|#(F zU{806s%NbqS?f)owcm5Ubo?TZhWnoO3V9;;yLuqt0L1Q-dgAr$v(VA4Er7k&8w(w} zrIXzaRp)@l#?J0)jRc0^=s-KJ6YW$PEdu1Fream$lh&VVW zF{q%1zlkoxu=Q7S?_iYlAJu;AS-X#D_4xM`;jFAqSz}MMk2XgW{|jbHwpHH&k*t z&wEkywdv6oOL(Nz9B2BN%BK(0kcP*YjfHr*^B*YLO=w!LNG`H(DLw$#Kq$XsJUvv! z)9+TzH)w798|!`MA7W&{o)h1=_&$lx6=2AC61f0~2U6a%4YYO_H*#C8ys^&FltN1G zl@5m|j&Mr1c2^((a4$U@S4(*A?&p2TVn%l-I8{JdkBP#J@Z;~QMXj>vR(6uxUlcmY z!Km9_F3HPq*EUvnC890%%x;R&hCiDeY{gfS7jV9g0KY8TDyO>x-Q87Y#_h@0bxmtm zk~}ohD#i~qLQd-X)-**YzmJVuYZd?=6|ipUd9|j{drI{3@Hn7var32@8fz)Tf~Opa z!Jx`~s_~I)NcF~p>$^Luk;gW%PQUhMIS;@ZqJD?&%JRor)9w&S7K&zLFwJ>tf8E_b58vOW+m5EPp#K0z+FSU1nd6h@7OyiyFjqEX7q|!N0sYa0 zKQ*2gtKPW@%v(?=ADca-O`lsUhN5qDeWiz&)GqPi+Qv__c#6@>Y2`+yo#jf&gvov= z+xBQ5uuT9ODP^YVk6yO^yJrQ*oKt@_YChYpoYC;O*^9`$)FgxZrnIH*ps{gzRx$ca z7w3v=Zjmj&`)VfjNUicFf;vtpyNG_j-2lW10ZuHv3~=4rH}d2tKN>R?hYG3R`Ze@a6T`eg%KxI9U=I( zlWwH=ARi(=6tn%2#XLE~@PD$U`X>u5dKddQ5x=TLKO;_Y+mp!W+fYl+qWUv`_AM9n zUPbvhqwM;P>1sU)WQ+A~CLjGuPETW^{zsp<{{SlY^9p8TB8w)Y`e#*W;Qp%a>NWh) zaKwKW@%Yr+N7z9J`p1*?-+HoL?5vD`mD?+Z1KoVi>vdqV>w2=d-U2&re0fHv81~c# z9*M1+{;v`JkScxFM(=xQHh_L;gn!_rH)!Zfr>s}r`fD8yI~aQb`z+~NH%D}B)%xD% z<~RkU10jxCN&6tN;op!jGgmj!`3l?L?Io%xjYGo0RA2#_IS%3JaHJgzOjA&{VcyI= z>eV&sg|nF2J*AA`9?sl=bIAAlhvP_fwmy7WVQ(e7enJclz1E-XslRA-hKeR=luIDw zg#E89j$TxL(df6xPi12b!v6sHK|}6%vHVoV+PY@&5o^2dNN#6)V;HaJpQp?B=H9zjg@Re+;A= zZ<}xInKL$xe{cHM6Y%!Nu0A!m&Xbc67Zv>|{>gH{?&=eFw>NM0rjUvD5n{VJ3sp_{2`b=X^>k3 zP5S=IB+F+-h+aqovEhYxcg^jC{zcCYjMyoZr=JFiHBdMt)63Gsza_ zHxIE@Fs$XN?A`ogHMez@K*M%~hT+J9I5!SpfJvoeX0EyYZFBzs4hY~Rd&MxrBFWjLAr+Z<65H(A z9l)K@l5v`l8!~xS?s}?A{{Y(&lkNOzr&Q=b!NNjEm?IQ;`rMDExPFv{?u06F?dhjm z+N6WB4h|jFGu*q}nU8SJ2ds*>U!rKwIU1AP^d)ZS$o`tJ!3P{i`S$0PINqb?8ivXa zI;S;#w=ukp*)v+8A*E5g1dEc&K=ij%3>E-@*#{Uj=b>$6PA#({hMjk#T=8H4CwIpg z_wcKx!=w13M2#?rqW}zG3W&Ernp&)?Eu#WX4}p&T>;ey=#AndmqBp8TxO-2^l!ypb zAnB6P-b5EjjI31eFkF$ql_vq0IpR1PcemS!k1L3q_R#({E!6tG*gNFJHvj-A0{{Vx z4jie%$DtiHF^d>anMFS_RDV^)?W^=Z*&H!y<`uwdkh|*@tTza@Z4>?_+I*3J8YbVQ z9ix+pw$trLnVv#D#YJvAsIj8UoB0X9duqS!qRmFSK!4m9bq zyM1kS6mmx@;m|l6!#kvBh#nk{JHZ_4f!N-IZLD|9Gs`36aweI~rric`kmPy^%RT1+ z5^+S?^>5Roo9iB%$TCzHZPYBqoFEwV7@x3m22MHEAMFGH`A*0qES}3p=^E@)B1;6z zENlT*QW!8ih$4{fF5lbQuJz5-cI)FKs{a6Jf_<5)Lu2|Mx3;&E(Km&;l14>WWhjn! zw-tc|1#IJwBZEobtJabmcvvKj`O-7FKC;Yd2m4hMTtNhm+j2iChUE(3ST~((^obFp z{*{4q!+197_8fewA16wBM?dML{{Z-g{3sCgH>Z#Kg}mNU*$so z0OoPGh7~^YJrr_om2_YJ>?!rjFU$%e9-DL|IsTIe{?c>u6){eZEeX%m;Qs)-PvufO zJ(adm!rmqIB9Fp^?`~QK(Vx%wQrZ4Ws=n(xv*Ybw89)9hzCwX#*26#_Wu5$lGq=oD zvo4&Un4|Vb@}uq79JqUh1OEV>gT!Qg{{X_1Fg=xV7h63OhVGwnDe|;q{i;-*aCH9w z^?DWa`mI3uny}@$J|==CHFNErum0cb{t*0MK%g;op^d)I$=)~XLkj%!PH=ULm4*{z z=f~+AWFH!ptC~3tSN{N+KeS`@UJ_caq1!u&7?H8|@qfg)xBlcWN@Lm!@y0C&PPaaC z7C*skJ_e_v=8lfyvt{hb z>Bq*V3iM_xDc;iLyM2}XcU3Cx&qdW-=3Szb)fKI}d`ifEbn_0&UIG6A>jmGoJ8%C0 z$EiD$C-x$gX0Sq?G!k%;3SKW9F5@|&XB1Z+kPf2$l=#D(o`$CC>c5TU%DA~qA86X}_j$n@F;was_hkDvU z=ARc777B1ikl0d&eka#W+e!C5zlA%?77a9QFWmOf+@*vhHw#I0qmWOHDZGD0kANrP zT(P&psPYvc?7SOg9DHeR$xJZVWgTxl`OoroNK2^QP-^ z;9(2ze#E0c4ey0Y_Tt~uR_^bY1%^|9vL3a zKjBM?(ThzySW3odrtO!w4{s6g0|)j_K3JtR!LZYAnc+Wmi5{*;`@2&P(PienYg$0-xyIQ}0RknZrP_S!b*VK3&k52*hD>G7&cb_E_Wy)75e znG#1jAG`3UNGWp9z&kN1H~Tur;-9BSeeZ?XXd<=VcC zY^97A)^S?dMlGz6dJ!*w>B@P@D|2^lB=`UYAyk@G?^ph=dUk{n?CqBS08wAJc|X}! z@A|Z7glazPpUM%OwhrjBE&6P|ien*@EuJb)RPp73fCrl3`O{x(N!}29s7`I}RHPj` zbzjkS=NHQDq3b*tTt)UCLBuqeDhp@thKgL{EWiO!D?YP(L9(ot`%7s0g^qBVga>@E zCx-ECBc6C!I|)xc=>P=UeW?Qg@TpG1^n$S~+uR_Y(S{l(0hb4!6@gL@dLKmj9yM1m zI8%j%0rXV+q0X`zcCQLeXK=D-Ib4A0j(wzg-;dV7DFhWPIMt7+Xwl5Ot};AFKVkm> zIjl{!-9fag>yvx4*X^O|N=pPxi1)F;qJ~$99N_j&(c_0_js%@EtOnHV>#SQxrZ%Hu zIDK}_Ll86epL8l8REuusnP)umpM~9t#?9)lj+wfbY1*VhIpPvvdL-mXo>9s@Bpv&Q zdNO&TtW&nXMpf;LnKWHZp_FkETo9Z7LR9>eo~Mmh#ERKn4fX5NHF5rra3Yg?ce9wJ za-RaISnq@4g@EL6cvaVO5>>;1ec9lH4c05@2jq0*hB)iikLRN&Gi zK@Sf8)&S7^(x%%#*Z%-Zm;oZoYTf(shf(Ti^2h${daLQJ&qcQK5O*-#fgA`POg$m* z#a6q0)K^Kp$)4UPW5lyYyXXGWKFU+=kkhQq)I7lqvrh$NjW4ZDgYivLB_0KuFYT&S z&r9jiB;y{Su0KX(Z<88u+OMKhNt6*E33ZH?OfZa|keEv4H@oU94D_#buSgA$CK z3|vTqnazDf)A}Z~K&#j(Rpbv981P@S?iDY!)544>!0;RgxKd}Rld~A4uy-p9^fB~L zSX#2pdgl62azvQi_4YuOC;lVKz9?&B?6*W#w#pSD100VG;Bs7m2vOXF2s@Nh7fGFD zw5=*OlKT260`S}|`9gm}CCrWcDo1}R#@k2qmuh;!n$upk|?+dZzKUqgWr?9uqQk*-kJXZ)v7ST7e;?Y<<;z>{{U-sC!S-+ut`fdj_i{h z?vCP+j1RjYt#Pwj6dG(I*2*ZY?bqx_3gK7unT|}7AH407d6G>SF_3O|DwnNOom_g3 zTIjO1^~B%OddVcc%(>l(+lzKV@>)}qnOv76kWJzZtvWe&k=uGsAaTf1<(_Qcc>4eV zQSr&$la74*&okypqmq=7<>tyeTS`5(y%}5H#o1ljCDeJ|-H}58*drr6hy?K*Y15*e zmWshXuWf9WuyOQbRY8-?oDL-Slf?JavPtJfJ5YQ_H(-hYD?NP`nnT(_u3EfhLSM+L z*xsn-`bc%Gl7?! zmrN6-u!Fl_tD20)ApU>ZUM#BvHfH_e&R79RM^ z(aeR!5b$r%gZmT!{{WJ>?t#T^^_B-5qdmdrg85crXwhDXkz zYWboU1ejMD>9)qgRU}I)zlP!!;pdirhADW6RZGvR^#n51MCOilqKXs3yxS!atL0x# z2YX%GR{sEai~F%4qPBbOMtsE;ws(SEOK9K5_GR`V3;0u(&Rb>U9%8%Zk0|;N*K%~esK5K6~X0E?=mp?NmVge?6Y>*eH_|s zj?Wc@QK{sqB*;g)P1Eehs-nzY{7 zZm6^8D}SlS4HM9bD&31Fv7i-NH@Q{?ZKA(~MWIH05!+>K%Lt$oSC4Q6}8b zexM)S5XQixLq~`+r0biCW zEA$&^-|EW#(f)KoVv};g_y}dJ3mQYS8oD1y*iJl*(VvKM@T2ZE+xYqEsVDq9gvar! z8rAoGT&>&eyRW#?Vby9g{{UHG`y=_)9fOZ9`hTJc5rvc^=?3wB-i$mfBmaU?M0*9Do=D*eMyhW-vbw z;Qs*aR7S^ev($o$Vra)S0M+v?*Ed0U8WTMe+R>ZTJ=FjRi4!%_*rB_CGEN7H{&m!g zJVDR--_()GgB4$`^5Pz3}_L*Aip(H*4;WU3NX5ydOO*8xS|1t^5lr8OA2+ejn1iq=OF%OdYby}@4x461n+UImw)=?4UIZ|MI3Yp!ID>F({` z4ap?zvKM*c99Pgn#~^~B^Kpg?l0jr*Jn^N*2x~NSUvIl_YM+}7L)ug^_>*6G z_8u)bE_HI_{S`pBs)wd#?Ju8?*+cPQK1)sLyajt%l)cT%d8+)|`@+3@Xv6@mxO{LA z&bqk`m88l69R`^gZSc5$@;GPK2LtUCgK2N@Ay3YlG3*VW!kryPJL#%pQLTG;)SE7#Lz_irG$A7ztppVC90im=D3gRkAJwwCgN_JWb+ zmGpz}sKonS7?rWle!ia$GP!!W{*IVzyAo9<Tth{R8U$lw|jT?+0}Js@Un$--W!MIaBZ>@vQC9P43}kCya7@5BRUfv?oYf*qYn3 zWh&}b(DPRujtJyHIVy3$WM+*Mt(1j3Y4)PJDa>o_9~xHBQ+~}h6_?LG`gS9q9u-1} z1%#EWs`crU%L0oFRJGxq#2RDRqqlXLlqP%V2^)RXhP1Byq-Sq3Ze4zS2)TH&@n;bERE2 z?olHhj!qU-35Q73@ooI8N0XiG8kF_?*=CnVGAPewEN*>AdNc8LtGlZHvkmTvVI(Aa zFr;o5?#UX2Wj+TZcnkD;j==Q>7A9hel9;W+V z@<)|1T|cb#vFFw|nVjZq77^ zw01m|6D5iA11H}?7Itv3iX1yAw^c5^k!w~GTu*Lo?qQK5xLESay`Vjust=?iVyvVR z%#1m(Al8SAZeoynGb;n)aDB%HzGi(Hw_jYo+B?Qw*e)Fa9oy1NET93)CD2#0uS8KPv2u7mF}a=0N$?JvPut z+~#xpP5kK#T7$-?-H7gp9^WeUh$Ct*j_tHbxI_Ab3Ju^sli&47tnb-Qr z<1A5CJS3UH0f!Y;IKgFMb!m}-SpNX8Hq9-z)9khDj3lcXe$-b4gX|L__EwG@Koqp5 z)T5#wqsLB1`u?qPF1>nvCr4tCnSS>3vl%AA;2E7&zVZ-oHC~mr<_K0BYe?)6a*i$C z)yE(fHB=uTRaKn^?ID)Yl%Gi)gYDz|>TI#%uQac^RRAUJdd}PK#hCY+RIOFciPL?Y zx{c0|g~j{`vo=@qY=j|YR?c^V$gFapcoTvu%V_8tPjw~!i;*^H_~oRLH6H?Tpg2;q z9cfrfQE8@@Ax9I-6ijJVuU#&-F>LOi^@xH#r$3=?jmH3BJkCEc2cM?pSM2W)7O=?e zAzYf>W#6SctOSiEzRX!aA3^SlpR~<}iW83I7@5Z|F`v3>wCK}j64u=!PC#)z?0ehR z$KAkp3WRi1vu|)E)h~&j{^mxJoyQQQ=OAMlkeu)UDg{-*Bvq{e#w((00~#7TAc1?7 z=L??t96%F;!@$u1&>gA`qX3@l;~UHAHxISn>c%NW^|;G4c>}#qc6>ZVcSyb5-6I9O zF~px5D(>L1JUH>rIB}p#29(5Sb)bHa+wIwJ(?4h~*yA{ml;AP?0j~ki~76?=h2Z9j_lxVnN$eI$+t2j#| zMS@j#kN6w{tJ+@BF1h9lTA7%Hx4v1+SYf55K#F5Ao?isCo4{0(T zD_ZI{LX#%kFP8coTlX5Yyu_E6P3-L$Q|62I;>U=n=R@}!_My)#x!=n`l@rcI8X1rXU6!!328_YIV{L*pFy-KHzZe8sUF^5l6-97xjFpsV7*=0~>5b z$l<$sBPaFR0zL|BK6agUJstZaV?RkNdTLt4Xh3-t3FVdpKJ!LJIXqX0^5a!L*mQkw zaLl(B=_$z`kOce9L!#>MV7W0Qw#WU<^2hy`1L0pC9@4|&7RU&rOK-#q`r2)^g4!A8 zk(csGzUz^^2X_v6ZcE~L4m1z_tZ(T?r-HX2Ia6-Ub#%0b=HhdiBVt+z7?TyTe@a6V9H=iR zq! zV~5{w-B1l7T%o8t^-2E#@9$WMephFbW8)3fc=tL3MObyhxk5$G&0a)2V(h2*0*085 zc%|X({{a0D?xf5|WqDxuQXNld;fQZbGDJLG)kpCWRcsx3^qx~Z)(`o&HuFdE++_YL zn)G|qm6oU4eNO5tlO8ws4oc^dBbW!c@}g1nKZS8ADXQbF1%zjW&vE4iyt8~_jzRIG zY&yY-P9oB8r1NWW2tM11A^BANNKcJ)Th+w@uqs^8PU;7Hw%U|{c3MUMRq8g{$P2R~!m0tEy|n&@0wdT7+#T!Ow7Y zC``ZT*D$K4dBrGJM!@{jgl-(jKDvMC_eOY9WRvX{jfw6p*v5`Wj#&xz_|xPjlnE)> zb7XrwJ*)fy{Q@XP=D1A+Jq;^5&k8|B!LCUk1d)@}NEEF${&D`jzj)JWY}b`Ms^YyD zyh4`WZ8VaQUCR(K38P}wpYymkdv{OSp#!nh=+rIC*yO&YfH zAGm+duAL^^MI4a)DHCmmc`v-ztokcj^XX6F@uHgCj&;}CWiG( zSKmILA;%aVK6Txn)8SykNp~a}#(5laJbCBCfvEb;O5-$%)YnaX^+2<_&71!M3gzeD>tG<4|4*Y`$`cM_>lbQsFRlgP|HLkPbG z28o(R9DpaxrKYWdxgkck;Gv7^Zcp4b>5!Pco`eqxcQoP zRN+Z9NVl$hmLh$w;Cz;)h;J84OvzFGb@Io^RGAW^+N?Qt_0a7Vb{rWl7;+ntkJvz4 z_?Y{tM3jqX<=%)kc(G>v<|pNfSs|)8*;a1}Gp!>jiLJ=+aqJ);!koJ<6yJ3&9^C$( zKX?&VJR&odBbt!1;y~TsXZM%>WM6$bGG@6Qx0i>*m1ni}%6Nq}=r&EurTry!_-F8^ zu8uGFE{BOSA@+m#fl-ZwpK@p$^zrWmel+)|#k+jN!ccvUgZS27TW-(EX=1vF3e?*z zpZd&G#he*qc*m`jfPMI^JJ3d?VY-qB^ml;-AI?+D94BkCH@Zk!fP z$;YjU5)W&u`v|QcWpZ>pu>DqNv~cTactF}aPR-qX2^AaCu9%rJc{Tv@#|oSDmbsTB zh+qgc54N!YliqpP)TK6dUpHpk6f5Dr8BZbz_wcCRhqN1jBQ^^9BpzHl#Ypd8by)ZU zeZsV-QeLB08pfM(eW=XhNg2`wM*yHvgEI^g-kHEtg~B#-yfK}VD2|GE9fkH5ct_7F z7VRUp(IvE&=EC4^TwA-L9v~bXcz}3=-lP%2m#WcXlUTGV8>Pqsg_;XCE7WqSYAC)uWiM6z>A389pPxp8n?V=4cW_+$I?(V!os8HqFDR*-ZnIVU-FtI5Ovv@53p< z9L7NAD-d1vF4ydPRMYR3pmT=o=4eX}<02dkj|FG)CZ(MOb-WVd3!*JAhoTAL9H+wv zf;m5M`Xv2=q=cZ;G_2kFo1dN{DR{{W@x8?kyL z(iZUr?KyG$tb{}d-U3iKgI49Wn&Fw)Bx(yYw*^E|yw1nDRY51!SrefxJ+_NzCD&z) zojTFdD?}rkCm3_Xlw&tjf4y{0Qc<2mTWAax<*ItA^?e#Og`K-&x6f^GbN;!&-d|K| z7BP+lsc*ejA(M%tWS2GM0#ehqB=j88WvOLR<&MuRbkgNW4^znYVoMvZph5I z=e-G8usq0CUu_ZxX>y~2jdf7kF*k3IvouRO`$D(($UdsP%yo`FjyR>ekIp2OadzOGVln76-oRj@Hv|@8zAuP?$xzW592SVH{6)q=j;O@mjJj zv)VWTPN@L$GB5M2@6(6T%RZ^A-Cjp~6}_y)NOyxYisS(55MU9ISRLiy+r8N{&W-+# zJrc`x=MrjCkM^f_Ecry3$ntk_;wW+$$ON9sMXflG=SyIEZncOEVyaHGopWQ-AnfCi z3#{XiZQNrjs(#eIIUynh7t0k!&j7s8M;mUZePMckw$N;v;(tzJFsYm|BxO`{^qUzc z2XS)V0Q|br*6(O9B-Qmd5+?9ekB%_BLL&XxW8{0;iODQosLgXzz1myPuj&M)s3)_G z6U#h8lgEi4l$RM|H&g%sOK#HDBUMCDiZ}o`06YhkYvbd1V*8;v_LehvP~nn*SWuN| z-87-3l9eM-fKf0CJ{nydBf#U5c+!jc%M#nfw|ykd9c)H;jE?$Q?X<&9D|f2h(2r5I zJLs(N%0`?R0x}G<9E|?}#j01LE5%xCQ4@N&pLcrI00OtEiF6D zr%?cG2Hzm4(MY#i5;bva#b{&buSnD!MKtuUt^HclQIq|hyv{w5F&`RrwtgbBx6&)t zEi7$DmkqzxFK?oB2RISNeFEMr#P09RX!xU0MC@6}j3%)V{1cHRqzRmQ%XB?_!a~KEj zBaiOa>CH#7$8#xMK}q4tkafVlmKzNQY?B-}X#PRBLj&$pO_6lvrgIlc-b${=+z`H=}rDOKCS1(YWt10N|luFbT=5=eAD}JGb}Q6CZLy`XQ{^mXX+T z1d~X68i{lT(|kIuqfHcYK|D>^+(jzxymKmz^*&iVIUZnuK@~ThYx56P#(p+a*&w55 z_2Fra!bHnMj8}mf<4t=-q}&;7%o3v8tc9lsJM6oR6;Oawjp7Re0pKusicT23L%9R% z^j_6T;H3k0qPikoNA>w;UfNF8%m}O%Oe(9{AkPJ2ce_SB+>^yZ)R#(`jA&+1vXupu zNh+lM&`24_EZ}FD0<9z(38w-+N9BK2cD~RU9~$QM2-ml1K=(I-iU>lAcH|90Hn*VI zuC8Odk+CX$lBy$d-;UNj$uXYd#CcTYB@cInYvXlEpy9{zyyk+c-LmTS70it*-(ANc z=2GTm9>5$Z_6lIM>bWWqVcJbbA1|ykk8|W7ZCuSpMn!5z?LKT@3yB_war&W<)mhEZ z=AwWY_O|RF_mNw;_ZY{9d_<^ttib>Q=6NrH?-C z;C>X{s96aWgCJz@-UAr(&kCV-gQQ)Vic=AS3)#msx3w27>LxhdbL3bW>3VlvlPrJI z@_k0*@n6*tF^u$5vM6FYgmj74e@@fev%9?9kp02D@9Qd{;2z>}p6Zh9M@aV9@d>Ri z7UDDBq~sq4Q-)LIz#l5Ye@@6`VpAKmp7pZhX*&uSN;W9C)Dx^%PSNQW$nK_FhdZ#u zKGcoF$lwf&oE~5RNfk^?IWk@&j9Dr|pGO4>TYAXap5E;}%u?O6N28=Mw21&s`6eg} z6_bB>fIuLFyT^xT_lBL=J+xcvJ6psU62y)``>aQ#zp}xX*;={%5==ORg~0Ddx1;|0 zE7(rVv7$>YRuUSsDmeG84*lj%prrNFunUu^L^vwNMHHSTpG$yff`BoI{j0&}YliX%EbNDm&AC z@mpzBcltlPK>TUrtQ4PF+Fg1>$FYtm$Cf)3{_A_{H`9Z0Mf~PL{6PK{`d6p4vkoTz z0Qis8T5=9bf(5P-%es}DdI7bfQBRshw<<@nzt`L!0UG=nkIYlFR$1jrwpT+XUBJ%t zjr=%I?wYx<4IvUOFFfiet-l#PwKz5;aU&H3)g)g?u>c(NB7g&rWLpTkTX3orZ~$OY z=)#bVtcVZmllO%qxZBu){=9oKenOAoxb{@>8at=p4}~=9vg36322<`~Nsmf!5B0hB zH}a<2?v|p@0AKchnX7nCBZVSX-P*RKx52KgwGF)MD+%QjpJI>1(cru@jJ#Efox@H_Q5y!jyDwVS9CnEiuNZ9_7WAfdgZAVYH)0AUu ziGhKHek0E;bLC1;(`1uiLM(ZWVZGMp)2{Q&*IARVCdg$iuLm$pP3k#xKK=3S6vd}? znb$W}Qq2wqGuXaQy|GaallH@0yoJToWI!;?0wRIC$3LCJ*Iwz}GE7-R%YrU$b0>_p zic@8M+uB-4PDlk0uwZ{@KH6;Eq@597?_19i=A=0G^Z2Ou(}M9e^q8Hgp>T4FaZTGw zKPNBhn%A9Dr!ZLQ@+UpkKD^5&N4BzlwQrP@^-s#RS5;Q+H0vSY9DAjDOEe1z_-^DT zKcrKm2JIu(t;P?b9L0OokH|{i%)sRSWL@uBzf8XpBDmy6eY}YF8u}Tt&lTmp&Yf@z zB#PtDvv0Jc^pJq*;J_a0`PepLtS+wXE(sFE-j5Cr0pdr1c*v1YfX6Z@7|+sa7G`P| zmzAzCqh^kZ+^vgQwwZR)*M26)b`QIsz}1=6=V|cqoZuWQe7#D9hd zAl(WBNo#uOmQOl2K2_IFR0>SE*hi(m0DHx9Md8AZR>gFT0h+f7bcn}I*PAvE82uCB z-Togcc_ZINZui%U;foCz#&nR^_GSZ}uD?f%hIY@}dowp3ZL8&SP&V?$4q}hj=5&fgY;2wr{U2meG-S zWxsN~R|gTf{VbfQ@!j7CklpS?##)*V4&i8izMUYwy`8RyXP4A<0-Nf;Xj|{%IQy_} zB?H!oLEzPKb#Ul!`Xf$`$iHY*mS*l}d>3Wq=#m1VI3*DY!N{sVs7ArQvvqm2+KlOO zeC*N38jyPg196{l5@Yl~BLbXm#vQnltTtw^g*&yKkp1+d#?TWZ3hFg=-n^@tOgR>U zN4-L>Z`X;d6;O?#Zcs%JeyUW2STr@Fl+b{xld`gKCXBXGpGkL@ydFdz1Ro+eP!*^x zHY%+&q>ESws|K8O``v#{Q-cKH#pCESiW}Mj8sGXJtS%!-hu;Ccr=4KVog4ZmYgXy~ z8(a8SE%!r!v7Gv(jaj?|nXPxw_S2$9V3EQVB&jDEQ^%R`-Yte1p(XPc%+ z0fIjx=36r!N|Ayk7++^LGUHX4YC4p?uJPcUxDFr?I3#>{DD;~pjx)T^`vuT6R`{0W zi;IqaD5uLmZL{}}4DuQDjhP%c<-(te5Y81#ttzy(Q~^{4P%r@C0Pq92d2*#9lBFuQ z6xbA?BZ5(gE3r}?tlMa9Bkd-cV;#Y`llrOwX7&$VF6Sb&(s7b9C^5`)RlToUAv=p} zEh zGCf?VO|p%sDV4R+S4SUpmA%9o(0zWaKC`nrm>f!WQjb#08PD#MR3)XZYfSXfVfCE? z^+3s!Ph;z4hd(u}Joc8$9Dwjs&{LY=u{Tg1iqda%tzI2c2$=&T30MV^ETd${E;pAO zp|B1K;J|~5&^=JDCpt_bc4+`-SlPL>L5M-hk5{&yHI_U;`>vse zL6Bz$J|ec9+Qe~<(S-wm0|OZF@~$)5ESCu#0T;L<<+t@+*GPeeF(BuJ%RZW=kL1_C zxKmeJo{vVYvlnO=H#VP_PanwwSP#~Oz6W~&OYZinG2fb%^hvbp^Ggv^*}7nYI2>>z z+NAp{lObL0Bt-i|r^2@+t6cv8w%F~9Jh{9(&G`VXB0B<)AuyU4nu;~W zi#5izrPV;Kojv*aX0%y{As0%9KoiJ>_)((R)K&Uk zwjX($)Ti;qR6RR&p`O)PK)+Xnq8RQB}hJbudDkl z(qqDVcj`s8Wbesp={At)iFpBrE-)QvHnSnrMg(q{$m64B`LFY8GfVy*6i z`eN5zB)7M=wT4f$4aDyL=Xi=K*YWC==9o0BV^}S$2A8EQ}Z2grS zK;~GILa0y)C5Rovi2{{qdg4tQN}N@IBO@FSk)d{e>J_D~Fce^URfJ){HKTPp$U?f0 z!<{kh4!ISyO+51@yesyeMhb&GvQG{tho2$Fr19HtXLN?gJRFdG$8}|E-Gg1C+8euD z%(j`Ph{s`)7F&r_v1i5(5CXXJ0B~o|Ytni^aC7pltZ1@pdh{PMPz|`#c~_3D(+-^l zgqoY)M)pWJylfmZ9sv_~Id(h(^YOq0h*iGYZ5_-)?$1xOjs^je8;K?D*-tIzKqE(C z$9ITa;I}$U(nnBRON~QJp3PF$!Z`!oTP{{POkkLtMm+-gCO0?)qi{LSb*Z!fU=9_d zG3mH$Yq6RnF5K$=QR%Vg=jl~Rh}*djS#zLnwAT8dyMpEsrfL?)62k5X13SCQ#3(8o z(Qfr6d4>k9`x(|n42&IoRE8e$!m3W5x|G>!w{T4)2&x>ku7t-dgLeTKf$1(lIXGqH zq*8c(j>`*i+G$spT2;^`~3I zdZ>q4y^nooRkP6+Mom82F?D=Uk-X9zLlGmB6;f9`mPT+1QCd%<-II#a6o%RrwuN{q zMgo95PkAT3j#!=~lTiMyT@&3|+D`hLiS;{)o=b^1VYg3b5L^HP2$f2%2JZP79IDq4 z;{vmK&rvNc#Kw2BXcTzZQ3Nh9Ca$cVVB^(c zxr**FX{lQ&GpE@EEeXfaA2VCPku+$|tfLM$RItnQZd`Y>k>|#* z9=`pBfYYH5&;7%R_wvA-hGXU}*Mkw=x zBlvgWM>lV}IDMv2espS2f3C`JwM&Us)RVi}ppYQX5RiujP-BY)VsVqaa%-{aKFvEd zzAUFpKAWGSyBg=33+UFP(@lh5Z&S8k`)IT73{5ttT0Jm-_Ey;b+W!D5#+nyYohn$; zXz!kQo0BO(4~W5Bf;-gUj{2Ety-#$kao=wec%G z=@F8@TI~q$NSKd#Pz^On^pk06$Vn%vFg|2eOub(;RBQ`(l>W4Ye8oK3^i!p$71Kj{ z(6`K*J9-~ZXJ;R{{>X!*Jo4bBll?39h;W*Gjyy{u{08^VjJx__p+)?TkXgS@PzS*c zFhOZ&9|Y2sf9x0jT3owR82dJRo~VHN3Vpa3EZjr-s}72Ly<`1Xti9a2j-{vw5VhMD zbRn5kZ||s53ZF*+XS{b&TK7vDjqySCMREuub~h&@m)Qr z;-6R|wm;-FiV^G)hfDte@>ywiLtK-Uizxp9h6nbz*RA>q*5o-TWBs7N1+8N*O><*{ zDE;GJPL`!S$oC)TK|M|Vn26q(JGo|vdZp^m&f+oal25@6I9hclz9S8L5|036Kin01 z-tEMX&O$w0DYi@Ec<-_Oqg4L@l9cudkNDWTtmm{HRxTiHNjl@XfFvJ?_~M(7SEG(M zL-#-Ys?oOqo(y+~pAk_Fd!u;YEf=8P8T3!HyV>riV$X5e!3o_tos~rgK~ayjId4z{ z=3190Lzd&YoA$qu6!7{Q}j_>o`=Sm}pQ$}YV>0yk)wI<7ciS0)?iT$Ja5l#^7 zU7G&@)tB46erAt$6b2Pf5y!eGg2CAk))s@6nttH?Y1+qV5ik-3QRJ#VLYq}{T@h62 zapbFr+B+#TbcrAs2nYG{6q-%Hl=pm3^QMwTd$X!(R@>1iePTxD=iWf1mx^2vNg(j% zd+6=T>00*%TT4h(*1||(x_F7k@N_>h@dBqJI*oM4qDKPu6^N%d{(6kPq6k$xfn0KHk{4s~+%4N%ti zqd4s3R>$k1AN@Lvp}0fFAX?Dc3vN0{KGK~306%!)R0q-pvY3|BRD}o}B6jXL%S2C} zH_)F1QQ=qo2NYh_Z0vW|S5|V2heNt~?&N|S{>{S4>I^&SX9}X3SWedOc~yrZ0Uo1X zW;EBb(Y$@Fyr8VGxkgjn#DYDH2= zgkD*8QZoK)5$-g1?g2`HrNk#f^kHmKe5fjET1O!anm!}tgW*IN2OQF?UH}aH>cm>` z0d=jw=R|G;g)qzO z-wC?k_K@!$_V1{}8YW0Vp}!TcM`Xk;U$CD^cA0phIST3Wo*0rbyXz9DW%js^uKk?b zB)LuaW=n)WdNx;IdJ-$;zM;{CQWLzY-e=Ko-dtzfnr*e{XR>b63&*x`$19d^VN)5b z_uzUa1mn8;-nRZ!62iV?=>1^!j?F^Ds{LUHC7wOz;rdd>PyQuNwqNNN*I@qur0I~| z4;5EK??!e05lTM|;S@;HxA~70QqD72-p}b{xL@nEJ>OK!J`D-^(~|u!Fb;Z5ng0Nc zzy2SpWmyJ4rUE0uG-+J_0Qf@w5SnAvzLi~0Q;U5DCI0}3Wt00N zAy6@%doLG-w+wZ}Q_mbcdxx&Rb?qNrt*E^8B97lr2k#_G2=>g=5&J3)L3N#hy}udM z_JO9)9?W*~0qk)aFYJOU;{nA`OajtfetJpS+nWWnp6tu0M9lF0TXJHIKn#+$^2V{g z7kF>>id9%40E3G5PpAE6M2^}S1e>@4oe_uwmRZ3B57NoUj|!FPT@6}7+y`yu<3A1q z;q9r7j+}@9RR9iL2MW`V5Vf?S(%Odq0PbSu?o)B7-96NtGlEY6-MrkCatf*d9Ehtg zLDEtL?BPKgcnsj56W+)4j!&Hi-eM_oO-u-YLDWhTy&SDc)X~9eu5Oiflxxu!H=zMy zLZ}%eoB%vY?i8YCRZ{N3Pz;hz00WsM_f$Kg?uxGVw)5(?&epnGHXd!l#oHMGE=Erc z>=G6=^yE9m6^7@4H$?)|I_=%wrzg}jOKEQ|k2w?_)Lem-90ITBOEC&F!B>M;!srj^ zgouH4v=?o6F~RI1bGho0Z?@;Si1z?Iq;Xp3pdPPQ8$V*U7HPas8Kq!5NuR~Y<_9HS zNW2D4NuT{>4g-Mj94j7d*09h;Y-FSz8|+1fnFzPj?4z}acSfN}^JDg%ze$NFyw+st zQ|WBcET)p)IBqW87^^gDs(`7(0Ayum!31P34mc7x)}@YKHAMBg*Q3#F8vg)J&>+9m zZY2>G7Qy(qCw^|tdq>FuLhlXcVV3S-K_Qfo;;ip!m9GZfw^%Ht0k zfC*Osk(>}gQb7b@^T*7Or5CFrXqtttv!^FvZ*b>*6N1NZzLr!D0cKpC(ZDjl-CzaJ z^iFN{t)#k!i+3EBdeoCV%@mRLXhNv;BVKCAH=LnW^av@HQ;S#Wf44)kCx7h=m_a%Lvg*ce2t(53-k*ZP#-92A)(>ro?kM&f1+j!Fr z&e~4==X3i<lo}(A$cmDGUkOa45e5GQZPZ{2tC9e zVy$TU1Bp)=-MqLDmTE_5(Y>+{>QTQ~7WtD)pJ*a(?Fq)=xQ*6-l!o9&M}eqT-SjVM zcC338NzMq9caBFO*dVfz=#Wlv$b*X4cN-T0;n>CdfPASUYz??M>=XFDS;Y~a)tHU8 z*S*mCtU+PbP8DtZA?HFxFmH?5=Gihq;@! zXZEiJ$o12fh14qa1@BlRNWIKUA8spJhpvJs?e_0A&C{fQ7ls=_yH!ra^*+;W zw^+n(?iU14G_54Vcb4lC0$}k00@aSY4G4-zTGlfgl3fa+M=i{-KWUG7IXnd^?Wp+X zu2(OsWn{m^dyV~gO%OOu`VP#;Zh|=BNFp4O0;>B0jB)Srrx_o@glEkKA_yDOWL)Bgan zga(m(*bUIR?hL_*;sF(09-S_0i(+Xfv!hf7$Yh;WMnsZJ$Fax660pD%*}z``&Rc}ijQb5 zpi`EO^j>I@!d0cYlIV^z_PdN9y_G`ljy!O|xN{`&p!H4aw|Svl-8IF`mydAREsUU8 z^V!Jk%v*ehDo1xLu;W+XN21~zSYV!Wc`7JZn{#x?N8a6B;Gbzc&l6E?sOteFd@bSL z$2OB7dlY(JTuAj$Gd-cooXd(`sg5;&Aa<`nnQl0Wom;DN_AS6$H~s_70xx z$2pjf_;2vxQB7y3Ppw$X6UW(HF$h4(QiqE$Cy3w*ka+RPByz3=ki~P6;S0M$4tJ|s@;p51N3%U-t)`J;DDg8KDQs?&;gC)<=flpRdo|G8+(p`# z?2z*Ga!4G>;xH+8)9pT;b*E+tBaorpjK)S*L^vbFcjr6^!Q;ZLVY}0Ncl1n>+TC3@ zy3pctf#2okYO&RNctn$SID1qa+k1CEsxW>y^;Kzk{UNNxY|>SPZ${F%1xrR8Z>B-v zyfeUKJc$dO&AMpl;Vt(igl@*ff;ppFg@?@^%a6{@Ch}MfrI` ztewxgH~oYH#;l&NyIz;m1(xzhU%EHW2^@D@Jb8B>CaaB<)gw(o1;7DiK#@7*vIfaR z+&e*l3Fbf*HQSDcUfIq4nv{`9?NDJsPU%-1H_-8K5uW_#^k!U4dvrgNHa)(JfncF( z;p-RCfx5Ao6cTa74)8ep!2ySusFzhs=Qd&oJKd2gr}Rcj{5}-BW;)F>#8&1bB%p9* z#v8+U9$sW;)m0wNZ8Wy!ni&j4=Q!o=JUv`1Cm$MP3!r}&VO{eLsc3W2pq;`* ze`$sn%ZD@YtQq5-&!^oDzElx)hbU)ReW#RI#im-Rd+W&+5f|-tVES$3r-G7jFkI&f zad-4}u}x)VX=G0GMH;IxzV*bC(kZmfM%sJX;C4lp+zFVdBoOC#Z*GuxBn0m+0UQB4`M4hSgd)Xo zacQ@9v-eCfv=+x*JkXXRDT|{nk)YHq#m%dik0nT9hejOH*SUwZ5J3ckN_s-+0^Y&~ zxw#Bf5UPkW^1Ebj*=^q_Ff8>tP*T?CV-rmkv^Zh{jqTfsayO(PV1c}$j|E;SS6@N9TP5C?V|6ji zvP26UV?ach$bhTV%$T)uC0wALE)Xf9xwp2h*$qUVrQ1fSb-Se|$w zcM2}f;KHh@!*zILy8@?Odv;lEb<0cKVk@XqeEyma!cTI9e_5!6`e6=$@?|64z}Md~ z+zvEFoM|8BZD4^%)fv^z@b}OkvP5$z{b9!*^&ZN&%5bV5qwQPjcUDgP5=f1^A259q zeV`9jXf(Gxcu;c%kl?x;C(}zxiYAg6_YX75pR77FMj00fPlEA393O=@-7sEc3Tl$@ z6U-lVW|lbcn{jHD9*UHIuJ|wWt~W+${@H!Ne|n+w-o`%DDD{v(8ed~*tOFg*NLH3p>EBf1~2iSPhK7~3D({J?19pMGpNM~G!4haW{@6U0_#yDcNF!03S zl2%q`VSs=FdmvFoYO(0X-c9@453K+okfKOee=EGh0Avmuyg2aAIDu0~=<$f-sY=cI zD;f`xrKEsH;?h%5PvS5*1m;$U9A={M^k6>x(o6c%xA%8x=T6v|laeR5EPd=v3EDu} zFw%RaTDH$5$~U+$mr6;Bh`agI-|ZEtwG@psBo*k8pSzD)ABJf{TX@ji<#x3~x_r|2 z);UMq^iQbr_>YY->~^8Q3V5%geq-TQPQ73zj%d%`R>!OikBAjZ^mTWJkvu>n);atS z!nXQ!o6_gHoHT4H#LvL^)rZt$Q{21MpeKVs9Dc-$kKvHjcQf#(zN(t1W2{6NaF!XP z_bV0n#tOxr!`$2t#}vP#4W#X00ghx(MZEt2HNB(a4J2D(KGR`muDtsJ^<78naSTIpqj>(B zfOGpmD|xVURs2$9R=w*RvZGMaWL$4c5z6=RUMc&)w12cK80>}C{3?6L1ML3*3{~;X z9VG@~vUANcZKN5Ai2I~4_Hg+cdTJ_rVCq!&f<5CD{EfDxqcr7db!*o5<(P8cP{Y^H z@%U4hO766k(WF93?w}|{`jCHY6M$^=O z%3b9R-$yfN*G-!}H=&OLX~PsHVusOXfhuB_cB8+AS6w6R(TuDX>?#LY2 z^9$TKp5at;^b1yFON|9Mmr_zOoS%I)&9yNn+L2fFm=Bq&i=a=UyHnL;7aFdhED^9o zAa=75MtG=Yjxck;Wr*a+rnM%_{SR9|2+YrOcHgHcMfM1h5Ahn#e^6@^O~&;r=TmLm z1pffi6ZeHWS!+oD0Af{dQv>of^llHLzh-2}7P3eBv&;SGZcK52P=>t{T(o~XJPl@-@QO#0Kfat$n z)wfNa&dAZX5iP`$#U4CEhqXdG{SfEFCatZX`W`izhV|yN6|p>bV-rog<(4rH9<%E_ z=pOdpL{9+vswr!oY8&Zzy zj0nxgrIHpuKb0t0YhBIsFKDNhp?E#8pZ<`h9b4#Kw^5rtZg^k2ekE^;JZ7Q2NI4V2 zS)Lea4n#_YFDH46eltnro`t=uF8Zy`O~+vs^K?{EcdhSSG$3d(o25J=(wONyCX`Kd6&cSU2|mG^mz6D5e@QkI>UXxfJ)!iOfsp;t z!+S6K5g;-*^8&uA^Xik6oJ9#`b$u-<~KXc$XRU)d^omzXy)*elg!!GrIOi z8z)^yrk7oM9UY+?l3Bgbl#{)_vXm*ijBf6N+0Jw2QmFb#u{UDJ>#&R5XRz&4wZ<}k zC!OvCjxn5Z<;JjwSN#GgFSa*Oj`8lLzKZ=WWs*Z7TZfB$rWlZ$j(2xPGDi90*){h> zn-^(kBuTDnzg54o)C|VnD=q8msY~WJTcBD=;g}rba~r(=>l9sx!fw%oge3XMo^p2i5yvzN54|R|?vQ;&~vRC+xrgO0MjP*v0{H3aP-|IUFla z=+@7k859W~87R^_D*(aE+Nm7nx%c`XbLks}_*utGxxZN*PXox+(L z1ygX6ARkL_O`ltzPtc-$=2sIQk`4!S25@8$gDqTWBaw9-XH-rZuKp=J-xyYy&AXfu>Sy>vO1W! z?Py`_^^CgV>9gDIb@;X=XKB&nBZ1zP5ym}i)nZ?DF53I)W148yC0rGDmNjKO@(Pkc z2agW_06J~@NMQQ5)yFq2=&hyw-$VZZr(4G3ZEhJ=#O@S;dE=P!#bR|nq3&#r`fkGC z!@>8!;0W1eTOXsFYbN7ytgJx)0Qb!9eZg_@rwRUxU1J<}kJ9ixwmto(eifkZcA1~; zOwH?Ze;O@p)x7JLf3;G#;AkIV{gbWsZN11@=cHewp5sfilTy=ld9F1(aoabexEWAo z#AG`@@Bpw3SU4NF1lE%3!PAh{blZEAx{{o%s-pl(oErdg3JsU<-38V9=#WRs%ct05^g4=awB;Z=MctF$N?TDl=+1q@HAgt zE~&O7L6S>YQrc@M`&eB7RsuX0QosgnL4{J?quzmfHFC5Tg-hU#as?J;7H<5bY8n~6-yof44-G6 zWWI~`S5eiqfvsrPvD@3Lc2Z9>m5%WgfZvl;XC-%i2}U@m&!nUd`=F2hhIq-)C=l)` z!-4nR5IhHhcUrTjzN|L7w`DOcX?q#WcQAk?1M^$+)AU=H91sfwAhYOx$tH^U!Xdjw z{G`zV5SVV^yLi*karb~=N)R_#Qr?(4PWq~LF!8t}B!Ebbe>m{QPu(8V!x^M3dU?8y z8ZGseNFy#X!#p{RXWN?PM^MgnLL|2CKF5)MpCn+`-l|$JWqL!jaNU%Hae1-e51vjq zdGf67(iYStw-dZvV=i&$=j}N@+J`|ph2{XJeYWW$-on-HrzFM*RaN^==aMJs924La z5_wjJH%@%SM8Rg&-sbY8mZ0XIV1)P@tZsJl;zMO)C56V5dv_u%Zy;F^KvkYeBXBo+ zbyW%?bp;wg$CfGgr=G2vMxUqL-lMd874SXLyVWe>J1bYAeVvvBd$avp z53)IxQgYusdc5i#*1x1?dF^AjlPZb6aB>NLK>39k{i?7!GwEIr=;n%M2|Q}cgTs6r z?v?T80UW^@R2eSF#c^d?=<;@qkXt<}O+Sb#@3Kx;W zfy191_y9PQ>J?BNb^%eWbGSowqx9XfxU^#MMI6Pj3dxax4swhL!yZQhL=)G*NiTxKq;I8oQ zVaF=#kbV_U^%t^hNwrHEJsX&2UE$&svJ>V54u7URYH7E&^3J+-qDZ!O)3YqsWdn9N z0IDk-2-;JX4q14PV~tvR7tzNRvweEQS-DH%59K5g%#H}+9dNtq+&C9K$Cw6}#$s<{MVk^_NEEOU zhQhA_$I=DZ4&_nzja!`%^x>i#D|=f_LNKtPKqfttkfWJb-Qve2<0B`XIP^udx3@7k zy@RvHH(wZ80UW}fAdddyk&gN!Y5EG&=3^t-!0da>Nt5d!0&Aa>KONC~r0qL*X$OB( z{{XZ=M`bOr*W;UvbEAXEsRSQU91o~*s9#V0KHKQ9MmIjUe=qeggXfL;F_sCQ3Zv4I zvKJU;6xT^z1dTc=buBCyULQ((b!P14gng$>l2w!KbHD>C0T^dar~57S(RZ{LgSEML zG28F)?Bh|pmG0jAfw`zw?_*73^z2AuIc3_T9gfER5XwUwTT`efZ6)+tX(KBNLTFVpmmb-l$&`5$e4Cfbb+E+Up0 zMp8v(Z`EOj_!D0*_B&;o3Gb|B{<0Tg?N%tndq6(g*7j>J$HjCQ{{W3LSlj_KGyZM|zl|;E4^jM$ z&AC6-$Z_g#@dmm#-h*A1%Lr_h>Ye`p_iqPvs(VI$WYO098xo)TEMxx7Kb1c`G+4K+ z?-kWnpD~21O1Uxr09PDq>Wx1D4IMtED{;yx@mQBW_TC=~bxebuK-wrnE2W8hH-6dP6{cbVpkROpW&o@uj%75t! zN2$;EY6KD<0vX9*?xoLaM|CHXp}LP*%T*j1*sI zBbgr-i|JFN^pJ~;9|r)CJc;=qWkhuCEJuyN6jZYNrum&&S59^Zho|WO0C(}IwXOBh zAM|L~^_-uPtvKV8O*taznX1B++G?z$(PE>@n{_KRkncK&J|t)7O_r7yj&KY~>|^tz zrRetMxV`9KC0u?Yr^Bx5d~cF*x5A7L9;!TN#-sQ9IH)k%f&JuPml|T08)5dY%lhOe z`!(SmuNmo1rn?KwdE{ni`GnAU=S!FZqj@AJ3x|ZwP%{#P$bo~;;_D=WVRZhceJ%q}~f;R^B z9{&JgqUpb1(-P#Dmyy zNAWkhv`)JZZDQnc#vFSOAAvQUFHYkmgP6!ZrW`+rd}~MaS0c>;PUc)=)(D}+=%Q1> zRpxY+5IQSzqoyRV|!h3S>q z+nG83Uh2#JcDm``+l$>E&dGu-5v8Gasn|rM%ShN`;OJ{T)+wrdW9@2|gIzb?io3yK_k4WEJ(X`2cZ$>e0<-6J+ z$&;S_;1#lX@xkR*RnIa#VzSrKS+t?_Nny@f8>blXSrRrL_7Cngq4x7^V6rpGb1K6M z9pdhApb|01&ma!+cmPSFJ%KPland$M^ZRcp;v`;@jqC# zkoM^`hy#^J_Umkgv`qZlkRB|1yY8|L2F6*)<{*zMxELVF#3*h9eS-nNIvMJVrs&K*r>5`R zss8|^TyD!R4)tOJMt|WVpAu=?psiJ5wHD9-ksup^c?2WT$Cf^M=4!laLF#?_lnR!Kn8_-$^c)t6VLtGfQu9 z+#^ef)q6wvsOa)8@$ObCTax7Prmec_y{76Kj9QJwu(+539E!xWZn(m+k0UOFg7MwP zHG zH^I&bBP<3=4aKu(gwKcoUrOyRIP<9lH=HZycDd7*wV+L@>6h1IQ+;XdIbvoE0mNq)I&aHKIb8{Z^iEax(%^^5NiLy7N#s&`d_-p_@ z5$9u;G`iKBASS74ovzuawHte#KIwZ)d61(6;N9cEgN9Swz!Ah(#SVouw}O2_)Scyu zP27%K%FcxQcYYQ28*chfk599{zmWP6M9=GBJq7iP#_q4u7%EXVDsP9TLAFD{C?|MG|6(mzlyGf;Xv9fsb^^PL9TsKJtKl(9t z`69H`Z1M%l%98~=%I@wlAF5Oa@ZJ@=bpDWFlN-(1KZ9`h9#!%SU)aY<1>LNzX?UkO zQdcJ&!Cop)elibu;&Uv}dfDv^K7;l*IPZ}YKGLQ#eYKf94kp57t{vCY4WHN@BHg#G z?XBZ()XoZkcV=bWG9cn3Vxt~_QRPlo+jR;^vS6_sH`X#rj_?~9qIEe}KTLLmxvJ@N{(Y|JwmDqApoxn+B&viirfyA5vjO0VUnoSESqRsiOaEJDUEIvk@ZFV2}M@Q1- zM-kjb9B&hmkQgXdRvqZTVSvYR?yg^|26!-;I2ZmGRnUnpkPb~Fc3$TBCW^;ZwpcIZ zjLjcs#wUw7JaS6Nhl8Q=Bb`_g>@2=Z?fz9>>6V&=m*)EM<9Wnl81cMgiXd`Ah9!$T zab_fVV-;tny!iB{9>pKyUp(rvLS(pWjq8i~F2fQjLuyV_zehVZ_SZ=%kQF&r03IOn zp#ijo7;WH9ACx}|(q@ic|Zdq)K@IbOX__DaIed)AE>)uq8?3_>wnfK^Wg z`$vNC^5I#>R_ML;ZK>9@>q~}$&Thg>wv75r5e8!im!~7~&PM}~c^lWF-iGJY%Sfyt zfE;$1$tRu_K^fu9k^s+rJHJAsXYR%AyDY6Qqb5e{J~;8>eS~{QePcJPI~IAI=MwTX zj{AZ+`T?@z+RFm?Rp!|B7HuO^(QY)OG}dV;X`^@F5jHW~4g&#)2f!p1Ug7|^I`HkA zjhwti%Sj-P5C9~TB#5K{Ig&H$CYJ2qO;?v!u8p{{FyNT`0Y`IlCA;&(!xcH!_Pz@x zjjjRRX5ibq!B0GpGDbU(I#y1vEV(gC@p>!oz3!t>Xd~57`ZrVSok|Nst6xuN8b=yK zWoE$=+BHzB9!3sjn*bRcuiD5{xBv(`sq~a>?DbeRE8x-C-pOo}&f!T;JAs*x5(=>c zc$^*4!A3RykFUbk@^SffgHM~vwx{1H&#`sYH)7fX#{Ir&eOgG;u5F##TYHoeMRyyMTxoDM>9io;r#0{uO(Eu>29212cfZg2SJgZ9!Y*!)e7fT&GB(giucNge; zeU!xQLv(<`GmiXc9r*F}Q&!q-R8}^V-b$s?B;MU#Y)J~LJlTq_c?{(66sh!M+Zi{w z9D6V^k5T9A_&c)XVDSdBu-+Qr=Zgcks`G-+`fJ{4^T{Rbr|MB#HN^6LuGZ#e%c_n$ zu|SKt5)IMCcri6@X&TIPMw3Lr<8TVFE(Q531wg^wEzYf8p&cbf zd7;|Ikr-sQRf0t-K;A_dU~$AaA&+=s!1CgwqK$V>&{kbP>)o#O^2|4*E7^%#dvTa& zdjx&6jQ9*yp{#VPX&h!lJY_lISQ33e5=r(_{Zl_4NDdqkklO@0`R!yVjirXkRrgqK zwwAgx!)zgY%lUyZ!5#w@8T8$of+qula!wBn0!DAzev_K^K`fd{u$1#?=1Eb%;wV_k zpVLs;EAnwYAN-gcJU%WB_hA=I@0CbHR%+2Z5t(A4T#M)0hSs19U(duwRbEMqQ37Xd0Xd5-O#VqAy5=n0|j0qug9Doy@(ns1{_b?!LVewDi1Jxztb2LAw}lSe#L>bkA%p&wG1 z#1WKXWRfJ^l2qY>rM(^T!a@rSj1w?cJEJ`7mUb$t0KiZ^pX5_^(QPzV@J&0Mst!Y& zF(FYG&Dez=yw?+&=`*r*2GMl^`z#{+C+3|Ym}Z^rABwJhBgkio^$up6w2rNJ4 zMx9XG-63YetXr+y7CZt7w|hd_;f>s9mTDih8zXcfp5$qZ1+$LQbY~IB`@DNj#X$m9 z134_NKmoXb1};}nk%7!>2>JT2r1Z{{2qDG8e#(dXPBxa&HEk{80cTj8+>~BDo3QMI zt_G>Afq_IM#Ik`Kd zXV5&zWIPz-k1@Xm^{_>*$LPCQ~D8hY8nc`X{#3?(Vf0l25G6+0H-+p+H#G zae}G<^CypeSF>zjw7E#`jIJp97w><4~X{hrXG*AAz`fFL1}i^I(&C&Za0{= z^2xlr%6~c;0;yqu=t;*a(w#Hxf=y#HAGD~>JedI?{4we@mRqF9lrl$HAX4Y@8eHN} zDzoXwr}VgtTiLr5KW)G(mF`8(8;{;N95@aH)oHNXbv2`jBRk9YlP7hD^YB0VQyr{9 zlpJ?bsBNj3`gA%di?y5Ln_5py>2A_nM!AsgLcyYyUmO9-y1#Jek3Q~o0ki8@rRnk8 z*+(3a+Nyw!*`3UD-UYDFl5$7~B$G|u4{UT7Zw;)m<|yTBhVXNM5lZ)s?@7Ck=m&;s z?CHZswUW|k?onDj#sC~}JEwPd!GXr^ID=W9=>%@@U#PU60iZoTsn*M4(Hj2C!K>M{ z0%lkyRXgz!mRyFwBoT(j2=K_N{@-Qp_D=6hw}SER^h@~+W*GqAic`1?0x&a{R?3A8 z6_t}AT9Nf!*M+^Et(>P4sBRhj=VkyOAbk7jKGk%vy0*Wi+Fb^+g3o>8NtJ_KPm#O3 zfqfKL46 zp6q6Ue`91YX1`hAzy~NlRYKz)XNl%OudE1-5LVfpwg}~0TBl03@$LI_f4K+j7 zKHX2-FX}`1;;P$?t+ZdTq5V;T^T*1KhorbiC>z)mQvT@9;G;V@j^SE!b*qCOg{#Va zN~8P6ny)(6NIuS;k69<-*^iw=wJwleG+7OliyLrOVpU1-2XhC9futIi)!ptH1(*qq z?6&dYbP(nZF7_dD6bB!;Fx}>R7*#s6CF< z-pn%`X^i6`6QAv7iq!?;g=uBnQotSDaL;}e4ivzEb^!<8-Q1O6+3LE4qb;a}@>L&# ztp;r-;QhT$Q|c@~zfp6s(ziRtBlf@MrFMM}hWk^K>R;wfU)|53R5_#lE4r4-M1QQi zcl~Ey$28K~J%V#^-?yLa)UF{Wlww<%Fr`d^D z)T93ZT3pJ}>Y8YA^i2n>Y`DaBs-w#T`zk2~$4qyU^0o)rA^z~I3OjOqXn)bZHBWf? zB4|FUskzkkDL9D4W64y1YbKLPYp5Oue0gty@D**%A}Gr+QRYCVxi+!|;$}>KuqWY@ zP$zNzRYMT&R5I723y1r6WO}#T=0z@tpeBBIB>K?*0L@Pi(#5*}0IdhqtbYMTN7M95 zcq=gV0rP5yK4ZgCn#hMGIceQlV#lX5vGT(F_o>!CuGYiu7WJ|I?kS#bv`PRG+Lk}0 z!{ikF=|BGf5O;6(Y!vz7&&w17GEaI$1_AR^ts|k!H0NxY^WJamF&_$hhhptllo5|o zAp9zrlcdd6&K4Nr{goK~@@Xvgnxvm<>?f&+{=(2sG|uKv)euh*;fqh`nolj&$EbfQ zVCx66i!H6f5(5PiE=V4djNoUyjp6O8D&M2~`8YPB*!^iw&lJB*>fRjYYut|fFCTk9 z;ZV#yFAp+srTH3~S|jB^dNjK9WKRMJ_V7O;Qr$;R?G&$wHD74RZSI;Pe)C3shXdjT zS=L6o7+tBfxjC9cmF?8CTQ`y1XN3@ZqxQx zcb1L!&^nJe$@#03>J@EtxzYTSNM(zOAa5@&BY@4V)iDj$mWnYW&zc0W6OR z;<70i_E4v~u!aun=dQ`#??Q(>h7Yrc@}%l^I6i;p<40!23H&M7N;-FCb2uI6cz)NV z^E7RopnyjrGw-KPm8B90W*>QXPpp6IOglgJ0Px{Na->Bcd14((o=1oH)l0M%v)RtK zmds$ zW6usTIn}7GmD!CF~~mek>OGl{w+O>+7R-bFAQ; zW;tTHUll4(@E#u3sco-Jah!&TuW{lCBh*0QY| zI|1no8_LqhNhf>iFeyF#gjxR0OLVK-1^W=jzNnOZmaTs!G^iU(h1WKw&s)-L<$}s6 z%G}(_otU~MT>wQqT<$Wa_dF;p7j<{GjlQr`47m(A`>Hh_*~PoP>0o&99{QmAW9#{4 zVU>x}X-5sjB14}t4&wgN&ynRzy}J7=G?1zfrTaigwQVl)A-Od6;66AKV|*(gJTP4N zARX0LyYr^qx#>3VXz!a-)MD(_Q6sjDWFjKA_C|QO-X*c=JLF&}P~j#kXAz~X-6uUb z?32gL8m{_Z*>2Ds-hXL*edGN3RgzYa>pEX(xXuXT51;tcayL*wVbX0|okY6KEQuYA z?f#V#?kFc6v?G#__mKgNb1DHD!`9`GP!0g7Kv%!J@wW1ON0ScY{znSh^-aSPhQy}#5$2^ilXZ+bCB4d9XqOs=^z1jJ zLcj>rdI$o;BVaLRZdoONG*-&lUa%=Lc94{~PtJ&k9?&vW`tW?Jai{dZzBm%TRX`0@ zF9StX-7j=KeXQ8%SC&yn1kk_MB(%KWF|)Wsi9t-UMp6Q@uJSqiCxM9Qok8xN((=Og z{v!mpaNa3~HV1qpi#l$4A~3F+M@Ce@_uO|)NG3RXLI1x#dd-z8Y9F~A|4(85z~St5zsK9q5@6%EHBw^_5q9O9yTY1gw&u*hk9WB$zuch9Lw3;S%flln~S zfvW3*SgjoCwxjHY-tA>1;|(%_xS(XRuIR=T_Jo5~@MLkTR}X02Nc#TujyIF8foCB4 ztWpDWC^=at2N)!Cjsi&iDKm`jp{6~Yut;MOMV`n27d-fLC(VvY@g}0153wn25I_Sl zAiEL8m}efKKIrb^d6QDunzdkrMkpm@ex%@JqWek$wG0|*!q9>uOMLSgpeBHX{{U7G zuu{Zo623Va2n82pI5gy6vVna-3iPMOo3^uQqP2&%o%dIeA9;_04)gFGiR>_C(-MoXmJK~Tl?;2dDTu2 zaEc?9j1K1XpPpQd0e?lb<}-nY^;S%|fu@2mX)ERgA4C?pCS-zBpH+lfz1`ix zA}Eo9lY*;~aKr{?3ZNk@at9+?Go!g2#HL;n5DN_NZm|O@4}R(4oN?z%jLAGkf;^>P zM%=HAJr?xS4fmnAVVfD0a=@N<}f>vh*7Sv}YH<#7KAIXy15Ra1F~@g8@Uc$S4PNe6B6UuYME{~d7OI95c^>g^O1jeR&>?6&0DEC zxr_EwKM;-crW=m8@h)+2%s)j4`KtWumyg-0AIM!?opKiU3rxk*96_=e5-*BB zrQV5YZ;~z&;ax^J6)7NK50+N5&G%XU-LVz4*9?5{eaas-BH0dyF7-Lm>rjB(S*O^A zvK`N;;ewnE<{@%06mwy`BWJkT415l6aosRSlh}P!tR(-~iwN9Dp3L zQ!RZK!oVxR%!5{?h(gZj5OxkVsdtn<^QS zFoAm_c=lxP4B#;tElm3{uQG&^7if+$Fi0cF;pae3n z1q5UeK)~+@8S<+?qfUhM#940TD{v)um=|H$#1-uOs`pA?J2LfXN?`O+6_VTQUO(2=HZ%N~kvTcsi%UK_#KBSi#7L+fv?Na1j zLy4t8+mSIC4(O|brA7$fFTA4%o-1JVWw$-NFo`70rBgW=C5nRHk9QpKsNYN56?Ly$ z$7^eHEuvx!t_J&dXO8M9Aa4`Qu^0o{P=oYU=|wo+UVUe}Zq-Q*+;@9EK=))G;KQAD z8IQ;6(6O0vX?P}xZJq0$HvFo?s9Tz}WzDm5BZUwWsDGp1ZZ4&JhBfB-tD)1Ko^3OV_wvVjT z+I;cY>l$BYc{ED)!q!gNW^&txB1lfn_qmgl^T7nT*=!B=r8$!BTb&Z!08tDw!ex?9 zd*)SF0x^K9fMvLnal)LB+1Fyo*GlGw-p20!m3FJ*Ucp8FkNuj4d(D3K{T5wWZyF=o z4d#j<4(LGNWmtw#Mlz_Na22REPLJ%CZrxywBpi2ws3XVH#(4LP;MNP;?NWQo>&we4 zzv%OCE7^%-T+MP~05ja2h~0O%BAD~L-Z%=HYTHoqfL+TR08cEOaX92N;oV#Q(Bn%i zjVyfv1+-t5fZNiMG29v!s@g3o%Jocg_HyHq1QtW$tT?Ip@*s-JTCIY`EU_yG++w@K zzXutnYi&WIB*x}8Ac9ojk>Qb1`|gpTv}8s}CYkuGQ7MvE9potczobYU@thjQ!Rb>u z+8gFpbG4n6*;LVYDN)NUu6;`BG}u<-O<@yDIg%ig<9H$@Tid_`o#vDSzy(|!(v|3r z>rA^5z4VuslSb^FyKhMxk+fiqemiLYx z+NN|6sEUd(pa2Je9Hgh?Zifq=~%ia>BUZ>ds8+RZ!kchYU{p3<0aKDe?mk=E!E1D<9li*&}P z1M+*WgYJc{tKVUDyH#ImU8XNpV_4APy3e|Pw+y)v%CaBPU};;;inq0{s`k<;V{V}L zJUB;RFjNj+V2>)Lw!3HNu$^tLTeL^dV>U6rj&Zo|k#ank5Jx)OhIUMRoQ}nOx52H# zxBidble8T@FK;zDl09#=VVU@DMY=E{CnE>a4Y&-4(Ohn0Hm<~dAv%7r*LJtUJw7b% zr)c=?Vqifeuuvb7E1pa+P6@1qb)dxjWJO{*5}=Xl$foUMW9{Z``63L%y!Z}4;~4|a znVKl(y?6bVwm(%c)eaQ*tX92h+Ngak!&yOR9ilom^1B8yHtT)xo54#C1;N2MrjCgE zMwZIvJCHZZ^9Q^hcs<9?n4_}*00saU0B`^g0o_AbF;$W<=7#nHmVBA6e7-MxTOoSY zTk8!Tkqp-O}MSnxDfL>j>7x`$<k? zQM-sZKJiO0HK}DIw2`F8=Mr)1%}lx*>bEYiZkG00Y|`FCZdMjmEiwVxxn@(8$sL#g zWMrCU=})U1`W4N(CF@<tB;?~{xJx?JbDWmS)G=TW#E(`Q+IqO+e%x3;>u z>@Fd4@ggqyl~@HKmn*q|GOkVz8E{F+rW;SZ3m&$zq69jXFZ{)cwUOu4m= zI~f$DYOC3v4uEhQ%6R_J$R|FLG~3n(Wrfrh_Hn4~CY~@44@FZiB%Y-}ec*TTN$rEi zF+L#C>$pE(L{jr~oHRbFJ3Xl<)@|TMMkkDN%B!658@t$2KQ{v&0-rRVq54EkzP76V zPVzYS7s2+5vo;sg%A;;SJY%1*bN5wk(l1?2H&2nFYm;>%@L>Xn3VXqJWK51kD>e@U zgIH|J&&hdY4tZ~F)9hupbs}D?x^U&ETc1>E_=Rm|2c>m4?)g71{{Sj|+`oWRH_~&o z-L1}(Vuw4C83_F;UvPXP9{Q_f19P+cySu*3VvF0(i?*9ryIaYuw2iE4zivh88jJ76$ZhbnG~i0a{ATeIMMi zH1K0Td)lXT`}4=uOLo#?(6}Ub9%H!s>i+<>`Z%s~O!|aLK$Vc%-WG} zS7x6!ko+q{{!Rvpzug#g*`2*AZ)LXP&28f{ZuXnFZRTv`aO9x#=S~rH*=C0!LGn~% z@p83nL)FJaiEzcIK0D8!;Rv*^Oz7)Hgyn3+hxGEp-0Z{QLOo_N@PdBIAowF0>RY$-#&a0cftDbAKb#H09%m`&r&aIJxPBFyr<&Gmf>uSfhupk0EXw-hl z_$twIbg=!(syu-|jVk`GUHpm2`XF!fEQ@2a+PuGKPd?aj@oHHIK>FmZp2xBEG;xn| zAHuZV*GW>JzmHOnhHk7~>vPCuhufufibd@eXZP_dQeXRyk z;?GKKy17~p^)f%}Km4I;c_)Q&A{9@5m%1pyh;~%n=S2`jBM!(6AG}HRb2O$c5+vms z4^ofiQ!bG$z-Xi#h}}N1#C$$g8Dm;(na^#d9g4=93&!`rP!6DIEDvz_*P7Y8g8NDS zRX6iq5dqmLis<-PzOZRB$0z3~_7n3WjgH*+zI}jHg*8b30IVMxH4P9JjZ;-{R}`wA zoV4D~@uXe>z;WxxrTjzVQXMBy-c@f955AXl*J0W~AH4`Z#^8Q9sCL5NZ$uZpPCbW+ z{A-};?y*I49YQ@XwD+?5;Ih=LZobNIlz-JEs~==?4|Q$1<<2mCs|4-UiD-%NA-@Cg z!S+_Z=uc@4&7HiA+r-8*`pf7)@h*L|Sw@#qUMh1`=toaG6{+@8#Qe8dACDSu2|;s; z5#3uzS+B2-hxJ{eyJX;(5Ujq^m~VT=PqwR)*~ML-NT}YMPPy+2V?L}a0r7u*Qb+*< z6wV>pD>$S>r3wedb&Ucs#rUSk^$vP76Vi6?N{;L=&QX$qZ`Yd8TAP! z@{qfGi#ezL1EFYP!x)T=JKu=*53}#hN~07eo`xd;@Tg@y*Bs}+G(%Te=rqt&4z=$3 z@M`UIdZ2>5LQi*Kul9%YsJ6e+%E~yXe}fNgOVrSogj!0lG^snThvtd5;*sOWfIGw5 zq#uPXvgrp`gVB#xm3*2>hCPf3_f^WE_|Y?1gUkc$%>=@Y;uTt2bkVOj59w(@@{#`l zy1B1vIu-r?1=go5=Rb$2 zb7R~*$OcacPzy5!P%=s62YEiKgY*H>44Xx$_R<3SbPf>^@DjTc$rlbId;=`(IIL<{ zA(}dgUL$RlRs!;@9-=)?4z+T+p0~XAgVK%@k=t?fOCB4r{%Q;vIq;7K{;F*bydeo~ zYN9BSLV$_@P&frp1{FZy00SI_M)qT4KCcqSiUvG5WcTnKhj0Uc00F=XK0QecjR<8k z01F^tz5(P){h$wVBLwoUMMJp-qfI0B zYOB7>y}h~>pLE8#ZUcFMJo#~?-6D19hRR#Wq0_E42yF`ec4!p@cIE!*-s0@IJK%1r zV1oP(0?cQ-m15u)Hrs6nR@Jt{S6dm98>kedZv0Fzrs8i!g<`IEx3|@Ilt}CdCpCTb zCF^^tTk9>eI{EFV-eihy1%!&F!n*(ji6V@nk-iS-VnccY>3^cT$*-+;r)DLM?qrE~ z<9r0TiIV{OSY7SRE2|azDnl!&%$KP5Qj6&IIkuBtEA{3sVTDLLjNq$C+oY(%5|JX7 z3Z#;Eg5zF24xo7m2Gvva-w`&W>(aJ`_irwyQLET2rkAO?k7dM-tX$y{i~;@ml$X z(C0*PU&QY6I`w<N5kZ$$IkoiN$-txD~q{;MCXi*Hy zsZQQGcvO4056-h^%!S~86z%9aRya~P@TuKbtp1$<-C36dh9emt!ZA_j>zibMJdf2i zreNwzvNogAM!8}6(#YBNAw{W{!t_D2PauLl9s%H9G<@T`Exvu`vSrD_cFxv+3C47J zi*+g|Wj5a4@&vfIaLB}|?kdBYuslJ*2h1Fbb&=|f)O2yBZ4SGo#ycoW3E@G>K*Wlw zJOnbZ{ej_AeP?V8cJj&w^CB|=;#Ell+C^pN;D$;Y9}WPE`K(gX4I{cj+D3{AUOp}q z;2v4zGv$-V%B>wMs5p|$%9@S9)c1FUy{2L^V>_lS5!jQ%1JVPIPb!J*2UII-%_b|G zr+Ke#B3Vq!BMr{bZz`gLxw@c*E4o1-=erm7gRxhC(JNxtuCs0`Jn^pUFbp@boZ!4; zaho~GEI8K2EHUF-0loLzpYXdpK9EdWpGL;Z8|;rs@f&!qCRqXyJ4pmmG%>z#Lr4^; z8{9Gp9I{1ScGn;%Dsm4IjzoBm9Qf47r!B9vv9prfbzb6F+9)lQ4d>ocfNl@78s{#= z1XTx)a11k|y?}Ox3sqs5;zcZZdMXz_(l}Q;)^XvAFA=*Ec=>;YkJNElj_}sH&wr9` zzq;pEcR}`gL#}SOB)Z-??T?}W<+=}|cyZ&Cp8<-6UD*pO*p~7=_`wW7;u#Nk<(wWR zd-0C?o%Gx^rnFhDj9*zSyPcK+Bky*KF~c3~0muM%isIoUJP<4%>l#2vq^AFmp zvpDSS%!(u2M{MChJD?5#uq1LEYNp!nq*?WL8g7gE3z>;gJo~YTm%O6yF?k5i;OCrR z6Uq0XMS@$EhT-=V^5c!;;h*!Gwz_`mGo)&8aEWZLfCVRsK!4i$m7F@9_|%i>_0|!tO7(CQ5$&_ zNX)C@jpA@TF@cT)g;o7(w&vnX%MAt#gwy`EtnYIomfcm(5Px-o(VT{aDGD+` z`+46OT!kYgvIgnSeHQHTv4LUj44BD289et#a_5lq&wXvaIVZzp^9p|-{{SF1v*>?y zfb*r)6^$&E)36&_mRgxv6SQl_K#YVLV+gyuk009XbC%y;0F%BrW{sO)PVi~7V>Re4 zH3=@}MtG!H)Uv5}grWf9uhZ7b69z;Q zEygjp*uX!o4hZuDC%|&5A)+UR7MdF|7$4`y)H}^LGY(rD6UEmGvAMwPoV9Iz=YG;g zF5Y}=u*r;3);dMjp%9kJ2(7JfdpTzkJhK7Av=K(gu|~ul-SW;x8yF&+?8AjCAPX`` z(Mq6_t0Lg~B!Eh~m37Dm3;_UxS_7pz23-xidM9Vak~W99Y*Oo+oQzRNW!nL_Spnoo zARkd96s9X~`q8>oy3G=dWf?~f>{mb3a=q9GRi$EXqOiWsZC&=Sc{~>PGFe&0!Pz)U z?0)DxH%39mIVkjjBmp7mQ>tzC#j_hr`R-W=ld_$I7G(z=n}9c81b_$t=NrO_&~=jp zy`q3K#P?^{=T@zUSZNRe92sxYI3Ei71G3~~tr0W^{Ri&3*3Q+D^j*G{;y{oz^D1Ug zIBwyJ@!`dZA7pW-eVyAV?lZb@!+}%5f%6}YNIEsJ3NPj>y7OE)1G{lqv5zh0YRt+vBsfLavw5$N$)3#r82g8jQ3)e z_quffvVi7}0;l$0Zx-M<-QAUQmCkti9zP2s^*)8g7mkT2fA$K=X{_^f~ z^iKjefyNt?RRPj&%SCGsZvqIJTrt4r7~z-I$braXgvvD2F!1_7o!CQq|U2uBYTi@xo8e~>) zE!^9vGNGbQD&44<626WO=;jaF#t5gbue~GbnpLDQq=8vL-t<_OY+$!8WY#8jQDjY% zPUQCV^;&V_;~5>Oim%mT&XsmL;#AEB` zUYT_+$rV}c;w>RSI6Fqole)ML;GBS5fyS}-SiQM!@vG)~? zS9`m3;+yZf%_~UlWu{A(B6{s*qJiAZp zoO;=5U}R&N>8mZ37?Z_^xD*5t7*5bHPdD=np}U zc^t_GiP_DB5EqcQYdaEJMjnyy1D*gK!-uc{Br!pN6bu3WfAuGs{kJu69d0ki3mM1rUID2py!SAhA*1yN&KyU=I_MS7$}K`I~vBz@0LS;uC=_ z`)8T`5GoiUrS>qDU{`~88qLj>*$n{l1tDwSbp-1{)0FnINnvVAO)4V0kF>F{&t`|X z$&h4jWiW$*e874x(&M$Gn%x)U85>iDhSvd2Vkp$RDG?aPB-uI-2VJ zJ6tg&0KUxp>n?OtbJ^;b5od&fkN1bn)z}h+od7NlE1qlJN z0nCBx;q#)6Tna-04HpzSQ94_#A1J&;R)?wEGY$h=LDyXg;SKZa@n zZ*f*urLDxO#N0(50p{zR`3(LQThFpq+zA{08FJb8oK~hVrqhxV?(1nq+-*E@{izgr zXD8e;Xg2*Jq5Cm3zp9uX^NLq(rN+NzB~M`dDOfbkwOWmGiA&ae6A*}aXD8vnd}&Cp zD$tZwK~+H}y^0+din%rygHmm7$F)~O_mr=<91pPI)g!UD#UXppu{p1K1` zUAPB4MHWRT**XHKy6(nhv5Cjrl}Ys#W%-d*2A^@2L*lA^;8vaM18dr8*J^nMC)&7S z{hWJiD{RZ=D9`BpxKXiAm2f7kMSO=U9T=3 zi0|Y6eYF?Rt~W~5uUAZ{VakomP|nUzRuXS2x0Yzerp=eQ^fBHEABd+K3wfntQaPjp zB-96KIz@JLx3Wf>;fPQbOC92H6;uLUiSEH2+Yl#*6GJfI)G5S6XK^)$S3zU1D+W9hv?rf$a~_vvPo& zU2Zq<5<>C(Qb|5ld374=3vsMzt8sS9*C{)~^ftg$ED2zPndABr>VF7V!qT3tB`&C`p4oiQ4W$ePR~?M%Tm)~)FT4`~@pIN`6f@^4v_Rh&9@r+2;k{rk}$1yhnAp`we zjrlE{SjUn(eLNac^RW)0Bc#0pu`bbIc|}js=-V zr9qZ<#w?=7!t=62w4EnG2M*JDyb}(6r#qwGNbsY&IJDLEXYBONGE2TVEXB$%>pHYC zC+(Wv-3)aE(sb@J!k6~|@Og)5Jh2fyi1jkcM{}CK_P3?GeInbpx|{0Os6y~k6-yi{ zgo#cEcLf5mtO+4os9Y+wfm1w_WlbsJk&gsIP%?46Ni+BtnC};G%n)ljS>+!j zaKN+Xf%Hmj++-}W-A>=7GAKU8iE;0#oxjm#vno`rw5r3C3z@U)=YVQ$ZMV?IZqi8r zXk!ydGwCcuIS-G`J)83HUm9`fL#6e6u!>|3yTb$n!wtlMK?HCldg(JfyxJ~+ zs;#m5CpLQMvzNS%Q_aNT&z5^slQooje)=pm9WE&^G;7wrjRx_H8YL+rjlTyJp&aBx(Nu&ZNcwILb67*ouCKDsBp zJ&vNI!vrshy%Y3~*DJ$8q36^B#3!D^y#joe?dzqhGg#1dOw~fxrSv zOT)tjjyx(=1Y8Al@HhYQw6~Fm7y87%v`CB} z+)BlLKG_z9vD~2hn~iKAqtB%nTTZoDm>KQ&l34SC0cOGdS61NMx3W2k4tC>YTyUkG zVY(7+PiQq;R=&E8TT2&^LmbSp8RIPg%5hSml?hN3mU2nsRVP6DMZ6L0Ub}9rw-a4Y zWql^|vrO`{q8TzzAUB6B4c`Dp#Y;Y}Zq(b`S(A90qR(n~ zcUoA{vO70y22mW0Mh-W!In?UwppAacU4APFAh@*E)VnmZ1Z!Dl588)a)-rDPvMAih zI7ZI~Q>n(?MS@p-!0%LYzq~+E@2V@MF20&oj`Fp_vyZtES)|W~5cX<+;z+)#xRyt3 z8lbce>d@#*qu=PAI?GPq`oyrN>25%=W^#DsgawFDeImHwn#=uiI`;@tO)E;cHy84L zPDzoy2t#@%IB;-eKV+~ccgKH%=kAp{-fH^!YfU{3g_Cf1FNbh>_OoCBq;rljk~lYM zN%}SDn$u6jfxfeO$MS??H17P8o;m$C9CY6vfMBY zG=uDh97ITa(Fx+;efa&5m~nHOA-u;Wd8uWEkSc57CB;0d&xD!4+^fj!|i%ntSKY3Fw1a}OCo)!;yEr< zjv$aRk;iv`9C1vkvsQX-?UXuw(p)U+?JC7LYJxDCSr~6V;q5T0#Dd_r4pqs;)13G` zTlGIJzH73REsU4Luk@>AQjI5BXe3EjnvmY($KEL({Rm>Yw-VB}14D%>hv^T~g}&11 zH|xSXh$B`$IA)3e0PvY8t1EN6%a;m$`c1T!Z6iRu#vUh<5_|n6Qa|#Erd=CaSrj

No native frames were symbolized.

", + )?; + return Ok(()); + } + + let mut options = inferno::flamegraph::Options::default(); + options.title = "Android Native Profile".into(); + let mut rendered = Vec::new(); + inferno::flamegraph::from_reader( + &mut options, + Cursor::new(folded_stacks.as_bytes()), + &mut rendered, + )?; + std::fs::write(output_path, rendered)?; + Ok(()) +} + fn load_profile_manifest(path: &Path) -> Result { let body = std::fs::read_to_string(path)?; Ok(serde_json::from_str(&body)?) @@ -1174,6 +1240,48 @@ mod tests { assert!(report.contains("sample_fns::fibonacci")); } + #[test] + fn android_post_processing_writes_symbolized_outputs_before_flamegraph_rendering() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let processed_root = temp_dir.path().join("artifacts/processed"); + let native_libraries = vec![NativeLibraryArtifact { + abi: "arm64-v8a".into(), + library_name: "libsample_fns.so".into(), + unstripped_path: PathBuf::from( + "/cargo/target/aarch64-linux-android/release/libsample_fns.so", + ), + packaged_path: PathBuf::from("/apk/jniLibs/arm64-v8a/libsample_fns.so"), + }]; + + let record = write_android_symbolized_outputs_with_resolver( + "dev.world.samplefns;libsample_fns.so[+94138] 1", + &native_libraries, + &processed_root, + |_path, offset| { + if offset == 94_138 { + Some("sample_fns::fibonacci".into()) + } else { + None + } + }, + ) + .expect("write symbolized outputs"); + + let folded = std::fs::read_to_string(processed_root.join("stacks.folded")) + .expect("read stacks.folded"); + let report = std::fs::read_to_string(processed_root.join("native-report.txt")) + .expect("read native report"); + let flamegraph = std::fs::read_to_string(processed_root.join("flamegraph.html")) + .expect("read flamegraph"); + + assert!(folded.contains("sample_fns::fibonacci")); + assert!(report.contains("sample_fns::fibonacci")); + assert!(flamegraph.contains(" Date: Thu, 26 Mar 2026 18:33:00 -0700 Subject: [PATCH 132/196] Fix fixture benchmark summary and smoke CI --- .github/workflows/mobile-bench.yml | 1 + .github/workflows/reusable-bench.yml | 2 ++ crates/mobench/src/lib.rs | 28 ++++++++++++++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index 59639d4..fa52a84 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -82,6 +82,7 @@ jobs: requested_by: ${{ inputs.requested_by }} head_sha: ${{ inputs.head_sha }} check_run_name: "Mobench Fixtures" + regression_threshold_pct: "1000.0" secrets: BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index c996835..42cab7e 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -302,6 +302,7 @@ jobs: --crate-path "$CRATE_PATH" \ $RELEASE_FLAG \ --fetch \ + --plots off \ --output-dir target/mobench/ci/ios - name: Upload iOS results @@ -519,6 +520,7 @@ jobs: --crate-path "$CRATE_PATH" \ $RELEASE_FLAG \ --fetch \ + --plots off \ --output-dir target/mobench/ci/android - name: Upload Android results diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index c87eedb..a7eb7b0 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -893,6 +893,13 @@ impl MobileTarget { Self::Ios => "ios", } } + + fn display_name(self) -> &'static str { + match self { + Self::Android => "Android", + Self::Ios => "iOS", + } + } } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] @@ -5244,10 +5251,10 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { summary.devices.join(", ") }; - let _ = writeln!(output, "# Benchmark Summary"); + let _ = writeln!(output, "### Benchmark Summary"); let _ = writeln!(output); let _ = writeln!(output, "- Generated: {}", summary.generated_at); - let _ = writeln!(output, "- Target: {:?}", summary.target); + let _ = writeln!(output, "- Target: {}", summary.target.display_name()); let _ = writeln!(output, "- Function: {}", summary.function); let _ = writeln!( output, @@ -9207,6 +9214,23 @@ mod ci_merge_tests { assert!(markdown.contains("bench_c")); } + #[test] + fn render_markdown_summary_uses_h3_heading_and_ios_label() { + let markdown = render_markdown_summary(&SummaryReport { + generated_at: "2026-03-27T00:45:55.028899Z".to_string(), + generated_at_unix: 1_774_569_955, + target: MobileTarget::Ios, + function: "ffi_benchmark::bench_fibonacci".to_string(), + iterations: 5, + warmup: 1, + devices: vec!["iPhone 13-15".to_string()], + device_summaries: Vec::new(), + }); + + assert!(markdown.starts_with("### Benchmark Summary\n")); + assert!(markdown.contains("- Target: iOS")); + } + #[cfg(unix)] #[test] fn render_summary_markdown_from_output_with_plots_embeds_image_links() { From f17dd0387f46dd2a628f53334e027e2bd4425975 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 18:42:38 -0700 Subject: [PATCH 133/196] Tighten CI markdown heading levels --- crates/mobench/src/lib.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index a7eb7b0..b779750 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -4762,7 +4762,7 @@ fn write_compare_report(report: &CompareReport, output: Option<&Path>) -> Result fn render_compare_markdown(report: &CompareReport) -> String { let mut output = String::new(); - let _ = writeln!(output, "# Benchmark Comparison"); + let _ = writeln!(output, "### Benchmark Comparison"); let _ = writeln!(output); let _ = writeln!(output, "- Baseline: {}", report.baseline.display()); let _ = writeln!(output, "- Candidate: {}", report.candidate.display()); @@ -4976,7 +4976,7 @@ fn append_plot_links_to_markdown( markdown.push('\n'); } markdown.push('\n'); - markdown.push_str("## Device Comparison Plots\n\n"); + markdown.push_str("### Device Comparison Plots\n\n"); for plot in rendered_plots { let _ = writeln!(markdown, "### {}", plot.function_label); @@ -5270,7 +5270,7 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { } for device in &summary.device_summaries { - let _ = writeln!(output, "## Device: {}", device.device); + let _ = writeln!(output, "### Device: {}", device.device); let _ = writeln!(output); let _ = writeln!( output, @@ -8782,6 +8782,7 @@ project = "proj" }], }; let markdown = render_compare_markdown(&report); + assert!(markdown.starts_with("### Benchmark Comparison\n")); assert!(markdown.contains("Median Label")); assert!(markdown.contains("P95 Label")); assert!(markdown.contains("regressed")); @@ -9224,11 +9225,24 @@ mod ci_merge_tests { iterations: 5, warmup: 1, devices: vec!["iPhone 13-15".to_string()], - device_summaries: Vec::new(), + device_summaries: vec![DeviceSummary { + device: "iPhone 13".to_string(), + benchmarks: vec![BenchmarkStats { + function: "ffi_benchmark::bench_fibonacci".to_string(), + samples: 5, + mean_ns: Some(17_000), + median_ns: Some(17_000), + p95_ns: Some(18_000), + min_ns: Some(16_000), + max_ns: Some(19_000), + resource_usage: None, + }], + }], }); assert!(markdown.starts_with("### Benchmark Summary\n")); assert!(markdown.contains("- Target: iOS")); + assert!(markdown.contains("### Device: iPhone 13")); } #[cfg(unix)] @@ -9292,7 +9306,7 @@ mod ci_merge_tests { ) .expect("render markdown with plots"); - assert!(markdown.contains("## Device Comparison Plots")); + assert!(markdown.contains("### Device Comparison Plots")); assert!(markdown.contains("![alpha](plots/alpha.svg)")); assert!(dir.path().join("plots/alpha.svg").exists()); } From 31b933cd1ced87079baae2d42eb64a98a2102bfd Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 18:43:51 -0700 Subject: [PATCH 134/196] feat: symbolize Android native profiling frames --- crates/mobench/src/profile.rs | 625 +++++++++++++++++++++++++++++++++- 1 file changed, 620 insertions(+), 5 deletions(-) diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 3cce655..dd0eb1d 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -3,6 +3,7 @@ use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; use std::fmt::Write; use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; use std::time::{SystemTime, UNIX_EPOCH}; use crate::MobileTarget; @@ -98,10 +99,20 @@ pub struct ProfileManifest { pub capture_status: CaptureStatus, pub raw_artifacts: Vec, pub processed_artifacts: Vec, + pub symbolization: Option, pub warnings: Vec, pub viewer_hint: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SymbolizationRecord { + pub status: CaptureStatus, + pub tool: String, + pub resolved: usize, + pub unresolved: usize, + pub notes: Vec, +} + fn default_profile_provider() -> ProfileProvider { ProfileProvider::Local } @@ -150,6 +161,29 @@ pub fn render_profile_markdown(manifest: &ProfileManifest) -> String { artifact.path.display() ); } + if let Some(symbolization) = &manifest.symbolization { + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## Symbolization"); + let _ = writeln!(markdown); + let _ = writeln!( + markdown, + "- Status: `{}`", + capture_status_label(symbolization.status) + ); + let _ = writeln!(markdown, "- Tool: `{}`", symbolization.tool); + let _ = writeln!(markdown, "- Resolved frames: `{}`", symbolization.resolved); + let _ = writeln!( + markdown, + "- Unresolved frames: `{}`", + symbolization.unresolved + ); + if !symbolization.notes.is_empty() { + let _ = writeln!(markdown, "- Notes:"); + for note in &symbolization.notes { + let _ = writeln!(markdown, " - {}", note); + } + } + } if !manifest.warnings.is_empty() { let _ = writeln!(markdown); let _ = writeln!(markdown, "## Warnings"); @@ -178,13 +212,33 @@ pub fn write_profile_manifest(path: &Path, manifest: &ProfileManifest) -> Result } pub fn cmd_profile_run(args: &ProfileRunArgs) -> Result<()> { + run_profile_session(args, execute_capture) +} + +fn run_profile_session(args: &ProfileRunArgs, execute: E) -> Result<()> +where + E: FnOnce(&ProfileRunArgs, &Path, &mut ProfileManifest) -> Result<()>, +{ validate_profile_request(args)?; std::fs::create_dir_all(&args.output_dir)?; let run_id = allocate_run_id(&args.output_dir, args.target, &args.function)?; let run_output_dir = args.output_dir.join(&run_id); std::fs::create_dir_all(&run_output_dir)?; - let manifest = build_capture_plan(args, &run_output_dir, &run_id)?; + let mut manifest = build_capture_plan(args, &run_output_dir, &run_id)?; + if let Err(error) = execute(args, &run_output_dir, &mut manifest) { + write_profile_artifacts(&run_output_dir, &args.output_dir, &manifest)?; + return Err(error); + } + write_profile_artifacts(&run_output_dir, &args.output_dir, &manifest)?; + Ok(()) +} + +fn write_profile_artifacts( + run_output_dir: &Path, + latest_output_dir: &Path, + manifest: &ProfileManifest, +) -> Result<()> { let rendered_summary = render_profile_markdown(&manifest); let run_profile_path = run_output_dir.join("profile.json"); @@ -192,8 +246,8 @@ pub fn cmd_profile_run(args: &ProfileRunArgs) -> Result<()> { write_profile_manifest(&run_profile_path, &manifest)?; std::fs::write(&run_summary_path, rendered_summary.as_bytes())?; - let latest_profile_path = args.output_dir.join("profile.json"); - let latest_summary_path = args.output_dir.join("summary.md"); + let latest_profile_path = latest_output_dir.join("profile.json"); + let latest_summary_path = latest_output_dir.join("summary.md"); write_profile_manifest(&latest_profile_path, &manifest)?; std::fs::write(&latest_summary_path, rendered_summary.as_bytes())?; @@ -338,6 +392,13 @@ fn build_capture_plan( capture_status: CaptureStatus::Planned, raw_artifacts, processed_artifacts, + symbolization: Some(SymbolizationRecord { + status: CaptureStatus::Planned, + tool: "llvm-addr2line".into(), + resolved: 0, + unresolved: 0, + notes: vec!["symbolization has not been attempted yet".into()], + }), warnings: vec![ "capture execution is not implemented yet; this session records the planned artifact contract only" .into(), @@ -346,6 +407,298 @@ fn build_capture_plan( }) } +trait CommandRunner { + fn output(&mut self, command: &mut Command) -> Result; +} + +struct RealCommandRunner; + +impl CommandRunner for RealCommandRunner { + fn output(&mut self, command: &mut Command) -> Result { + command.output().context("running external command") + } +} + +fn execute_capture( + args: &ProfileRunArgs, + output_root: &Path, + manifest: &mut ProfileManifest, +) -> Result<()> { + if args.provider != ProfileProvider::Local || manifest.backend != ProfileBackend::AndroidNative { + return Ok(()); + } + + let layout = crate::resolve_project_layout(crate::ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: args.crate_path.as_deref(), + config_path: args.config.as_deref(), + }) + .context("failed to resolve benchmark layout for android profile execution")?; + + let mut runner = RealCommandRunner; + execute_local_android_capture_with_runner( + args, + output_root, + manifest, + &layout, + |layout| { + let ndk_home = std::env::var("ANDROID_NDK_HOME") + .context("ANDROID_NDK_HOME is required for local Android profiling")?; + crate::run_android_build(layout, &ndk_home, true, false) + .context("building Android artifacts for local profiling") + }, + &mut runner, + ) +} + +fn execute_local_android_capture_with_runner( + args: &ProfileRunArgs, + output_root: &Path, + manifest: &mut ProfileManifest, + layout: &crate::ResolvedProjectLayout, + build_fn: F, + runner: &mut R, +) -> Result<()> +where + R: CommandRunner, + F: FnOnce(&crate::ResolvedProjectLayout) -> Result, +{ + let build = build_fn(layout)?; + + let raw_root = output_root.join("artifacts/raw"); + let processed_root = output_root.join("artifacts/processed"); + std::fs::create_dir_all(&raw_root)?; + std::fs::create_dir_all(&processed_root)?; + manifest + .warnings + .retain(|warning| !warning.contains("capture execution is not implemented yet")); + + let raw_perf_path = manifest + .raw_artifacts + .iter() + .find(|artifact| artifact.path.ends_with("sample.perf")) + .map(|artifact| artifact.path.clone()) + .context("local Android profile plan did not include sample.perf")?; + let folded_path = manifest + .processed_artifacts + .iter() + .find(|artifact| artifact.path.ends_with("stacks.folded")) + .map(|artifact| artifact.path.clone()) + .unwrap_or_else(|| processed_root.join("stacks.folded")); + let native_report_path = manifest + .processed_artifacts + .iter() + .find(|artifact| artifact.path.ends_with("native-report.txt")) + .map(|artifact| artifact.path.clone()) + .unwrap_or_else(|| processed_root.join("native-report.txt")); + let flamegraph_path = manifest + .processed_artifacts + .iter() + .find(|artifact| artifact.path.ends_with("flamegraph.html")) + .map(|artifact| artifact.path.clone()) + .unwrap_or_else(|| processed_root.join("flamegraph.html")); + + let package_name = "dev.world.bench"; + let activity_name = "dev.world.bench/.MainActivity"; + + let install_output = run_adb_command(runner, ["install", "-r"], &build.app_path, None)?; + if !install_output.status.success() { + let stderr = String::from_utf8_lossy(&install_output.stderr); + manifest.capture_status = CaptureStatus::Failed; + manifest.symbolization = Some(SymbolizationRecord { + status: CaptureStatus::Failed, + tool: "llvm-addr2line".into(), + resolved: 0, + unresolved: 0, + notes: vec![format!("adb install failed: {}", stderr.trim())], + }); + manifest.warnings.push(format!("adb install failed: {}", stderr.trim())); + bail!("adb install failed for {}: {}", build.app_path.display(), stderr.trim()); + } + + let mut record_command = Command::new("simpleperf"); + record_command + .arg("record") + .arg("--app") + .arg(package_name) + .arg("--call-graph") + .arg("fp") + .arg("--duration") + .arg("15") + .arg("--out") + .arg("/data/local/tmp/sample.perf") + .arg("--") + .arg("am") + .arg("start") + .arg("-n") + .arg(activity_name) + .arg("--es") + .arg("bench_function") + .arg(&args.function); + + let record_output = runner.output(&mut record_command)?; + if !record_output.status.success() { + let stderr = String::from_utf8_lossy(&record_output.stderr); + manifest.capture_status = CaptureStatus::Failed; + manifest.symbolization = Some(SymbolizationRecord { + status: CaptureStatus::Failed, + tool: "llvm-addr2line".into(), + resolved: 0, + unresolved: 0, + notes: vec![format!("simpleperf record failed: {}", stderr.trim())], + }); + manifest + .warnings + .push(format!("simpleperf record failed: {}", stderr.trim())); + bail!("simpleperf capture failed: {}", stderr.trim()); + } + + let mut pull_command = Command::new("adb"); + pull_command.args(["pull", "/data/local/tmp/sample.perf"]); + pull_command.arg(&raw_perf_path); + let pull_output = runner.output(&mut pull_command)?; + if !pull_output.status.success() { + let stderr = String::from_utf8_lossy(&pull_output.stderr); + manifest.capture_status = CaptureStatus::Failed; + manifest.symbolization = Some(SymbolizationRecord { + status: CaptureStatus::Failed, + tool: "llvm-addr2line".into(), + resolved: 0, + unresolved: 0, + notes: vec![format!("adb pull failed: {}", stderr.trim())], + }); + manifest.warnings.push(format!("adb pull failed: {}", stderr.trim())); + bail!("failed to pull simpleperf capture: {}", stderr.trim()); + } + + let mut report_command = Command::new("simpleperf"); + report_command.arg("report").arg("-i").arg(&raw_perf_path); + let report_output = runner.output(&mut report_command)?; + if !report_output.status.success() { + let stderr = String::from_utf8_lossy(&report_output.stderr); + manifest.capture_status = CaptureStatus::Failed; + manifest.symbolization = Some(SymbolizationRecord { + status: CaptureStatus::Failed, + tool: "llvm-addr2line".into(), + resolved: 0, + unresolved: 0, + notes: vec![format!("simpleperf report failed: {}", stderr.trim())], + }); + manifest + .warnings + .push(format!("simpleperf report failed: {}", stderr.trim())); + bail!("simpleperf report failed: {}", stderr.trim()); + } + + let report_text = String::from_utf8_lossy(&report_output.stdout).to_string(); + let addr2line_path = std::env::var("LLVM_ADDR2LINE") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("llvm-addr2line")); + let (symbolized_report, symbolization) = symbolize_android_capture_report( + &report_text, + &addr2line_path, + &build.native_libraries, + )?; + + std::fs::write(&native_report_path, symbolized_report.as_bytes())?; + std::fs::write(&folded_path, symbolized_report.as_bytes())?; + std::fs::write( + &flamegraph_path, + render_android_flamegraph_html(&symbolized_report, &manifest.run_id).as_bytes(), + )?; + + manifest.capture_status = if symbolization.unresolved == 0 { + CaptureStatus::Captured + } else { + CaptureStatus::Partial + }; + manifest + .warnings + .retain(|warning| !warning.contains("capture execution is not implemented yet")); + manifest.symbolization = Some(SymbolizationRecord { + status: manifest.capture_status, + tool: addr2line_path.display().to_string(), + resolved: symbolization.resolved, + unresolved: symbolization.unresolved, + notes: if symbolization.unresolved == 0 { + vec!["Android native profiling capture completed and symbols resolved".into()] + } else { + vec![ + "Android native profiling capture completed with unresolved native frames".into(), + ] + }, + }); + if symbolization.unresolved > 0 { + manifest.warnings.push(format!( + "Android native profiling completed with {} unresolved native frames", + symbolization.unresolved + )); + } + + Ok(()) +} + +fn run_adb_command( + runner: &mut R, + args: I, + artifact: &Path, + extra: Option<&str>, +) -> Result +where + R: CommandRunner, + I: IntoIterator, + S: AsRef, +{ + let mut command = Command::new("adb"); + command.args(args); + if let Some(extra) = extra { + command.arg(extra); + } + command.arg(artifact); + runner.output(&mut command) +} + +fn symbolize_android_capture_report( + report_text: &str, + addr2line_path: &Path, + libraries: &[mobench_sdk::NativeLibraryArtifact], +) -> Result<(String, AndroidSymbolizationTotals)> { + let mut output = String::new(); + let mut totals = AndroidSymbolizationTotals::default(); + + for line in report_text.lines() { + let (symbolized_line, stats) = + mobench_sdk::builders::android::symbolize_android_native_stack_line( + line, + addr2line_path, + libraries, + )?; + totals.resolved += stats.resolved; + totals.unresolved += stats.unresolved; + output.push_str(&symbolized_line); + output.push('\n'); + } + + Ok((output, totals)) +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct AndroidSymbolizationTotals { + resolved: usize, + unresolved: usize, +} + +fn render_android_flamegraph_html(report_text: &str, run_id: &str) -> String { + let escaped = report_text + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + format!( + "Android profile {run_id}

Android native profile {run_id}

Symbolized native report

{escaped}
" + ) +} + fn allocate_run_id(output_dir: &Path, target: MobileTarget, function: &str) -> Result { let prefix = build_run_id(target, function); let timestamp = SystemTime::now() @@ -497,6 +850,7 @@ fn slugify_function_name(function: &str) -> String { #[cfg(test)] mod tests { use super::*; + use std::process::{Command, Output}; #[test] fn profile_manifest_serializes_partial_failure_state() { @@ -534,6 +888,205 @@ mod tests { assert!(rendered.contains("Profile Summary")); } + #[test] + fn android_native_offsets_are_symbolized_into_rust_frames() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let addr2line_shim = temp_dir.path().join("llvm-addr2line"); + std::fs::write( + &addr2line_shim, + b"#!/bin/sh\nprintf 'sample_fns::fibonacci\\n/opt/sample_fns.rs:123\\n'\n", + ) + .expect("write addr2line shim"); + make_executable(&addr2line_shim); + + let native_lib = temp_dir.path().join("libsample_fns.so"); + std::fs::write(&native_lib, b"so").expect("write library"); + let artifact = mobench_sdk::NativeLibraryArtifact { + abi: "arm64-v8a".into(), + packaged_path: native_lib.clone(), + unstripped_path: native_lib, + }; + + let (rendered, totals) = symbolize_android_capture_report( + "libsample_fns.so[+0x1a2b]\n", + &addr2line_shim, + &[artifact], + ) + .expect("symbolize capture report"); + + assert!(rendered.contains("sample_fns::fibonacci")); + assert_eq!(totals.resolved, 1); + assert_eq!(totals.unresolved, 0); + } + + #[test] + fn local_android_capture_attempts_symbolization_and_updates_manifest_status() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let output_root = temp_dir.path().join("profile"); + let run_id = "android-ffi_benchmark--bench_fibonacci-123"; + let args = ProfileRunArgs { + target: MobileTarget::Android, + function: "ffi_benchmark::bench_fibonacci".into(), + crate_path: Some(workspace_root().join("examples/ffi-benchmark")), + config: None, + output_dir: output_root.clone(), + provider: ProfileProvider::Local, + backend: ProfileBackend::AndroidNative, + format: ProfileFormat::Both, + }; + let mut manifest = build_capture_plan( + &args, + &output_root.join(run_id), + run_id, + ) + .expect("build capture plan"); + + let layout = crate::resolve_project_layout(crate::ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: args.crate_path.as_deref(), + config_path: args.config.as_deref(), + }) + .expect("resolve layout"); + + let app_path = temp_dir.path().join("app.apk"); + std::fs::write(&app_path, b"apk").expect("write app artifact"); + let packaged_lib = temp_dir.path().join("libsample_fns.so"); + let unstripped_lib = temp_dir.path().join("libsample_fns.so.unstripped"); + std::fs::write(&packaged_lib, b"so").expect("write packaged lib"); + std::fs::write(&unstripped_lib, b"so").expect("write unstripped lib"); + let addr2line_shim = temp_dir.path().join("llvm-addr2line"); + std::fs::write( + &addr2line_shim, + b"#!/bin/sh\nprintf 'sample_fns::fibonacci\\n/opt/sample_fns.rs:123\\n'\n", + ) + .expect("write addr2line shim"); + make_executable(&addr2line_shim); + + unsafe { + std::env::set_var("LLVM_ADDR2LINE", &addr2line_shim); + } + + let build_result = mobench_sdk::BuildResult { + platform: mobench_sdk::Target::Android, + app_path: app_path.clone(), + test_suite_path: Some(temp_dir.path().join("androidTest.apk")), + native_libraries: vec![mobench_sdk::NativeLibraryArtifact { + abi: "arm64-v8a".into(), + packaged_path: packaged_lib.clone(), + unstripped_path: unstripped_lib.clone(), + }], + }; + + let mut runner = RecordingRunner { + outputs: vec![ + success_output(""), + success_output(""), + success_output(""), + success_output("libsample_fns.so[+0x1a2b]\n"), + ], + commands: Vec::new(), + }; + + execute_local_android_capture_with_runner( + &args, + &output_root.join(run_id), + &mut manifest, + &layout, + |_| Ok(build_result.clone()), + &mut runner, + ) + .expect("android capture attempt"); + + assert_eq!(manifest.capture_status, CaptureStatus::Captured); + let symbolization = manifest.symbolization.as_ref().expect("symbolization record"); + assert_eq!(symbolization.status, CaptureStatus::Captured); + assert_eq!(symbolization.resolved, 1); + assert_eq!(symbolization.unresolved, 0); + assert!( + manifest + .warnings + .iter() + .all(|warning| !warning.contains("capture execution is not implemented yet")) + ); + assert!(output_root.join(run_id).join("artifacts/raw/sample.perf").exists()); + assert!(output_root + .join(run_id) + .join("artifacts/processed/native-report.txt") + .exists()); + assert!(output_root + .join(run_id) + .join("artifacts/processed/stacks.folded") + .exists()); + assert!(output_root + .join(run_id) + .join("artifacts/processed/flamegraph.html") + .exists()); + assert!(runner + .commands + .iter() + .any(|command| command.contains("simpleperf record"))); + assert!(runner + .commands + .iter() + .any(|command| command.contains("simpleperf report"))); + + unsafe { + std::env::remove_var("LLVM_ADDR2LINE"); + } + } + + #[test] + fn profile_session_writes_failed_android_manifest_after_attempted_execution() { + let dir = tempfile::tempdir().expect("temp dir"); + let args = ProfileRunArgs { + target: MobileTarget::Android, + function: "ffi_benchmark::bench_fibonacci".into(), + crate_path: Some(workspace_root().join("examples/ffi-benchmark")), + config: None, + output_dir: dir.path().to_path_buf(), + provider: ProfileProvider::Local, + backend: ProfileBackend::AndroidNative, + format: ProfileFormat::Both, + }; + + let error = run_profile_session(&args, |_, _, manifest| { + manifest.capture_status = CaptureStatus::Failed; + manifest.symbolization = Some(SymbolizationRecord { + status: CaptureStatus::Failed, + tool: "llvm-addr2line".into(), + resolved: 0, + unresolved: 0, + notes: vec!["simulated Android capture failure".into()], + }); + anyhow::bail!("simulated Android capture failure"); + }) + .expect_err("simulated execution failure should bubble up"); + + assert!(error + .to_string() + .contains("simulated Android capture failure")); + + let run_dir = find_single_run_dir( + &dir.path().to_path_buf(), + "android-ffi_benchmark--bench_fibonacci", + ); + let manifest = load_profile_manifest(&run_dir.join("profile.json")) + .expect("load failed profile manifest"); + assert_eq!(manifest.capture_status, CaptureStatus::Failed); + assert_eq!( + manifest + .symbolization + .as_ref() + .expect("symbolization record") + .status, + CaptureStatus::Failed + ); + assert!(run_dir.join("summary.md").exists()); + assert!(dir.path().join("profile.json").exists()); + assert!(dir.path().join("summary.md").exists()); + } + #[test] fn android_backend_builds_capture_plan_with_flamegraph_artifacts() { let plan = build_capture_plan( @@ -675,7 +1228,7 @@ mod tests { config: None, output_dir: dir.path().to_path_buf(), provider: ProfileProvider::Local, - backend: ProfileBackend::AndroidNative, + backend: ProfileBackend::RustTracing, format: ProfileFormat::Both, }; let ios_args = ProfileRunArgs { @@ -751,7 +1304,7 @@ mod tests { config: None, output_dir: dir.path().to_path_buf(), provider: ProfileProvider::Local, - backend: ProfileBackend::AndroidNative, + backend: ProfileBackend::RustTracing, format: ProfileFormat::Both, }; @@ -844,8 +1397,70 @@ mod tests { label: "flamegraph".into(), path: PathBuf::from("artifacts/processed/flamegraph.html"), }], + symbolization: Some(SymbolizationRecord { + status: CaptureStatus::Partial, + tool: "llvm-addr2line".into(), + resolved: 1, + unresolved: 1, + notes: vec!["missing symbols".into()], + }), warnings: vec!["missing symbols".into()], viewer_hint: Some("Open flamegraph.html in a browser".into()), } } + + fn success_output(stdout: &str) -> Output { + let status = std::process::Command::new("sh") + .arg("-c") + .arg("true") + .status() + .expect("create success status"); + Output { + status, + stdout: stdout.as_bytes().to_vec(), + stderr: Vec::new(), + } + } + + fn make_executable(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path) + .expect("metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms).expect("set perms"); + } + } + + struct RecordingRunner { + outputs: Vec, + commands: Vec, + } + + impl CommandRunner for RecordingRunner { + fn output(&mut self, command: &mut Command) -> Result { + let program = command.get_program().to_string_lossy().to_string(); + let args: Vec = command + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); + let mut rendered = command.get_program().to_string_lossy().to_string(); + for arg in &args { + rendered.push(' '); + rendered.push_str(arg); + } + self.commands.push(rendered); + + if program == "adb" && args.first().is_some_and(|arg| arg == "pull") { + if let Some(destination) = args.last() { + std::fs::write(destination, b"simpleperf") + .expect("create pulled raw capture artifact"); + } + } + + Ok(self.outputs.remove(0)) + } + } } From 72d53f5cb11aa2e7b442e51d1d3032ffe5d55c57 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 18:44:26 -0700 Subject: [PATCH 135/196] Forward CI regression threshold to benchmark runs --- .github/workflows/reusable-bench.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 42cab7e..d6fd0cf 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -286,6 +286,7 @@ jobs: WARMUP: ${{ inputs.warmup }} IOS_DEVICE: ${{ steps.resolve_device.outputs.device_name }} IOS_OS_VERSION: ${{ steps.resolve_device.outputs.os_version }} + REGRESSION_THRESHOLD: ${{ inputs.regression_threshold_pct }} RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} CRATE_PATH: ${{ inputs.crate_path }} run: | @@ -302,6 +303,7 @@ jobs: --crate-path "$CRATE_PATH" \ $RELEASE_FLAG \ --fetch \ + --regression-threshold-pct "${REGRESSION_THRESHOLD}" \ --plots off \ --output-dir target/mobench/ci/ios @@ -504,6 +506,7 @@ jobs: WARMUP: ${{ inputs.warmup }} ANDROID_DEVICE: ${{ steps.resolve_device.outputs.device_name }} ANDROID_OS_VERSION: ${{ steps.resolve_device.outputs.os_version }} + REGRESSION_THRESHOLD: ${{ inputs.regression_threshold_pct }} RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} CRATE_PATH: ${{ inputs.crate_path }} run: | @@ -520,6 +523,7 @@ jobs: --crate-path "$CRATE_PATH" \ $RELEASE_FLAG \ --fetch \ + --regression-threshold-pct "${REGRESSION_THRESHOLD}" \ --plots off \ --output-dir target/mobench/ci/android From 48eeba8942b401e73989536950151a10db0d8118 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 18:51:11 -0700 Subject: [PATCH 136/196] fix: persist attempted android profile failures --- crates/mobench/src/lib.rs | 61 ++-- crates/mobench/src/profile.rs | 518 ++++++++++++++++++++++++++++++++-- 2 files changed, 525 insertions(+), 54 deletions(-) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index c58bf57..8037b9f 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -972,16 +972,16 @@ struct DeviceMatrix { } #[derive(Debug, Serialize, Deserialize, Clone)] -struct RunSpec { - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, - devices: Vec, +pub(crate) struct RunSpec { + pub(crate) target: MobileTarget, + pub(crate) function: String, + pub(crate) iterations: u32, + pub(crate) warmup: u32, + pub(crate) devices: Vec, #[serde(skip_serializing, skip_deserializing, default)] - browserstack: Option, + pub(crate) browserstack: Option, #[serde(skip_serializing_if = "Option::is_none", default)] - ios_xcuitest: Option, + pub(crate) ios_xcuitest: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -1014,22 +1014,22 @@ struct RunSummary { } #[derive(Debug, Clone)] -struct ResolvedProjectLayout { - project_root: PathBuf, - crate_dir: PathBuf, - crate_name: String, - library_name: String, - config_path: Option, - output_dir: PathBuf, - default_function: Option, +pub(crate) struct ResolvedProjectLayout { + pub(crate) project_root: PathBuf, + pub(crate) crate_dir: PathBuf, + pub(crate) crate_name: String, + pub(crate) library_name: String, + pub(crate) config_path: Option, + pub(crate) output_dir: PathBuf, + pub(crate) default_function: Option, } #[derive(Debug, Clone, Copy)] -struct ProjectLayoutOptions<'a> { - start_dir: Option<&'a Path>, - project_root: Option<&'a Path>, - crate_path: Option<&'a Path>, - config_path: Option<&'a Path>, +pub(crate) struct ProjectLayoutOptions<'a> { + pub(crate) start_dir: Option<&'a Path>, + pub(crate) project_root: Option<&'a Path>, + pub(crate) crate_path: Option<&'a Path>, + pub(crate) config_path: Option<&'a Path>, } #[derive(Debug, Deserialize)] @@ -1335,7 +1335,7 @@ pub fn run() -> Result<()> { println!("[3/4] Uploading to BrowserStack..."); } let test_apk = build.test_suite_path.as_ref().context( - "Android test suite APK missing. Run `cargo mobench build --target android` or `./gradlew assembleDebugAndroidTest` in target/mobench/android", + "Android test suite APK missing. Run `cargo mobench build --target android` or `./gradlew app:assembleReleaseAndroidTest` in target/mobench/android", )?; let run = trigger_browserstack_espresso(&spec, &apk, test_apk)?; remote_run = Some(run); @@ -2053,7 +2053,9 @@ fn resolve_legacy_crate_dir(project_root: &Path) -> Result { ) } -fn resolve_project_layout(options: ProjectLayoutOptions<'_>) -> Result { +pub(crate) fn resolve_project_layout( + options: ProjectLayoutOptions<'_>, +) -> Result { let start_dir = match options.start_dir { Some(path) => canonicalize_from(Path::new("."), path)?, None => std::env::current_dir().context("Failed to get current directory")?, @@ -3499,7 +3501,7 @@ fn filter_devices_by_tags(devices: Vec, tags: &[String]) -> Result< Ok(matched) } -fn run_ios_build( +pub(crate) fn run_ios_build( layout: &ResolvedProjectLayout, release: bool, dry_run: bool, @@ -3952,7 +3954,10 @@ fn run_local_smoke(spec: &RunSpec) -> Result { /// /// This provides early feedback when a function name is misspelled or doesn't exist. /// If validation fails, it warns but continues (the final validation happens on device). -fn validate_benchmark_function(layout: &ResolvedProjectLayout, function_name: &str) -> Result<()> { +pub(crate) fn validate_benchmark_function( + layout: &ResolvedProjectLayout, + function_name: &str, +) -> Result<()> { let benchmarks = discover_benchmarks_for_layout(layout)?; let found_any_benchmarks = !benchmarks.is_empty(); let simple_name = function_name.split("::").last().unwrap_or(function_name); @@ -3995,7 +4000,7 @@ fn validate_benchmark_function(layout: &ResolvedProjectLayout, function_name: &s Ok(()) } -fn persist_mobile_spec( +pub(crate) fn persist_mobile_spec( layout: &ResolvedProjectLayout, spec: &RunSpec, release: bool, @@ -5341,7 +5346,7 @@ fn format_ms(value: Option) -> String { .unwrap_or_else(|| "-".to_string()) } -fn run_android_build( +pub(crate) fn run_android_build( layout: &ResolvedProjectLayout, _ndk_home: &str, release: bool, @@ -5404,7 +5409,7 @@ fn load_dotenv_global() { } } -fn load_dotenv_for_layout(layout: &ResolvedProjectLayout) { +pub(crate) fn load_dotenv_for_layout(layout: &ResolvedProjectLayout) { let mut directories = vec![layout.project_root.clone()]; if let Some(config_path) = &layout.config_path && let Some(config_dir) = config_path.parent() diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index c7708ef..680b70b 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -1,12 +1,17 @@ -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt::Write; use std::io::Cursor; use std::path::{Path, PathBuf}; +use std::process::Command; -use crate::{resolve_devices_for_profile, DevicePlatform, MobileTarget, ResolvedMatrixDevice}; +use crate::{ + load_dotenv_for_layout, persist_mobile_spec, resolve_devices_for_profile, + resolve_project_layout, run_android_build, validate_benchmark_function, DevicePlatform, + MobileTarget, ProjectLayoutOptions, ResolvedMatrixDevice, RunSpec, +}; use mobench_sdk::types::NativeLibraryArtifact; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] @@ -472,19 +477,51 @@ pub fn write_profile_manifest(path: &Path, manifest: &ProfileManifest) -> Result } pub fn cmd_profile_run(args: &ProfileRunArgs, dry_run: bool) -> Result<()> { + run_profile_session_with_executor(args, dry_run, execute_capture) +} + +fn run_profile_session_with_executor(args: &ProfileRunArgs, dry_run: bool, execute: E) -> Result<()> +where + E: FnOnce(&ProfileRunArgs, &ResolvedProfileTarget, &mut ProfileManifest) -> Result<()>, +{ let target = resolve_profile_target(args)?; let run_id = build_run_id(args.target, &args.function); let run_output_dir = args.output_dir.join(&run_id); let mut manifest = build_capture_plan(args, &target, &run_output_dir)?; - if dry_run { + let execution_result = if dry_run { manifest.capture_metadata.warnings.push( "dry-run enabled; capture planning stopped before execution and recorded the planned artifact contract only" .into(), ); + Ok(()) } else { - execute_capture(args, &target, &mut manifest)?; - } + execute(args, &target, &mut manifest) + }; + write_profile_session_outputs(args, &run_output_dir, &manifest)?; + execution_result?; + + println!("Profile session written to {}", run_output_dir.join("profile.json").display()); + println!( + "Profile summary written to {}", + run_output_dir.join("summary.md").display() + ); + println!( + "Latest profile manifest refreshed at {}", + args.output_dir.join("profile.json").display() + ); + println!( + "Latest profile summary refreshed at {}", + args.output_dir.join("summary.md").display() + ); + Ok(()) +} + +fn write_profile_session_outputs( + args: &ProfileRunArgs, + run_output_dir: &Path, + manifest: &ProfileManifest, +) -> Result<()> { std::fs::create_dir_all(&args.output_dir)?; std::fs::create_dir_all(&run_output_dir)?; create_selected_artifact_roots( @@ -502,17 +539,6 @@ pub fn cmd_profile_run(args: &ProfileRunArgs, dry_run: bool) -> Result<()> { let latest_summary_path = args.output_dir.join("summary.md"); write_profile_manifest(&latest_profile_path, &manifest)?; std::fs::write(&latest_summary_path, rendered_summary.as_bytes())?; - - println!("Profile session written to {}", run_profile_path.display()); - println!("Profile summary written to {}", run_summary_path.display()); - println!( - "Latest profile manifest refreshed at {}", - latest_profile_path.display() - ); - println!( - "Latest profile summary refreshed at {}", - latest_summary_path.display() - ); Ok(()) } @@ -688,6 +714,323 @@ fn write_android_flamegraph_html(folded_stacks: &str, output_path: &Path) -> Res Ok(()) } +const DEFAULT_PROFILE_ITERATIONS: u32 = 20; +const DEFAULT_PROFILE_WARMUP: u32 = 3; +const DEFAULT_ANDROID_CAPTURE_DURATION_SECS: u64 = 10; + +#[derive(Debug, Clone)] +struct AndroidProfilerToolchain { + sdk_root: PathBuf, + adb_path: PathBuf, + app_profiler_path: PathBuf, + stackcollapse_path: PathBuf, + python_path: PathBuf, +} + +fn locate_android_profiler_toolchain() -> Result { + let sdk_root = std::env::var_os("ANDROID_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("ANDROID_SDK_ROOT").map(PathBuf::from)) + .or_else(|| { + std::env::var_os("ANDROID_NDK_HOME") + .map(PathBuf::from) + .and_then(|ndk_home| ndk_home.parent().and_then(Path::parent).map(PathBuf::from)) + }) + .or_else(|| { + std::env::var_os("HOME").map(PathBuf::from).map(|home| { + home.join("Library") + .join("Android") + .join("sdk") + }) + }) + .filter(|path| path.exists()) + .context("Android SDK not found; set ANDROID_HOME or ANDROID_SDK_ROOT")?; + + let ndk_root = std::env::var_os("ANDROID_NDK_HOME") + .map(PathBuf::from) + .filter(|path| path.exists()) + .or_else(|| { + let ndk_dir = sdk_root.join("ndk"); + std::fs::read_dir(&ndk_dir) + .ok() + .and_then(|entries| { + entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .max() + }) + }) + .context("Android NDK not found; set ANDROID_NDK_HOME or install an NDK under the SDK")?; + + let adb_path = sdk_root.join("platform-tools").join("adb"); + let app_profiler_path = ndk_root.join("simpleperf").join("app_profiler.py"); + let stackcollapse_path = ndk_root.join("simpleperf").join("stackcollapse.py"); + let python_path = std::env::var_os("PYTHON") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("python3")); + + for path in [&adb_path, &app_profiler_path, &stackcollapse_path] { + if !path.exists() { + bail!("required Android profiling tool not found at {}", path.display()); + } + } + + Ok(AndroidProfilerToolchain { + sdk_root, + adb_path, + app_profiler_path, + stackcollapse_path, + python_path, + }) +} + +fn prepend_path_env(toolchain: &AndroidProfilerToolchain) -> Option { + let mut entries = vec![toolchain.sdk_root.join("platform-tools").into_os_string()]; + if let Some(existing) = std::env::var_os("PATH") { + entries.push(existing); + } + std::env::join_paths(entries).ok() +} + +fn ensure_android_device_connected(toolchain: &AndroidProfilerToolchain) -> Result<()> { + let output = Command::new(&toolchain.adb_path) + .arg("devices") + .output() + .context("failed to run `adb devices`")?; + if !output.status.success() { + bail!( + "adb devices failed with status {}", + output.status + ); + } + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout + .lines() + .skip(1) + .any(|line| line.split_whitespace().nth(1) == Some("device")) + { + return Ok(()); + } + + let avd_hint = sdk_root_emulator_hint(&toolchain.sdk_root) + .unwrap_or_else(|| "start an Android emulator or connect a device over adb".into()); + bail!("no Android device is connected via adb; {avd_hint}"); +} + +fn sdk_root_emulator_hint(sdk_root: &Path) -> Option { + let emulator_path = sdk_root.join("emulator").join("emulator"); + if !emulator_path.exists() { + return None; + } + let output = Command::new(&emulator_path) + .arg("-list-avds") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let avd = String::from_utf8_lossy(&output.stdout) + .lines() + .find(|line| !line.trim().is_empty())? + .trim() + .to_string(); + Some(format!( + "start one with `{}` -avd {}`", + emulator_path.display(), + avd + )) +} + +fn read_android_application_id(android_root: &Path) -> Result { + let build_gradle = android_root.join("app").join("build.gradle"); + let contents = std::fs::read_to_string(&build_gradle) + .with_context(|| format!("reading {}", build_gradle.display()))?; + for line in contents.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix("applicationId ") { + return extract_quoted_value(value).with_context(|| { + format!( + "parsing applicationId from {}", + build_gradle.display() + ) + }); + } + } + bail!( + "applicationId not found in {}", + build_gradle.display() + ) +} + +fn extract_quoted_value(source: &str) -> Result { + let start = source.find('"').context("missing opening quote")? + 1; + let end = source[start..] + .find('"') + .map(|index| start + index) + .context("missing closing quote")?; + Ok(source[start..end].to_string()) +} + +fn run_android_stackcollapse( + toolchain: &AndroidProfilerToolchain, + perf_data_path: &Path, + working_dir: &Path, +) -> Result { + let mut command = Command::new(&toolchain.python_path); + command + .arg(&toolchain.stackcollapse_path) + .arg("-i") + .arg(perf_data_path) + .current_dir(working_dir); + if let Some(path_env) = prepend_path_env(toolchain) { + command.env("PATH", path_env); + } + let output = command + .output() + .with_context(|| format!("running {}", toolchain.stackcollapse_path.display()))?; + if !output.status.success() { + bail!( + "stackcollapse.py failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn execute_local_android_capture( + args: &ProfileRunArgs, + manifest: &mut ProfileManifest, +) -> Result<()> { + let toolchain = locate_android_profiler_toolchain()?; + ensure_android_device_connected(&toolchain)?; + + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: args.crate_path.as_deref(), + config_path: args.config.as_deref(), + })?; + load_dotenv_for_layout(&layout); + validate_benchmark_function(&layout, &args.function)?; + + let spec = RunSpec { + target: MobileTarget::Android, + function: args.function.clone(), + iterations: DEFAULT_PROFILE_ITERATIONS, + warmup: DEFAULT_PROFILE_WARMUP, + devices: Vec::new(), + browserstack: None, + ios_xcuitest: None, + }; + persist_mobile_spec(&layout, &spec, false)?; + + let build = run_android_build(&layout, "", false, false)?; + let android_root = layout.output_dir.join("android"); + let package_name = read_android_application_id(&android_root)?; + + let raw_perf_path = manifest + .native_capture + .raw_artifacts + .iter() + .find(|artifact| artifact.label == "simpleperf") + .map(|artifact| artifact.path.clone()) + .context("android profile plan missing simpleperf artifact")?; + let processed_root = manifest + .native_capture + .processed_artifacts + .iter() + .find_map(|artifact| artifact.path.parent().map(Path::to_path_buf)) + .context("android profile plan missing processed artifact root")?; + if let Some(parent) = raw_perf_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::create_dir_all(&processed_root)?; + + let mut install = Command::new(&toolchain.adb_path); + install.arg("install").arg("-r").arg(&build.app_path); + if let Some(path_env) = prepend_path_env(&toolchain) { + install.env("PATH", path_env.clone()); + } + let install_output = install + .output() + .with_context(|| format!("installing {}", build.app_path.display()))?; + if !install_output.status.success() { + bail!( + "adb install failed with status {}\nstdout:\n{}\nstderr:\n{}", + install_output.status, + String::from_utf8_lossy(&install_output.stdout), + String::from_utf8_lossy(&install_output.stderr) + ); + } + + let mut profiler = Command::new(&toolchain.python_path); + profiler + .arg(&toolchain.app_profiler_path) + .arg("-p") + .arg(&package_name) + .arg("-a") + .arg(".MainActivity") + .arg("-o") + .arg(&raw_perf_path) + .arg("-r") + .arg(format!( + "-e task-clock:u -f 1000 -g --duration {}", + DEFAULT_ANDROID_CAPTURE_DURATION_SECS + )) + .current_dir( + raw_perf_path + .parent() + .context("simpleperf artifact path missing parent directory")?, + ); + if let Some(path_env) = prepend_path_env(&toolchain) { + profiler.env("PATH", path_env); + } + let profiler_output = profiler.output().with_context(|| { + format!( + "running Android profiler script {}", + toolchain.app_profiler_path.display() + ) + })?; + if !profiler_output.status.success() { + bail!( + "app_profiler.py failed with status {}\nstdout:\n{}\nstderr:\n{}", + profiler_output.status, + String::from_utf8_lossy(&profiler_output.stdout), + String::from_utf8_lossy(&profiler_output.stderr) + ); + } + + let folded_stacks = run_android_stackcollapse( + &toolchain, + &raw_perf_path, + raw_perf_path + .parent() + .context("simpleperf artifact path missing parent directory")?, + )?; + let symbolization = write_android_symbolized_outputs( + &folded_stacks, + &build.native_libraries, + &processed_root, + )?; + + manifest.native_capture.symbolization = symbolization.clone(); + manifest.native_capture.status = match symbolization.status { + CaptureStatus::Planned | CaptureStatus::Captured => CaptureStatus::Captured, + CaptureStatus::Partial | CaptureStatus::Failed => CaptureStatus::Partial, + }; + manifest.capture_metadata.sample_duration_secs = Some(DEFAULT_ANDROID_CAPTURE_DURATION_SECS); + manifest.capture_metadata.capture_method = Some("simpleperf/app_profiler.py".into()); + manifest.capture_metadata.warnings.push(format!( + "android profile run used default benchmark settings: iterations={}, warmup={}", + DEFAULT_PROFILE_ITERATIONS, DEFAULT_PROFILE_WARMUP + )); + + Ok(()) +} + fn load_profile_manifest(path: &Path) -> Result { let body = std::fs::read_to_string(path)?; Ok(serde_json::from_str(&body)?) @@ -920,10 +1263,17 @@ fn execute_capture( target: &ResolvedProfileTarget, manifest: &mut ProfileManifest, ) -> Result<()> { + if let Some(device) = &target.device { + manifest.capture_metadata.warnings.push(format!( + "resolved target device: {} ({}, source: {})", + device.identifier, device.os, device.source + )); + } + let plan_only_warning = match (args.provider, target.backend) { - (ProfileProvider::Local, ProfileBackend::AndroidNative) => Some( - "local android-native capture is not implemented yet; this session records the planned simpleperf artifact contract only", - ), + (ProfileProvider::Local, ProfileBackend::AndroidNative) => { + return execute_capture_with_local_android_executor(args, manifest, execute_local_android_capture); + } (ProfileProvider::Local, ProfileBackend::IosInstruments) => Some( "local ios-instruments capture is not implemented yet; this session records the planned Instruments trace/XML artifact contract only", ), @@ -950,18 +1300,61 @@ fn execute_capture( (_, ProfileBackend::Auto) => unreachable!("auto backend should resolve before execution"), }; - if let Some(device) = &target.device { - manifest.capture_metadata.warnings.push(format!( - "resolved target device: {} ({}, source: {})", - device.identifier, device.os, device.source - )); - } if let Some(warning) = plan_only_warning { manifest.capture_metadata.warnings.push(warning.into()); } Ok(()) } +fn execute_capture_with_local_android_executor( + args: &ProfileRunArgs, + manifest: &mut ProfileManifest, + execute: E, +) -> Result<()> +where + E: FnOnce(&ProfileRunArgs, &mut ProfileManifest) -> Result<()>, +{ + if let Err(error) = execute(args, manifest) { + mark_android_capture_attempt_failed(manifest, &error); + return Err(error); + } + Ok(()) +} + +fn mark_android_capture_attempt_failed( + manifest: &mut ProfileManifest, + error: &anyhow::Error, +) { + manifest.native_capture.status = CaptureStatus::Failed; + manifest.native_capture.symbolization.status = CaptureStatus::Failed; + if manifest.native_capture.symbolization.tool.is_none() { + manifest.native_capture.symbolization.tool = Some("llvm-addr2line".into()); + } + + let failure_note = format!("local android-native capture failed: {error}"); + if !manifest + .native_capture + .symbolization + .notes + .iter() + .any(|note| note == &failure_note) + { + manifest + .native_capture + .symbolization + .notes + .push(failure_note.clone()); + } + if !manifest + .capture_metadata + .warnings + .iter() + .any(|warning| warning == &failure_note) + { + manifest.capture_metadata.warnings.push(failure_note); + } +} + fn browserstack_native_capture_unsupported_message( backend_label: &str, artifact_guidance: &str, @@ -1282,6 +1675,79 @@ mod tests { assert_eq!(record.unresolved_frames, 0); } + #[test] + fn local_android_attempted_capture_marks_failed_state() { + let args = sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ); + let target = resolve_profile_target(&args).expect("resolve target"); + let mut manifest = build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile")) + .expect("build capture plan"); + + let error = execute_capture_with_local_android_executor(&args, &mut manifest, |_args, _manifest| { + anyhow::bail!("simulated android capture failure") + }) + .expect_err("simulated capture failure"); + + assert!(error.to_string().contains("simulated android capture failure")); + assert_eq!(manifest.native_capture.status, CaptureStatus::Failed); + assert_eq!(manifest.native_capture.symbolization.status, CaptureStatus::Failed); + assert_eq!( + manifest.native_capture.symbolization.tool.as_deref(), + Some("llvm-addr2line") + ); + assert!( + manifest + .capture_metadata + .warnings + .iter() + .any(|warning| warning.contains("simulated android capture failure")) + ); + } + + #[test] + fn profile_session_writes_failed_android_manifest_after_attempted_execution() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut args = sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ); + args.output_dir = dir.path().to_path_buf(); + + let error = run_profile_session_with_executor(&args, false, |args, _target, manifest| { + execute_capture_with_local_android_executor(args, manifest, |_args, _manifest| { + anyhow::bail!("simulated android capture failure") + }) + }) + .expect_err("simulated execution failure should bubble up"); + + assert!(error.to_string().contains("simulated android capture failure")); + + let run_dir = dir + .path() + .join(build_run_id(args.target, &args.function)); + let manifest = load_profile_manifest(&run_dir.join("profile.json")) + .expect("load failed profile manifest"); + + assert_eq!(manifest.native_capture.status, CaptureStatus::Failed); + assert_eq!(manifest.native_capture.symbolization.status, CaptureStatus::Failed); + assert!( + manifest + .capture_metadata + .warnings + .iter() + .any(|warning| warning.contains("simulated android capture failure")) + ); + assert!(run_dir.join("summary.md").exists()); + assert!(dir.path().join("profile.json").exists()); + assert!(dir.path().join("summary.md").exists()); + } + #[test] fn android_backend_builds_capture_plan_with_flamegraph_artifacts() { let plan = build_capture_plan( @@ -1486,7 +1952,7 @@ mod tests { ios_args.function = "sample_fns::checksum".into(); ios_args.output_dir = dir.path().to_path_buf(); - cmd_profile_run(&android_args, false).expect("write first planned profile session"); + cmd_profile_run(&android_args, true).expect("write first planned profile session"); cmd_profile_run(&ios_args, false).expect("write second planned profile session"); let android_run_dir = dir.path().join("android-sample_fns--fibonacci"); From 0db7de02cdfa06fbf7ff43e4eed0321e2b6e4653 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 19:04:37 -0700 Subject: [PATCH 137/196] fix: tighten android profiling quality --- crates/mobench-sdk/src/builders/android.rs | 83 +++- crates/mobench/src/lib.rs | 16 +- crates/mobench/src/profile.rs | 451 +++++++++++++++------ 3 files changed, 409 insertions(+), 141 deletions(-) diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index d06b30e..3b23a23 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -1310,7 +1310,8 @@ pub fn resolve_android_native_symbol_with_addr2line( library_path: &Path, offset: u64, ) -> Option { - resolve_android_native_symbol_with_tool(Path::new("llvm-addr2line"), library_path, offset) + let tool_path = locate_android_addr2line_tool_path()?; + resolve_android_native_symbol_with_tool(&tool_path, library_path, offset) } pub fn resolve_android_native_symbol_with_tool( @@ -1337,14 +1338,71 @@ fn parse_android_addr2line_stdout(stdout: &str) -> Option { if symbol.is_empty() || symbol == "??" || symbol.starts_with("?? ") { None } else { - Some(symbol.split(" at ").next().unwrap_or(symbol).trim().to_owned()) + Some( + symbol + .split(" at ") + .next() + .unwrap_or(symbol) + .trim() + .to_owned(), + ) } }) } +fn locate_android_addr2line_tool_path() -> Option { + let override_path = std::env::var_os("MOBENCH_ANDROID_LLVM_ADDR2LINE") + .or_else(|| std::env::var_os("LLVM_ADDR2LINE")) + .map(PathBuf::from); + if let Some(path) = override_path { + return path.exists().then_some(path); + } + + let sdk_root = std::env::var_os("ANDROID_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("ANDROID_SDK_ROOT").map(PathBuf::from)) + .or_else(|| { + std::env::var_os("ANDROID_NDK_HOME") + .map(PathBuf::from) + .and_then(|ndk_home| ndk_home.parent().and_then(Path::parent).map(PathBuf::from)) + })?; + let ndk_root = std::env::var_os("ANDROID_NDK_HOME") + .map(PathBuf::from) + .or_else(|| { + let ndk_dir = sdk_root.join("ndk"); + std::fs::read_dir(&ndk_dir).ok().and_then(|entries| { + entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .max() + }) + })?; + + let tool_name = if cfg!(windows) { + "llvm-addr2line.exe" + } else { + "llvm-addr2line" + }; + let prebuilt_root = ndk_root.join("toolchains").join("llvm").join("prebuilt"); + let mut candidates = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&prebuilt_root) { + for entry in entries.flatten() { + let candidate = entry.path().join("bin").join(tool_name); + if candidate.exists() { + candidates.push(candidate); + } + } + } + candidates.sort(); + candidates.into_iter().next() +} + fn split_folded_stack_line(line: &str) -> (&str, Option<&str>) { match line.rsplit_once(' ') { - Some((stack, count)) if !stack.is_empty() && count.chars().all(|ch| ch.is_ascii_digit()) => { + Some((stack, count)) + if !stack.is_empty() && count.chars().all(|ch| ch.is_ascii_digit()) => + { (stack, Some(count)) } _ => (line, None), @@ -1428,18 +1486,15 @@ mod tests { #[test] fn android_native_offsets_are_symbolized_into_rust_frames() { - let input = - "dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1"; - let output = symbolize_android_native_stack_line_with_resolver( - input, - |library_name, offset| { + let input = "dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1"; + let output = + symbolize_android_native_stack_line_with_resolver(input, |library_name, offset| { if library_name == "libsample_fns.so" && offset == 94_138 { Some("sample_fns::fibonacci".into()) } else { None } - }, - ); + }); assert!( output.line.contains("sample_fns::fibonacci"), @@ -1508,16 +1563,14 @@ mod tests { #[test] fn android_native_offsets_preserve_unresolved_frames() { let input = "dev.world.samplefns;libsample_fns.so[+94138];libother.so[+17] 1"; - let output = symbolize_android_native_stack_line_with_resolver( - input, - |library_name, offset| { + let output = + symbolize_android_native_stack_line_with_resolver(input, |library_name, offset| { if library_name == "libsample_fns.so" && offset == 94_138 { Some("sample_fns::fibonacci".into()) } else { None } - }, - ); + }); assert!(output.line.contains("sample_fns::fibonacci")); assert!(output.line.contains("libother.so[+17]")); diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 8037b9f..e823058 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -662,7 +662,9 @@ enum ReportCommand { #[derive(Subcommand, Debug)] enum ProfileCommand { - /// Plan or execute a native profiling session depending on backend/provider support. + #[command( + about = "Plan or execute a native profiling session; local android-native now performs real simpleperf capture" + )] Run(profile::ProfileRunArgs), /// Render markdown or JSON from a normalized profile manifest. Summarize(profile::ProfileSummarizeArgs), @@ -8708,14 +8710,14 @@ project = "proj" let help = render_profile_run_help(); assert!( - help.contains("plan") - || help.contains("Plan") - || help.contains("depending on backend/provider support"), - "expected profile run help to explain whether it plans or executes capture, got:\n{help}" + help.contains("Plan or execute a native profiling session; local android-native now performs real simpleperf capture"), + "expected profile run help to describe the real local android-native execution scope, got:\n{help}" ); assert!( - help.contains("BrowserStack") || help.contains("browserstack"), - "expected profile run help to mention BrowserStack capability scope, got:\n{help}" + help.contains( + "local + android-native: attempts real simpleperf capture and symbolization" + ), + "expected profile run help to mention real Android native execution, got:\n{help}" ); } diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 680b70b..7134580 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -1,16 +1,15 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::fmt::Write; use std::io::Cursor; use std::path::{Path, PathBuf}; use std::process::Command; use crate::{ + DevicePlatform, MobileTarget, ProjectLayoutOptions, ResolvedMatrixDevice, RunSpec, load_dotenv_for_layout, persist_mobile_spec, resolve_devices_for_profile, - resolve_project_layout, run_android_build, validate_benchmark_function, DevicePlatform, - MobileTarget, ProjectLayoutOptions, ResolvedMatrixDevice, RunSpec, + resolve_project_layout, run_android_build, validate_benchmark_function, }; use mobench_sdk::types::NativeLibraryArtifact; @@ -47,10 +46,10 @@ pub enum ProfileSummaryFormat { #[derive(Debug, Clone, Args)] #[command( - about = "Plan or execute a native profiling session depending on backend/provider support", + about = "Plan or execute a native profiling session; local android-native now performs real simpleperf capture", after_help = concat!( "Capability matrix:\n", - " local + android-native: planned manifest today; native simpleperf capture is not implemented yet\n", + " local + android-native: attempts real simpleperf capture and symbolization\n", " local + ios-instruments: planned manifest today; Instruments trace export capture is not implemented yet\n", " local + rust-tracing: planned manifest today; structured trace output is local-only\n", " browserstack + android-native: unsupported for native capture in this release\n", @@ -480,7 +479,11 @@ pub fn cmd_profile_run(args: &ProfileRunArgs, dry_run: bool) -> Result<()> { run_profile_session_with_executor(args, dry_run, execute_capture) } -fn run_profile_session_with_executor(args: &ProfileRunArgs, dry_run: bool, execute: E) -> Result<()> +fn run_profile_session_with_executor( + args: &ProfileRunArgs, + dry_run: bool, + execute: E, +) -> Result<()> where E: FnOnce(&ProfileRunArgs, &ResolvedProfileTarget, &mut ProfileManifest) -> Result<()>, { @@ -501,7 +504,10 @@ where write_profile_session_outputs(args, &run_output_dir, &manifest)?; execution_result?; - println!("Profile session written to {}", run_output_dir.join("profile.json").display()); + println!( + "Profile session written to {}", + run_output_dir.join("profile.json").display() + ); println!( "Profile summary written to {}", run_output_dir.join("summary.md").display() @@ -628,39 +634,63 @@ where pub(crate) fn symbolize_android_folded_stacks_with_native_libraries( folded_stacks: &str, native_libraries: &[NativeLibraryArtifact], + runtime_abi: Option<&str>, mut resolve: F, ) -> (String, SymbolizationRecord, String) where F: FnMut(&Path, u64) -> Option, { - let library_paths: HashMap = native_libraries - .iter() - .map(|artifact| { - ( - artifact.library_name.clone(), - artifact.unstripped_path.clone(), - ) - }) - .collect(); + let runtime_abi = runtime_abi.map(str::to_owned); symbolize_android_folded_stacks_with_resolver(folded_stacks, |library_name, offset| { - let library_path = library_paths.get(library_name)?; - resolve(library_path.as_path(), offset) + let library_path = resolve_android_native_library_path( + native_libraries, + library_name, + runtime_abi.as_deref(), + )?; + resolve(library_path, offset) }) } +fn resolve_android_native_library_path<'a>( + native_libraries: &'a [NativeLibraryArtifact], + library_name: &str, + runtime_abi: Option<&str>, +) -> Option<&'a Path> { + match runtime_abi { + Some(runtime_abi) => native_libraries + .iter() + .find(|artifact| artifact.library_name == library_name && artifact.abi == runtime_abi) + .map(|artifact| artifact.unstripped_path.as_path()), + None => { + let mut matching = native_libraries + .iter() + .filter(|artifact| artifact.library_name == library_name); + let artifact = matching.next()?; + if matching.next().is_some() { + return None; + } + Some(artifact.unstripped_path.as_path()) + } + } +} + #[allow(dead_code)] pub(crate) fn write_android_symbolized_outputs( folded_stacks: &str, native_libraries: &[NativeLibraryArtifact], processed_root: &Path, + runtime_abi: Option<&str>, + llvm_addr2line_path: &Path, ) -> Result { write_android_symbolized_outputs_with_resolver( folded_stacks, native_libraries, processed_root, + runtime_abi, |library_path, offset| { - mobench_sdk::builders::android::resolve_android_native_symbol_with_addr2line( + mobench_sdk::builders::android::resolve_android_native_symbol_with_tool( + llvm_addr2line_path, library_path, offset, ) @@ -672,6 +702,7 @@ pub(crate) fn write_android_symbolized_outputs_with_resolver( folded_stacks: &str, native_libraries: &[NativeLibraryArtifact], processed_root: &Path, + runtime_abi: Option<&str>, resolve: F, ) -> Result where @@ -679,12 +710,12 @@ where { std::fs::create_dir_all(processed_root)?; - let (symbolized_stacks, record, report) = - symbolize_android_folded_stacks_with_native_libraries( - folded_stacks, - native_libraries, - resolve, - ); + let (symbolized_stacks, record, report) = symbolize_android_folded_stacks_with_native_libraries( + folded_stacks, + native_libraries, + runtime_abi, + resolve, + ); std::fs::write(processed_root.join("stacks.folded"), &symbolized_stacks)?; std::fs::write(processed_root.join("native-report.txt"), &report)?; @@ -725,6 +756,7 @@ struct AndroidProfilerToolchain { app_profiler_path: PathBuf, stackcollapse_path: PathBuf, python_path: PathBuf, + llvm_addr2line_path: PathBuf, } fn locate_android_profiler_toolchain() -> Result { @@ -737,11 +769,9 @@ fn locate_android_profiler_toolchain() -> Result { .and_then(|ndk_home| ndk_home.parent().and_then(Path::parent).map(PathBuf::from)) }) .or_else(|| { - std::env::var_os("HOME").map(PathBuf::from).map(|home| { - home.join("Library") - .join("Android") - .join("sdk") - }) + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join("Library").join("Android").join("sdk")) }) .filter(|path| path.exists()) .context("Android SDK not found; set ANDROID_HOME or ANDROID_SDK_ROOT")?; @@ -751,15 +781,13 @@ fn locate_android_profiler_toolchain() -> Result { .filter(|path| path.exists()) .or_else(|| { let ndk_dir = sdk_root.join("ndk"); - std::fs::read_dir(&ndk_dir) - .ok() - .and_then(|entries| { - entries - .filter_map(|entry| entry.ok()) - .map(|entry| entry.path()) - .filter(|path| path.is_dir()) - .max() - }) + std::fs::read_dir(&ndk_dir).ok().and_then(|entries| { + entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .max() + }) }) .context("Android NDK not found; set ANDROID_NDK_HOME or install an NDK under the SDK")?; @@ -769,10 +797,18 @@ fn locate_android_profiler_toolchain() -> Result { let python_path = std::env::var_os("PYTHON") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("python3")); + let llvm_addr2line_override = std::env::var_os("MOBENCH_ANDROID_LLVM_ADDR2LINE") + .or_else(|| std::env::var_os("LLVM_ADDR2LINE")) + .map(PathBuf::from); + let llvm_addr2line_path = + locate_android_llvm_addr2line(&ndk_root, llvm_addr2line_override.as_deref())?; for path in [&adb_path, &app_profiler_path, &stackcollapse_path] { if !path.exists() { - bail!("required Android profiling tool not found at {}", path.display()); + bail!( + "required Android profiling tool not found at {}", + path.display() + ); } } @@ -782,9 +818,45 @@ fn locate_android_profiler_toolchain() -> Result { app_profiler_path, stackcollapse_path, python_path, + llvm_addr2line_path, }) } +fn locate_android_llvm_addr2line(ndk_root: &Path, override_path: Option<&Path>) -> Result { + if let Some(path) = override_path { + if path.exists() { + return Ok(path.to_path_buf()); + } + bail!( + "explicit llvm-addr2line override does not exist at {}", + path.display() + ); + } + + let prebuilt_root = ndk_root.join("toolchains").join("llvm").join("prebuilt"); + let tool_name = if cfg!(windows) { + "llvm-addr2line.exe" + } else { + "llvm-addr2line" + }; + let mut candidates = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&prebuilt_root) { + for entry in entries.flatten() { + let candidate = entry.path().join("bin").join(tool_name); + if candidate.exists() { + candidates.push(candidate); + } + } + } + candidates.sort(); + candidates + .into_iter() + .next() + .context( + "llvm-addr2line not found under the Android NDK; set MOBENCH_ANDROID_LLVM_ADDR2LINE or LLVM_ADDR2LINE to override", + ) +} + fn prepend_path_env(toolchain: &AndroidProfilerToolchain) -> Option { let mut entries = vec![toolchain.sdk_root.join("platform-tools").into_os_string()]; if let Some(existing) = std::env::var_os("PATH") { @@ -799,10 +871,7 @@ fn ensure_android_device_connected(toolchain: &AndroidProfilerToolchain) -> Resu .output() .context("failed to run `adb devices`")?; if !output.status.success() { - bail!( - "adb devices failed with status {}", - output.status - ); + bail!("adb devices failed with status {}", output.status); } let stdout = String::from_utf8_lossy(&output.stdout); if stdout @@ -836,7 +905,7 @@ fn sdk_root_emulator_hint(sdk_root: &Path) -> Option { .trim() .to_string(); Some(format!( - "start one with `{}` -avd {}`", + "start one with `{}` -avd `{}`", emulator_path.display(), avd )) @@ -849,18 +918,11 @@ fn read_android_application_id(android_root: &Path) -> Result { for line in contents.lines() { let trimmed = line.trim(); if let Some(value) = trimmed.strip_prefix("applicationId ") { - return extract_quoted_value(value).with_context(|| { - format!( - "parsing applicationId from {}", - build_gradle.display() - ) - }); + return extract_quoted_value(value) + .with_context(|| format!("parsing applicationId from {}", build_gradle.display())); } } - bail!( - "applicationId not found in {}", - build_gradle.display() - ) + bail!("applicationId not found in {}", build_gradle.display()) } fn extract_quoted_value(source: &str) -> Result { @@ -906,6 +968,7 @@ fn execute_local_android_capture( ) -> Result<()> { let toolchain = locate_android_profiler_toolchain()?; ensure_android_device_connected(&toolchain)?; + let runtime_abi = resolve_android_runtime_abi(&toolchain)?; let layout = resolve_project_layout(ProjectLayoutOptions { start_dir: None, @@ -1014,6 +1077,8 @@ fn execute_local_android_capture( &folded_stacks, &build.native_libraries, &processed_root, + runtime_abi.as_deref(), + &toolchain.llvm_addr2line_path, )?; manifest.native_capture.symbolization = symbolization.clone(); @@ -1031,6 +1096,43 @@ fn execute_local_android_capture( Ok(()) } +fn resolve_android_runtime_abi(toolchain: &AndroidProfilerToolchain) -> Result> { + let primary_abi = read_android_device_property(&toolchain.adb_path, "ro.product.cpu.abi")?; + if let Some(abi) = primary_abi { + return Ok(Some(abi)); + } + + let abi_list = read_android_device_property(&toolchain.adb_path, "ro.product.cpu.abilist")?; + Ok(abi_list.and_then(|value| { + value + .split(',') + .map(str::trim) + .find(|value| !value.is_empty()) + .map(str::to_owned) + })) +} + +fn read_android_device_property(adb_path: &Path, property: &str) -> Result> { + let output = Command::new(adb_path) + .args(["shell", "getprop", property]) + .output() + .with_context(|| format!("reading Android device property {property}"))?; + if !output.status.success() { + bail!( + "adb shell getprop {property} failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value)) + } +} + fn load_profile_manifest(path: &Path) -> Result { let body = std::fs::read_to_string(path)?; Ok(serde_json::from_str(&body)?) @@ -1272,7 +1374,11 @@ fn execute_capture( let plan_only_warning = match (args.provider, target.backend) { (ProfileProvider::Local, ProfileBackend::AndroidNative) => { - return execute_capture_with_local_android_executor(args, manifest, execute_local_android_capture); + return execute_capture_with_local_android_executor( + args, + manifest, + execute_local_android_capture, + ); } (ProfileProvider::Local, ProfileBackend::IosInstruments) => Some( "local ios-instruments capture is not implemented yet; this session records the planned Instruments trace/XML artifact contract only", @@ -1283,7 +1389,7 @@ fn execute_capture( (ProfileProvider::Browserstack, ProfileBackend::AndroidNative) => { bail!(browserstack_native_capture_unsupported_message( "android-native", - "local Android profiling produces simpleperf artifacts and flamegraphs when implemented", + "local Android profiling produces simpleperf artifacts and flamegraphs", )); } (ProfileProvider::Browserstack, ProfileBackend::IosInstruments) => { @@ -1321,15 +1427,9 @@ where Ok(()) } -fn mark_android_capture_attempt_failed( - manifest: &mut ProfileManifest, - error: &anyhow::Error, -) { +fn mark_android_capture_attempt_failed(manifest: &mut ProfileManifest, error: &anyhow::Error) { manifest.native_capture.status = CaptureStatus::Failed; manifest.native_capture.symbolization.status = CaptureStatus::Failed; - if manifest.native_capture.symbolization.tool.is_none() { - manifest.native_capture.symbolization.tool = Some("llvm-addr2line".into()); - } let failure_note = format!("local android-native capture failed: {error}"); if !manifest @@ -1530,7 +1630,10 @@ mod tests { Some("Open flamegraph.html in a browser") ); assert_eq!(manifest.capture_metadata.warnings, vec!["legacy manifest"]); - assert_eq!(manifest.semantic_profile.status, SemanticCaptureStatus::Planned); + assert_eq!( + manifest.semantic_profile.status, + SemanticCaptureStatus::Planned + ); } #[test] @@ -1604,20 +1707,33 @@ mod tests { } #[test] - fn android_native_offsets_use_unstripped_library_paths() { - let unstripped_path = PathBuf::from("/cargo/target/aarch64-linux-android/release/libsample_fns.so"); + fn android_native_offsets_use_runtime_abi_to_select_unstripped_library_paths() { + let unstripped_path = + PathBuf::from("/cargo/target/aarch64-linux-android/release/libsample_fns.so"); let packaged_path = PathBuf::from("/apk/jniLibs/arm64-v8a/libsample_fns.so"); - let native_libraries = vec![NativeLibraryArtifact { - abi: "arm64-v8a".into(), - library_name: "libsample_fns.so".into(), - unstripped_path: unstripped_path.clone(), - packaged_path, - }]; + let other_unstripped_path = + PathBuf::from("/cargo/target/x86_64-linux-android/release/libsample_fns.so"); + let other_packaged_path = PathBuf::from("/apk/jniLibs/x86_64/libsample_fns.so"); + let native_libraries = vec![ + NativeLibraryArtifact { + abi: "arm64-v8a".into(), + library_name: "libsample_fns.so".into(), + unstripped_path: unstripped_path.clone(), + packaged_path, + }, + NativeLibraryArtifact { + abi: "x86_64".into(), + library_name: "libsample_fns.so".into(), + unstripped_path: other_unstripped_path.clone(), + packaged_path: other_packaged_path, + }, + ]; let mut seen_paths = Vec::new(); let (symbolized, record, report) = symbolize_android_folded_stacks_with_native_libraries( "dev.world.samplefns;libsample_fns.so[+94138] 1", &native_libraries, + Some("x86_64"), |path, offset| { seen_paths.push((path.to_path_buf(), offset)); Some("sample_fns::fibonacci".into()) @@ -1626,13 +1742,54 @@ mod tests { assert!(symbolized.contains("sample_fns::fibonacci")); assert_eq!(seen_paths.len(), 1); - assert_eq!(seen_paths[0].0, unstripped_path); + assert_eq!(seen_paths[0].0, other_unstripped_path); assert_eq!(seen_paths[0].1, 94_138); assert_eq!(record.status, CaptureStatus::Captured); assert_eq!(record.resolved_frames, 1); assert!(report.contains("sample_fns::fibonacci")); } + #[test] + fn android_native_offsets_do_not_collapse_multiple_abis_without_a_runtime_selection() { + let native_libraries = vec![ + NativeLibraryArtifact { + abi: "arm64-v8a".into(), + library_name: "libsample_fns.so".into(), + unstripped_path: PathBuf::from( + "/cargo/target/aarch64-linux-android/release/libsample_fns.so", + ), + packaged_path: PathBuf::from("/apk/jniLibs/arm64-v8a/libsample_fns.so"), + }, + NativeLibraryArtifact { + abi: "x86_64".into(), + library_name: "libsample_fns.so".into(), + unstripped_path: PathBuf::from( + "/cargo/target/x86_64-linux-android/release/libsample_fns.so", + ), + packaged_path: PathBuf::from("/apk/jniLibs/x86_64/libsample_fns.so"), + }, + ]; + let mut seen_paths = Vec::new(); + + let (symbolized, record, report) = symbolize_android_folded_stacks_with_native_libraries( + "dev.world.samplefns;libsample_fns.so[+94138] 1", + &native_libraries, + None, + |path, offset| { + seen_paths.push((path.to_path_buf(), offset)); + Some("sample_fns::fibonacci".into()) + }, + ); + + assert!(symbolized.contains("libsample_fns.so[+94138]")); + assert!(report.contains("libsample_fns.so[+94138]")); + assert!(seen_paths.is_empty()); + assert!(!symbolized.contains("sample_fns::fibonacci")); + assert_eq!(record.status, CaptureStatus::Failed); + assert_eq!(record.resolved_frames, 0); + assert_eq!(record.unresolved_frames, 1); + } + #[test] fn android_post_processing_writes_symbolized_outputs_before_flamegraph_rendering() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -1650,6 +1807,7 @@ mod tests { "dev.world.samplefns;libsample_fns.so[+94138] 1", &native_libraries, &processed_root, + Some("arm64-v8a"), |_path, offset| { if offset == 94_138 { Some("sample_fns::fibonacci".into()) @@ -1675,6 +1833,42 @@ mod tests { assert_eq!(record.unresolved_frames, 0); } + #[test] + fn android_ndk_addr2line_discovery_prefers_ndk_toolchain_bin() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let ndk_root = temp_dir.path().join("ndk/26.3.11579264"); + let tool_path = ndk_root + .join("toolchains") + .join("llvm") + .join("prebuilt") + .join("darwin-x86_64") + .join("bin") + .join(if cfg!(windows) { + "llvm-addr2line.exe" + } else { + "llvm-addr2line" + }); + std::fs::create_dir_all(tool_path.parent().expect("tool parent")).expect("create tool dir"); + std::fs::write(&tool_path, "#!/bin/sh\n").expect("write tool"); + + let discovered = locate_android_llvm_addr2line(&ndk_root, None).expect("discover tool"); + + assert_eq!(discovered, tool_path); + } + + #[test] + fn android_ndk_addr2line_discovery_honors_explicit_override() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let override_path = temp_dir.path().join("custom-llvm-addr2line"); + std::fs::write(&override_path, "#!/bin/sh\n").expect("write override"); + + let discovered = + locate_android_llvm_addr2line(Path::new("/does/not/matter"), Some(&override_path)) + .expect("discover override"); + + assert_eq!(discovered, override_path); + } + #[test] fn local_android_attempted_capture_marks_failed_state() { let args = sample_run_args( @@ -1684,21 +1878,28 @@ mod tests { ProfileFormat::Both, ); let target = resolve_profile_target(&args).expect("resolve target"); - let mut manifest = build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile")) - .expect("build capture plan"); + let mut manifest = + build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile")) + .expect("build capture plan"); - let error = execute_capture_with_local_android_executor(&args, &mut manifest, |_args, _manifest| { - anyhow::bail!("simulated android capture failure") - }) + let error = execute_capture_with_local_android_executor( + &args, + &mut manifest, + |_args, _manifest| anyhow::bail!("simulated android capture failure"), + ) .expect_err("simulated capture failure"); - assert!(error.to_string().contains("simulated android capture failure")); + assert!( + error + .to_string() + .contains("simulated android capture failure") + ); assert_eq!(manifest.native_capture.status, CaptureStatus::Failed); - assert_eq!(manifest.native_capture.symbolization.status, CaptureStatus::Failed); assert_eq!( - manifest.native_capture.symbolization.tool.as_deref(), - Some("llvm-addr2line") + manifest.native_capture.symbolization.status, + CaptureStatus::Failed ); + assert_eq!(manifest.native_capture.symbolization.tool, None); assert!( manifest .capture_metadata @@ -1726,16 +1927,22 @@ mod tests { }) .expect_err("simulated execution failure should bubble up"); - assert!(error.to_string().contains("simulated android capture failure")); + assert!( + error + .to_string() + .contains("simulated android capture failure") + ); - let run_dir = dir - .path() - .join(build_run_id(args.target, &args.function)); + let run_dir = dir.path().join(build_run_id(args.target, &args.function)); let manifest = load_profile_manifest(&run_dir.join("profile.json")) .expect("load failed profile manifest"); assert_eq!(manifest.native_capture.status, CaptureStatus::Failed); - assert_eq!(manifest.native_capture.symbolization.status, CaptureStatus::Failed); + assert_eq!( + manifest.native_capture.symbolization.status, + CaptureStatus::Failed + ); + assert_eq!(manifest.native_capture.symbolization.tool, None); assert!( manifest .capture_metadata @@ -1768,26 +1975,30 @@ mod tests { ) .expect("android capture plan"); - assert!(plan - .native_capture - .raw_artifacts - .iter() - .any(|p| p.path.ends_with("sample.perf"))); - assert!(plan - .native_capture - .processed_artifacts - .iter() - .any(|p| p.path.ends_with("flamegraph.html"))); - assert!(plan - .native_capture - .processed_artifacts - .iter() - .any(|p| p.path.ends_with("stacks.folded"))); - assert!(plan - .native_capture - .processed_artifacts - .iter() - .any(|p| p.path.ends_with("native-report.txt"))); + assert!( + plan.native_capture + .raw_artifacts + .iter() + .any(|p| p.path.ends_with("sample.perf")) + ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("flamegraph.html")) + ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("stacks.folded")) + ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("native-report.txt")) + ); } #[test] @@ -1838,16 +2049,18 @@ mod tests { ) .expect("ios capture plan"); - assert!(plan - .native_capture - .raw_artifacts - .iter() - .any(|p| p.path.ends_with("time-profiler.trace"))); - assert!(plan - .native_capture - .processed_artifacts - .iter() - .any(|p| p.path.ends_with("time-profiler.xml"))); + assert!( + plan.native_capture + .raw_artifacts + .iter() + .any(|p| p.path.ends_with("time-profiler.trace")) + ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("time-profiler.xml")) + ); } #[test] From 246e633a4c56fc3d45b251df6808da9c2ed31864 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 19:11:59 -0700 Subject: [PATCH 138/196] feat: add semantic phase profiling to mobench-sdk --- crates/mobench-sdk/src/codegen.rs | 17 ++ crates/mobench-sdk/src/ffi.rs | 25 +++ crates/mobench-sdk/src/lib.rs | 2 +- crates/mobench-sdk/src/timing.rs | 243 ++++++++++++++++++++++++- crates/mobench-sdk/src/uniffi_types.rs | 3 + crates/sample-fns/src/lib.rs | 34 +++- 6 files changed, 311 insertions(+), 13 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 6ec6afa..f512315 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -167,10 +167,17 @@ pub struct BenchSample { pub duration_ns: u64, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct SemanticPhase { + pub name: String, + pub duration_ns: u64, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct BenchReport { pub spec: BenchSpec, pub samples: Vec, + pub phases: Vec, } #[derive(Debug, thiserror::Error, uniffi::Error)] @@ -215,11 +222,21 @@ impl From for BenchSample { } } +impl From for SemanticPhase { + fn from(phase: mobench_sdk::SemanticPhase) -> Self { + Self { + name: phase.name, + duration_ns: phase.duration_ns, + } + } +} + impl From for BenchReport { fn from(report: mobench_sdk::RunnerReport) -> Self { Self { spec: report.spec.into(), samples: report.samples.into_iter().map(Into::into).collect(), + phases: report.phases.into_iter().map(Into::into).collect(), } } } diff --git a/crates/mobench-sdk/src/ffi.rs b/crates/mobench-sdk/src/ffi.rs index b86882e..3c4ae93 100644 --- a/crates/mobench-sdk/src/ffi.rs +++ b/crates/mobench-sdk/src/ffi.rs @@ -100,6 +100,24 @@ pub struct BenchReportFfi { pub spec: BenchSpecFfi, /// All collected timing samples. pub samples: Vec, + /// Optional semantic phase timings captured during measured iterations. + pub phases: Vec, +} + +/// FFI-ready semantic phase timing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SemanticPhaseFfi { + pub name: String, + pub duration_ns: u64, +} + +impl From for SemanticPhaseFfi { + fn from(phase: crate::SemanticPhase) -> Self { + Self { + name: phase.name, + duration_ns: phase.duration_ns, + } + } } impl From for BenchReportFfi { @@ -107,6 +125,7 @@ impl From for BenchReportFfi { Self { spec: report.spec.into(), samples: report.samples.into_iter().map(Into::into).collect(), + phases: report.phases.into_iter().map(Into::into).collect(), } } } @@ -237,12 +256,18 @@ mod tests { crate::BenchSample { duration_ns: 100 }, crate::BenchSample { duration_ns: 200 }, ], + phases: vec![crate::SemanticPhase { + name: "prove".to_string(), + duration_ns: 300, + }], }; let ffi: BenchReportFfi = report.into(); assert_eq!(ffi.spec.name, "test"); assert_eq!(ffi.samples.len(), 2); assert_eq!(ffi.samples[0].duration_ns, 100); + assert_eq!(ffi.phases.len(), 1); + assert_eq!(ffi.phases[0].name, "prove"); } #[test] diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index c2f8ea9..fe54657 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -378,7 +378,7 @@ pub use types::{BenchError, BenchSample, BenchSpec, RunnerReport}; pub use types::{BuildConfig, BuildProfile, BuildResult, InitConfig, Target}; // Re-export timing types at the crate root for convenience -pub use timing::{BenchSummary, TimingError, run_closure}; +pub use timing::{BenchSummary, SemanticPhase, TimingError, profile_phase, run_closure}; /// Re-export of [`std::hint::black_box`] for preventing compiler optimizations. /// diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index 6662872..e61da45 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -63,6 +63,7 @@ //! ``` use serde::{Deserialize, Serialize}; +use std::cell::RefCell; use std::time::{Duration, Instant}; use thiserror::Error; @@ -232,6 +233,9 @@ pub struct BenchReport { /// /// The length equals `spec.iterations`. Samples are in execution order. pub samples: Vec, + + /// Optional semantic phase timings captured during measured iterations. + pub phases: Vec, } impl BenchReport { @@ -356,6 +360,129 @@ pub struct BenchSummary { pub p99_ns: f64, } +/// Flat semantic phase timing captured during a benchmark run. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SemanticPhase { + pub name: String, + pub duration_ns: u64, +} + +#[derive(Default)] +struct SemanticPhaseCollector { + enabled: bool, + depth: usize, + phases: Vec, +} + +impl SemanticPhaseCollector { + fn reset(&mut self) { + self.enabled = false; + self.depth = 0; + self.phases.clear(); + } + + fn begin_measurement(&mut self) { + self.reset(); + self.enabled = true; + } + + fn finish(&mut self) -> Vec { + self.enabled = false; + self.depth = 0; + std::mem::take(&mut self.phases) + } + + fn enter_phase(&mut self) -> Option { + if !self.enabled { + return None; + } + let top_level = self.depth == 0; + self.depth += 1; + Some(top_level) + } + + fn exit_phase(&mut self, name: &str, top_level: bool, elapsed: Duration) { + self.depth = self.depth.saturating_sub(1); + if !self.enabled || !top_level { + return; + } + + let duration_ns = elapsed.as_nanos().min(u128::from(u64::MAX)) as u64; + if let Some(phase) = self.phases.iter_mut().find(|phase| phase.name == name) { + phase.duration_ns = phase.duration_ns.saturating_add(duration_ns); + } else { + self.phases.push(SemanticPhase { + name: name.to_string(), + duration_ns, + }); + } + } +} + +thread_local! { + static SEMANTIC_PHASE_COLLECTOR: RefCell = + RefCell::new(SemanticPhaseCollector::default()); +} + +struct SemanticPhaseGuard { + name: String, + started_at: Option, + top_level: bool, +} + +impl Drop for SemanticPhaseGuard { + fn drop(&mut self) { + let Some(started_at) = self.started_at else { + return; + }; + + let elapsed = started_at.elapsed(); + SEMANTIC_PHASE_COLLECTOR.with(|collector| { + collector + .borrow_mut() + .exit_phase(&self.name, self.top_level, elapsed); + }); + } +} + +fn reset_semantic_phase_collection() { + SEMANTIC_PHASE_COLLECTOR.with(|collector| collector.borrow_mut().reset()); +} + +fn begin_semantic_phase_collection() { + SEMANTIC_PHASE_COLLECTOR.with(|collector| collector.borrow_mut().begin_measurement()); +} + +fn finish_semantic_phase_collection() -> Vec { + SEMANTIC_PHASE_COLLECTOR.with(|collector| collector.borrow_mut().finish()) +} + +/// Records a flat semantic phase when called inside an active benchmark measurement loop. +/// +/// Phases are aggregated across measured iterations and ignored during warmup/setup. +/// Nested phases are intentionally collapsed in v1 to keep the output flat. +pub fn profile_phase(name: &str, f: impl FnOnce() -> T) -> T { + let guard = SEMANTIC_PHASE_COLLECTOR.with(|collector| { + let mut collector = collector.borrow_mut(); + match collector.enter_phase() { + Some(top_level) => SemanticPhaseGuard { + name: name.to_string(), + started_at: Some(Instant::now()), + top_level, + }, + None => SemanticPhaseGuard { + name: String::new(), + started_at: None, + top_level: false, + }, + } + }); + + let result = f(); + drop(guard); + result +} + /// Errors that can occur during benchmark execution. /// /// # Example @@ -457,20 +584,31 @@ where }); } + reset_semantic_phase_collection(); + // Warmup phase - not measured for _ in 0..spec.warmup { f()?; } // Measurement phase + begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); for _ in 0..spec.iterations { let start = Instant::now(); - f()?; + if let Err(err) = f() { + let _ = finish_semantic_phase_collection(); + return Err(err); + } samples.push(BenchSample::from_duration(start.elapsed())); } + let phases = finish_semantic_phase_collection(); - Ok(BenchReport { spec, samples }) + Ok(BenchReport { + spec, + samples, + phases, + }) } /// Runs a benchmark with setup that executes once before all iterations. @@ -515,6 +653,8 @@ where }); } + reset_semantic_phase_collection(); + // Setup phase - not timed let input = setup(); @@ -524,14 +664,23 @@ where } // Measurement phase + begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); for _ in 0..spec.iterations { let start = Instant::now(); - f(&input)?; + if let Err(err) = f(&input) { + let _ = finish_semantic_phase_collection(); + return Err(err); + } samples.push(BenchSample::from_duration(start.elapsed())); } + let phases = finish_semantic_phase_collection(); - Ok(BenchReport { spec, samples }) + Ok(BenchReport { + spec, + samples, + phases, + }) } /// Runs a benchmark with per-iteration setup. @@ -577,6 +726,8 @@ where }); } + reset_semantic_phase_collection(); + // Warmup phase for _ in 0..spec.warmup { let input = setup(); @@ -584,16 +735,25 @@ where } // Measurement phase + begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); for _ in 0..spec.iterations { let input = setup(); // Not timed let start = Instant::now(); - f(input)?; // Only this is timed + if let Err(err) = f(input) { + let _ = finish_semantic_phase_collection(); + return Err(err); + } samples.push(BenchSample::from_duration(start.elapsed())); } + let phases = finish_semantic_phase_collection(); - Ok(BenchReport { spec, samples }) + Ok(BenchReport { + spec, + samples, + phases, + }) } /// Runs a benchmark with setup and teardown. @@ -641,6 +801,8 @@ where }); } + reset_semantic_phase_collection(); + // Setup phase - not timed let input = setup(); @@ -650,17 +812,26 @@ where } // Measurement phase + begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); for _ in 0..spec.iterations { let start = Instant::now(); - f(&input)?; + if let Err(err) = f(&input) { + let _ = finish_semantic_phase_collection(); + return Err(err); + } samples.push(BenchSample::from_duration(start.elapsed())); } + let phases = finish_semantic_phase_collection(); // Teardown phase - not timed teardown(input); - Ok(BenchReport { spec, samples }) + Ok(BenchReport { + spec, + samples, + phases, + }) } #[cfg(test)] @@ -698,13 +869,67 @@ mod tests { #[test] fn serializes_to_json() { let spec = BenchSpec::new("test", 10, 2).unwrap(); - let report = run_closure(spec, || Ok(())).unwrap(); + let report = run_closure(spec, || { + profile_phase("prove", || std::thread::sleep(Duration::from_millis(1))); + Ok(()) + }) + .unwrap(); let json = serde_json::to_string(&report).unwrap(); let restored: BenchReport = serde_json::from_str(&json).unwrap(); assert_eq!(restored.spec.name, "test"); assert_eq!(restored.samples.len(), 10); + assert_eq!(restored.phases.len(), 1); + assert_eq!(restored.phases[0].name, "prove"); + assert!(restored.phases[0].duration_ns > 0); + } + + #[test] + fn profile_phase_records_only_measured_iterations() { + let spec = BenchSpec::new("semantic", 2, 1).unwrap(); + let mut call_index = 0u32; + let report = run_closure(spec, || { + let phase_name = if call_index == 0 { + "warmup-only" + } else { + "prove" + }; + call_index += 1; + profile_phase(phase_name, || std::thread::sleep(Duration::from_millis(1))); + Ok(()) + }) + .unwrap(); + + assert!( + !report.phases.iter().any(|phase| phase.name == "warmup-only"), + "warmup phases should not be recorded" + ); + let prove = report + .phases + .iter() + .find(|phase| phase.name == "prove") + .expect("prove phase"); + assert!(prove.duration_ns > 0); + } + + #[test] + fn profile_phase_keeps_the_v1_model_flat() { + let spec = BenchSpec::new("semantic-flat", 1, 0).unwrap(); + let report = run_closure(spec, || { + profile_phase("prove", || { + std::thread::sleep(Duration::from_millis(1)); + profile_phase("inner", || std::thread::sleep(Duration::from_millis(1))); + }); + Ok(()) + }) + .unwrap(); + + assert!(report.phases.iter().any(|phase| phase.name == "prove")); + assert!( + !report.phases.iter().any(|phase| phase.name == "inner"), + "nested phases should not create a second flat phase entry" + ); } #[test] diff --git a/crates/mobench-sdk/src/uniffi_types.rs b/crates/mobench-sdk/src/uniffi_types.rs index bef1246..c3018df 100644 --- a/crates/mobench-sdk/src/uniffi_types.rs +++ b/crates/mobench-sdk/src/uniffi_types.rs @@ -209,6 +209,8 @@ pub struct BenchReportTemplate { pub spec: BenchSpecTemplate, /// All collected timing samples. pub samples: Vec, + /// Optional semantic phase timings captured during measured iterations. + pub phases: Vec, } impl From for BenchReportTemplate { @@ -216,6 +218,7 @@ impl From for BenchReportTemplate { Self { spec: report.spec.into(), samples: report.samples.into_iter().map(Into::into).collect(), + phases: report.phases, } } } diff --git a/crates/sample-fns/src/lib.rs b/crates/sample-fns/src/lib.rs index ffb83ee..23cdcb3 100644 --- a/crates/sample-fns/src/lib.rs +++ b/crates/sample-fns/src/lib.rs @@ -1,6 +1,6 @@ //! Sample benchmark functions for mobile testing using UniFFI (proc macro mode). -use mobench_sdk::timing::{run_closure, TimingError}; +use mobench_sdk::timing::{profile_phase, run_closure, TimingError}; const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; @@ -18,11 +18,19 @@ pub struct BenchSample { pub duration_ns: u64, } +/// Flat semantic phase timing captured during measured iterations. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct SemanticPhase { + pub name: String, + pub duration_ns: u64, +} + /// Complete benchmark report with spec and timing samples. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct BenchReport { pub spec: BenchSpec, pub samples: Vec, + pub phases: Vec, } /// Error types for benchmark operations. @@ -71,11 +79,21 @@ impl From for BenchSample { } } +impl From for SemanticPhase { + fn from(phase: mobench_sdk::timing::SemanticPhase) -> Self { + Self { + name: phase.name, + duration_ns: phase.duration_ns, + } + } +} + impl From for BenchReport { fn from(report: mobench_sdk::timing::BenchReport) -> Self { Self { spec: report.spec.into(), samples: report.samples.into_iter().map(Into::into).collect(), + phases: report.phases.into_iter().map(Into::into).collect(), } } } @@ -97,8 +115,14 @@ pub fn run_benchmark(spec: BenchSpec) -> Result { let report = match timing_spec.name.as_str() { "fibonacci" | "fib" | "sample_fns::fibonacci" => { run_closure(timing_spec, || { - let result = fibonacci_batch(30, 1000); - // Use the result to prevent optimization + let result = profile_phase("prove", || fibonacci_batch(30, 1000)); + let serialized = profile_phase("serialize", || result.to_string()); + profile_phase("verify", || { + let checksum = serialized + .bytes() + .fold(0u64, |acc, byte| acc.wrapping_add(u64::from(byte))); + std::hint::black_box(checksum); + }); std::hint::black_box(result); Ok(()) }) @@ -186,6 +210,10 @@ mod tests { let report = run_benchmark(spec).unwrap(); assert_eq!(report.samples.len(), 3); assert_eq!(report.spec.name, "fibonacci"); + assert_eq!(report.phases.len(), 3); + assert_eq!(report.phases[0].name, "prove"); + assert_eq!(report.phases[1].name, "serialize"); + assert_eq!(report.phases[2].name, "verify"); } #[test] From 079b3702cfef72a4b31288e5d34b1d8496182816 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 19:17:30 -0700 Subject: [PATCH 139/196] feat: add semantic phase profiling to mobench-sdk --- crates/mobench-sdk/src/timing.rs | 5 ++++- crates/mobench-sdk/src/types.rs | 3 ++- crates/sample-fns/src/lib.rs | 28 +++++++++++++--------------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index e61da45..312ced7 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -902,7 +902,10 @@ mod tests { .unwrap(); assert!( - !report.phases.iter().any(|phase| phase.name == "warmup-only"), + !report + .phases + .iter() + .any(|phase| phase.name == "warmup-only"), "warmup phases should not be recorded" ); let prove = report diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs index bdcdd42..dbef5f1 100644 --- a/crates/mobench-sdk/src/types.rs +++ b/crates/mobench-sdk/src/types.rs @@ -18,7 +18,8 @@ // Re-export timing types for convenience pub use crate::timing::{ - BenchReport as RunnerReport, BenchSample, BenchSpec, BenchSummary, TimingError as RunnerError, + BenchReport as RunnerReport, BenchSample, BenchSpec, BenchSummary, SemanticPhase, + TimingError as RunnerError, }; use std::path::PathBuf; diff --git a/crates/sample-fns/src/lib.rs b/crates/sample-fns/src/lib.rs index 23cdcb3..c2ce5e5 100644 --- a/crates/sample-fns/src/lib.rs +++ b/crates/sample-fns/src/lib.rs @@ -113,21 +113,19 @@ pub fn run_benchmark(spec: BenchSpec) -> Result { let timing_spec: mobench_sdk::timing::BenchSpec = spec.into(); let report = match timing_spec.name.as_str() { - "fibonacci" | "fib" | "sample_fns::fibonacci" => { - run_closure(timing_spec, || { - let result = profile_phase("prove", || fibonacci_batch(30, 1000)); - let serialized = profile_phase("serialize", || result.to_string()); - profile_phase("verify", || { - let checksum = serialized - .bytes() - .fold(0u64, |acc, byte| acc.wrapping_add(u64::from(byte))); - std::hint::black_box(checksum); - }); - std::hint::black_box(result); - Ok(()) - }) - .map_err(|e: TimingError| -> BenchError { e.into() })? - } + "fibonacci" | "fib" | "sample_fns::fibonacci" => run_closure(timing_spec, || { + let result = profile_phase("prove", || fibonacci_batch(30, 1000)); + let serialized = profile_phase("serialize", || result.to_string()); + profile_phase("verify", || { + let checksum = serialized + .bytes() + .fold(0u64, |acc, byte| acc.wrapping_add(u64::from(byte))); + std::hint::black_box(checksum); + }); + std::hint::black_box(result); + Ok(()) + }) + .map_err(|e: TimingError| -> BenchError { e.into() })?, "checksum" | "checksum_1k" | "sample_fns::checksum" => { run_closure(timing_spec, || { // Run checksum 10000 times to make it measurable From 52a706953912cf3b40858e9cd40e055fec5206c8 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 19:19:22 -0700 Subject: [PATCH 140/196] feat: merge semantic phases into profile outputs --- README.md | 14 +- .../src/main/java/MainActivity.kt.template | 9 + .../BenchRunner/BenchRunnerFFI.swift.template | 8 + crates/mobench/src/lib.rs | 28 + crates/mobench/src/profile.rs | 677 +++++++++++++++++- crates/mobench/tests/profile_cli.rs | 4 + 6 files changed, 733 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9c31c2c..d1fcb57 100644 --- a/README.md +++ b/README.md @@ -113,17 +113,29 @@ The summary renderer keeps native and semantic outputs separate so the flamegrap view stays focused on native stacks while phase timings remain readable as benchmark metadata. +When a benchmark uses `mobench_sdk::timing::profile_phase(...)`, local profile +runs also persist a run-scoped semantic sidecar at +`artifacts/semantic/phases.json`. The profile summary renders those phase totals +separately from the flamegraph so phase timing does not get mislabeled as native +stack data. + Profiling capability matrix: | Provider | Backend | Current behavior | Notes | |----------|---------|------------------|-------| -| `local` | `android-native` | Planned manifest only | Native `simpleperf` capture is not implemented yet | +| `local` | `android-native` | Attempts real native capture | Uses `simpleperf`, symbolized `stacks.folded`, `native-report.txt`, `flamegraph.html`, and semantic phase summaries when the benchmark emits `profile_phase` data and an `adb` device is available | | `local` | `ios-instruments` | Planned manifest only | iOS output is an Instruments trace (`time-profiler.trace`) plus XML export (`time-profiler.xml`), not a flamegraph | | `local` | `rust-tracing` | Planned manifest only | Structured trace output is local-only and still not implemented | | `browserstack` | `android-native` | Unsupported | Use `--provider local` for planning/local capture, or a normal BrowserStack benchmark for timing/memory metrics | | `browserstack` | `ios-instruments` | Unsupported | BrowserStack does not provide retrievable native Instruments trace artifacts in this release | | `browserstack` | `rust-tracing` | Unsupported | Use `--provider local` for trace-events output | +For local native profiling, `profile run` also accepts `--warmup-mode warm|cold`. +Warm mode is the default for local Android/iOS native plans. On Android it performs +one preparatory launch before recording to prime startup caches and reduce first-run +noise. That improves the capture, but it does not remove all per-process bridge +initialization from the recorded run. + When you need device-specific planning inputs for profiling, `profile run` reuses the same resolution model as `devices resolve`: diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index a02cf34..8649df0 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -122,6 +122,15 @@ class MainActivity : AppCompatActivity() { samples.forEach { sampleArray.put(it) } json.put("samples_ns", sampleArray) + val phases = JSONArray() + report.phases.forEach { phase -> + val phaseJson = JSONObject() + phaseJson.put("name", phase.name) + phaseJson.put("duration_ns", phase.durationNs.toLong()) + phases.put(phaseJson) + } + json.put("phases", phases) + if (samples.isNotEmpty()) { val min = samples.minOrNull() ?: 0L val max = samples.maxOrNull() ?: 0L diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index 8ab114d..db131af 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -169,6 +169,14 @@ enum {{PROJECT_NAME_PASCAL}}FFI { let samplesArray = report.samples.map { ["duration_ns": $0.durationNs] } json["samples"] = samplesArray + let phases = report.phases.map { phase in + [ + "name": phase.name, + "duration_ns": phase.durationNs + ] as [String: Any] + } + json["phases"] = phases + // Statistics if !report.samples.isEmpty { let durations = report.samples.map { $0.durationNs } diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index e823058..5f1346a 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -8705,6 +8705,30 @@ project = "proj" } } + #[test] + fn profile_run_parses_capture_warmup_mode() { + let cli = Cli::parse_from([ + "mobench", + "profile", + "run", + "--target", + "android", + "--function", + "sample_fns::fibonacci", + "--warmup-mode", + "cold", + ]); + + match cli.command { + Command::Profile { + command: ProfileCommand::Run(args), + } => { + assert_eq!(args.warmup_mode, Some(profile::CaptureWarmupMode::Cold)); + } + _ => panic!("expected profile run command"), + } + } + #[test] fn profile_run_help_mentions_planned_only_or_execution_scope() { let help = render_profile_run_help(); @@ -8719,6 +8743,10 @@ project = "proj" ), "expected profile run help to mention real Android native execution, got:\n{help}" ); + assert!( + help.contains("--warmup-mode"), + "expected profile run help to expose warm/cold profiling mode, got:\n{help}" + ); } #[test] diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 7134580..11c4864 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result, bail}; use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::fmt::Write; use std::io::Cursor; use std::path::{Path, PathBuf}; @@ -44,6 +45,22 @@ pub enum ProfileSummaryFormat { Json, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CaptureWarmupMode { + Cold, + Warm, +} + +impl CaptureWarmupMode { + fn as_str(self) -> &'static str { + match self { + Self::Cold => "cold", + Self::Warm => "warm", + } + } +} + #[derive(Debug, Clone, Args)] #[command( about = "Plan or execute a native profiling session; local android-native now performs real simpleperf capture", @@ -103,6 +120,12 @@ pub struct ProfileRunArgs { pub backend: ProfileBackend, #[arg(long, value_enum, default_value_t = ProfileFormat::Both)] pub format: ProfileFormat, + #[arg( + long, + value_enum, + help = "Warm or cold capture mode for local native profiling (defaults to warm for local Android/iOS native backends)" + )] + pub warmup_mode: Option, } #[derive(Debug, Clone, Args)] @@ -209,7 +232,7 @@ impl Default for SemanticProfileRecord { pub struct CaptureMetadataRecord { pub device: Option, pub sample_duration_secs: Option, - pub warmup_mode: Option, + pub warmup_mode: Option, pub capture_method: Option, pub warnings: Vec, } @@ -440,7 +463,7 @@ pub fn render_profile_markdown(manifest: &ProfileManifest) -> String { } match &manifest.capture_metadata.warmup_mode { Some(warmup_mode) => { - let _ = writeln!(markdown, "- Warmup mode: `{warmup_mode}`"); + let _ = writeln!(markdown, "- Warmup mode: `{}`", warmup_mode.as_str()); } None => { let _ = writeln!(markdown, "- Warmup mode: `not recorded`"); @@ -538,6 +561,7 @@ fn write_profile_session_outputs( let run_profile_path = run_output_dir.join("profile.json"); let run_summary_path = run_output_dir.join("summary.md"); + write_semantic_phase_sidecar(manifest)?; write_profile_manifest(&run_profile_path, &manifest)?; std::fs::write(&run_summary_path, rendered_summary.as_bytes())?; @@ -548,6 +572,23 @@ fn write_profile_session_outputs( Ok(()) } +fn write_semantic_phase_sidecar(manifest: &ProfileManifest) -> Result<()> { + let Some(path) = manifest.semantic_profile.spans_path.as_ref() else { + return Ok(()); + }; + if manifest.semantic_profile.phases.is_empty() { + return Ok(()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write( + path, + serde_json::to_vec_pretty(&manifest.semantic_profile.phases)?, + )?; + Ok(()) +} + pub fn cmd_profile_summarize(args: &ProfileSummarizeArgs) -> Result<()> { let rendered = cmd_profile_summarize_for_test(args)?; if let Some(path) = &args.output { @@ -748,6 +789,8 @@ fn write_android_flamegraph_html(folded_stacks: &str, output_path: &Path) -> Res const DEFAULT_PROFILE_ITERATIONS: u32 = 20; const DEFAULT_PROFILE_WARMUP: u32 = 3; const DEFAULT_ANDROID_CAPTURE_DURATION_SECS: u64 = 10; +const DEFAULT_ANDROID_WARMUP_TIMEOUT_SECS: u64 = 60; +const ANDROID_BENCH_LOG_MARKER: &str = "BENCH_JSON"; #[derive(Debug, Clone)] struct AndroidProfilerToolchain { @@ -993,6 +1036,10 @@ fn execute_local_android_capture( let build = run_android_build(&layout, "", false, false)?; let android_root = layout.output_dir.join("android"); let package_name = read_android_application_id(&android_root)?; + let warmup_mode = manifest + .capture_metadata + .warmup_mode + .unwrap_or(CaptureWarmupMode::Cold); let raw_perf_path = manifest .native_capture @@ -1029,6 +1076,14 @@ fn execute_local_android_capture( ); } + prepare_android_profile_capture(&toolchain, &package_name, warmup_mode)?; + manifest.capture_metadata.warmup_mode = Some(warmup_mode); + if let Err(error) = android_clear_logcat(&toolchain) { + manifest.capture_metadata.warnings.push(format!( + "failed to clear Android logcat before the recorded profile run: {error}" + )); + } + let mut profiler = Command::new(&toolchain.python_path); profiler .arg(&toolchain.app_profiler_path) @@ -1092,7 +1147,345 @@ fn execute_local_android_capture( "android profile run used default benchmark settings: iterations={}, warmup={}", DEFAULT_PROFILE_ITERATIONS, DEFAULT_PROFILE_WARMUP )); + if warmup_mode == CaptureWarmupMode::Warm { + manifest.capture_metadata.warnings.push( + "performed one preparatory warm launch before recording; startup caches are warmed, but per-process bridge initialization may still appear in the captured run".into(), + ); + } + match android_read_logcat(&toolchain) { + Ok(logs) => { + let reports = extract_benchmark_reports_from_logs(&logs); + if let Some(report) = select_benchmark_value_for_function(&reports, &args.function) { + merge_semantic_profile_from_bench_report(manifest, report)?; + } + } + Err(error) => { + manifest.capture_metadata.warnings.push(format!( + "semantic phase capture was unavailable because Android logcat could not be read: {error}" + )); + } + } + + Ok(()) +} + +fn prepare_android_profile_capture( + toolchain: &AndroidProfilerToolchain, + package_name: &str, + warmup_mode: CaptureWarmupMode, +) -> Result<()> { + android_force_stop(toolchain, package_name)?; + if warmup_mode == CaptureWarmupMode::Cold { + return Ok(()); + } + + android_clear_logcat(toolchain)?; + android_start_activity(toolchain, package_name, ".MainActivity")?; + wait_for_android_bench_log_marker( + toolchain, + ANDROID_BENCH_LOG_MARKER, + DEFAULT_ANDROID_WARMUP_TIMEOUT_SECS, + )?; + android_force_stop(toolchain, package_name)?; + Ok(()) +} +fn android_force_stop(toolchain: &AndroidProfilerToolchain, package_name: &str) -> Result<()> { + let output = Command::new(&toolchain.adb_path) + .args(["shell", "am", "force-stop"]) + .arg(package_name) + .output() + .with_context(|| format!("force-stopping Android package {package_name}"))?; + if !output.status.success() { + bail!( + "adb force-stop failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + +fn android_clear_logcat(toolchain: &AndroidProfilerToolchain) -> Result<()> { + let output = Command::new(&toolchain.adb_path) + .args(["logcat", "-c"]) + .output() + .context("clearing Android logcat before warm profile capture")?; + if !output.status.success() { + bail!( + "adb logcat -c failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + +fn android_start_activity( + toolchain: &AndroidProfilerToolchain, + package_name: &str, + activity_name: &str, +) -> Result<()> { + let component = format!("{package_name}/{activity_name}"); + let output = Command::new(&toolchain.adb_path) + .args(["shell", "am", "start", "-W", "-n"]) + .arg(&component) + .output() + .with_context(|| format!("starting Android activity {component}"))?; + if !output.status.success() { + bail!( + "adb am start failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + +fn wait_for_android_bench_log_marker( + toolchain: &AndroidProfilerToolchain, + marker: &str, + timeout_secs: u64, +) -> Result<()> { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); + while std::time::Instant::now() < deadline { + let logcat = android_read_logcat(toolchain)?; + if android_log_contains_marker(&logcat, marker) { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + bail!("timed out waiting for Android warmup marker `{marker}` in logcat"); +} + +fn android_read_logcat(toolchain: &AndroidProfilerToolchain) -> Result { + let output = Command::new(&toolchain.adb_path) + .args(["logcat", "-d", "-s", "BenchRunner:I", "MainActivity:D"]) + .output() + .context("reading Android logcat for warm profile capture")?; + if !output.status.success() { + bail!( + "adb logcat -d failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn android_log_contains_marker(logcat: &str, marker: &str) -> bool { + logcat.lines().any(|line| line.contains(marker)) +} + +fn extract_benchmark_reports_from_logs(logs: &str) -> Vec { + let mut results = Vec::new(); + if let Some(json) = extract_ios_benchmark_json(logs) { + results.push(json); + } + + let marker = "BENCH_JSON "; + for line in logs.lines() { + if let Some(index) = line.find(marker) { + let json_part = &line[index + marker.len()..]; + if let Ok(parsed) = serde_json::from_str::(json_part) { + results.push(parsed); + } + } + } + + results +} + +fn extract_ios_benchmark_json(logs: &str) -> Option { + let start_marker = "BENCH_REPORT_JSON_START"; + let end_marker = "BENCH_REPORT_JSON_END"; + let start_pos = logs.rfind(start_marker)?; + let after_start = &logs[start_pos + start_marker.len()..]; + let end_pos = after_start.find(end_marker)?; + extract_ios_json_from_log_section(&after_start[..end_pos]) +} + +fn extract_ios_json_from_log_section(section: &str) -> Option { + let trimmed = section.trim(); + if trimmed.starts_with('{') + && trimmed.ends_with('}') + && let Ok(parsed) = serde_json::from_str::(trimmed) + { + return Some(parsed); + } + + for line in section.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(json_start) = line.find('{') + && let Some(json) = extract_balanced_json(&line[json_start..]) + && let Ok(parsed) = serde_json::from_str::(&json) + { + return Some(parsed); + } + } + + let collapsed: String = section + .lines() + .map(|line| { + if let Some(prefix_end) = line.find("] ") { + &line[prefix_end + 2..] + } else { + line.trim() + } + }) + .collect::>() + .join(""); + let json_start = collapsed.find('{')?; + let json = extract_balanced_json(&collapsed[json_start..])?; + serde_json::from_str(&json).ok() +} + +fn extract_balanced_json(input: &str) -> Option { + if !input.starts_with('{') { + return None; + } + + let mut depth = 0; + let mut in_string = false; + let mut escape_next = false; + for (index, ch) in input.char_indices() { + if escape_next { + escape_next = false; + continue; + } + + match ch { + '\\' if in_string => escape_next = true, + '"' => in_string = !in_string, + '{' if !in_string => depth += 1, + '}' if !in_string => { + depth -= 1; + if depth == 0 { + return Some(input[..=index].to_string()); + } + } + _ => {} + } + } + + None +} + +fn benchmark_value_function(value: &Value) -> Option<&str> { + value.get("function").and_then(Value::as_str).or_else(|| { + value + .get("spec") + .and_then(|spec| spec.get("name")) + .and_then(Value::as_str) + }) +} + +fn select_benchmark_value_for_function<'a>( + values: &'a [Value], + function: &str, +) -> Option<&'a Value> { + let simple_name = function.split("::").last().unwrap_or(function); + values + .iter() + .rev() + .find(|value| { + benchmark_value_function(value).is_some_and(|name| { + name == function + || name == simple_name + || name.ends_with(&format!("::{simple_name}")) + || function.ends_with(&format!("::{name}")) + }) + }) + .or_else(|| values.last()) +} + +fn benchmark_value_sample_duration_total_ns(benchmark_value: &Value) -> u64 { + let sample_objects_total_ns: u64 = benchmark_value + .get("samples") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|sample| sample.get("duration_ns").and_then(Value::as_u64)) + .sum(); + if sample_objects_total_ns > 0 { + return sample_objects_total_ns; + } + + benchmark_value + .get("samples_ns") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_u64) + .sum() +} + +fn populate_semantic_profile_from_benchmark_value( + manifest: &mut ProfileManifest, + benchmark_value: &Value, +) { + let Some(phases) = benchmark_value.get("phases").and_then(Value::as_array) else { + return; + }; + + let phase_duration_total_ns: u64 = phases + .iter() + .filter_map(|phase| phase.get("duration_ns").and_then(Value::as_u64)) + .sum(); + let sample_duration_total_ns = benchmark_value_sample_duration_total_ns(benchmark_value); + let total_duration_ns = if sample_duration_total_ns > 0 { + sample_duration_total_ns + } else { + phase_duration_total_ns + }; + + let mut semantic_phases = Vec::new(); + let mut partial = false; + for phase in phases { + let Some(name) = phase.get("name").and_then(Value::as_str) else { + partial = true; + continue; + }; + let duration_ns = phase.get("duration_ns").and_then(Value::as_u64); + let percent_total = duration_ns.and_then(|duration_ns| { + (total_duration_ns > 0).then_some( + (duration_ns.saturating_mul(100) + (total_duration_ns / 2)) / total_duration_ns, + ) + }); + if duration_ns.is_none() { + partial = true; + } + semantic_phases.push(SemanticPhaseRecord { + name: name.to_string(), + duration_ns, + percent_total, + }); + } + + if semantic_phases.is_empty() { + return; + } + + manifest.semantic_profile.status = if partial { + SemanticCaptureStatus::Partial + } else { + SemanticCaptureStatus::Captured + }; + manifest.semantic_profile.phases = semantic_phases; +} + +fn merge_semantic_profile_from_bench_report( + manifest: &mut ProfileManifest, + bench_report: &Value, +) -> Result<()> { + populate_semantic_profile_from_benchmark_value(manifest, bench_report); Ok(()) } @@ -1159,7 +1552,7 @@ fn build_capture_plan( target: &ResolvedProfileTarget, output_root: &Path, ) -> Result { - let backend = resolve_backend(args.target, args.backend); + let backend = target.backend; validate_format_capabilities(backend, args.format)?; let raw_root = output_root.join("artifacts/raw"); @@ -1226,14 +1619,17 @@ fn build_capture_plan( symbolization: SymbolizationRecord::default(), viewer_hint, }, - semantic_profile: SemanticProfileRecord::default(), + semantic_profile: SemanticProfileRecord { + spans_path: Some(output_root.join("artifacts/semantic/phases.json")), + ..SemanticProfileRecord::default() + }, capture_metadata: CaptureMetadataRecord { device: target .device .as_ref() .map(|device| device.identifier.clone()), sample_duration_secs: None, - warmup_mode: None, + warmup_mode: resolve_capture_warmup_mode(args.provider, backend, args.warmup_mode), capture_method: Some(match backend { ProfileBackend::AndroidNative => "simpleperf".into(), ProfileBackend::IosInstruments => "instruments".into(), @@ -1245,6 +1641,18 @@ fn build_capture_plan( }) } +fn resolve_capture_warmup_mode( + provider: ProfileProvider, + backend: ProfileBackend, + requested: Option, +) -> Option { + requested.or(match (provider, backend) { + (ProfileProvider::Local, ProfileBackend::AndroidNative) + | (ProfileProvider::Local, ProfileBackend::IosInstruments) => Some(CaptureWarmupMode::Warm), + _ => None, + }) +} + fn build_run_id(target: MobileTarget, function: &str) -> String { format!("{}-{}", target.as_str(), slugify_function_name(function)) } @@ -1541,9 +1949,204 @@ mod tests { provider, backend, format, + warmup_mode: None, } } + #[test] + fn local_native_profiles_default_to_warm_capture_mode() { + let android_target = resolve_profile_target(&sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + )) + .expect("resolve android target"); + let android_plan = build_capture_plan( + &sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ), + &android_target, + &PathBuf::from("target/mobench/profile"), + ) + .expect("build android plan"); + + assert_eq!( + android_plan.capture_metadata.warmup_mode, + Some(CaptureWarmupMode::Warm) + ); + } + + #[test] + fn explicit_capture_warmup_mode_overrides_local_default() { + let mut args = sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ); + args.warmup_mode = Some(CaptureWarmupMode::Cold); + let target = resolve_profile_target(&args).expect("resolve target"); + let plan = build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile")) + .expect("build plan"); + + assert_eq!( + plan.capture_metadata.warmup_mode, + Some(CaptureWarmupMode::Cold) + ); + } + + #[test] + fn android_warmup_log_marker_detection_uses_bench_json_marker() { + assert!(android_log_contains_marker( + "03-26 19:00:00.000 BenchRunner I BENCH_JSON {\"samples_ns\":[1,2]}", + ANDROID_BENCH_LOG_MARKER + )); + assert!(!android_log_contains_marker( + "03-26 19:00:00.000 BenchRunner I unrelated log line", + ANDROID_BENCH_LOG_MARKER + )); + } + + #[test] + fn benchmark_logs_extract_android_bench_json_reports() { + let reports = extract_benchmark_reports_from_logs( + "03-26 19:00:00.000 BenchRunner I BENCH_JSON {\"function\":\"sample_fns::fibonacci\",\"phases\":[{\"name\":\"prove\",\"duration_ns\":90},{\"name\":\"serialize\",\"duration_ns\":10}]}", + ); + + assert_eq!(reports.len(), 1); + assert_eq!( + benchmark_value_function(&reports[0]), + Some("sample_fns::fibonacci") + ); + } + + #[test] + fn semantic_profile_populates_from_benchmark_phase_payload() { + let mut manifest = build_capture_plan( + &sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ), + &resolve_profile_target(&sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + )) + .expect("resolve target"), + &PathBuf::from("target/mobench/profile"), + ) + .expect("build plan"); + + populate_semantic_profile_from_benchmark_value( + &mut manifest, + &serde_json::json!({ + "function": "sample_fns::fibonacci", + "phases": [ + {"name": "prove", "duration_ns": 90}, + {"name": "serialize", "duration_ns": 10} + ] + }), + ); + + assert_eq!( + manifest.semantic_profile.status, + SemanticCaptureStatus::Captured + ); + assert_eq!(manifest.semantic_profile.phases.len(), 2); + assert_eq!(manifest.semantic_profile.phases[0].name, "prove"); + assert_eq!(manifest.semantic_profile.phases[0].duration_ns, Some(90)); + assert_eq!(manifest.semantic_profile.phases[0].percent_total, Some(90)); + assert_eq!(manifest.semantic_profile.phases[1].percent_total, Some(10)); + } + + #[test] + fn semantic_profile_uses_samples_ns_totals_for_android_log_payloads() { + let mut manifest = build_capture_plan( + &sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ), + &resolve_profile_target(&sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + )) + .expect("resolve target"), + &PathBuf::from("target/mobench/profile"), + ) + .expect("build plan"); + + merge_semantic_profile_from_bench_report( + &mut manifest, + &serde_json::json!({ + "function": "sample_fns::fibonacci", + "samples_ns": [100, 300], + "phases": [ + {"name": "prove", "duration_ns": 320}, + {"name": "serialize", "duration_ns": 40} + ] + }), + ) + .expect("merge semantic profile"); + + assert_eq!( + manifest.semantic_profile.status, + SemanticCaptureStatus::Captured + ); + assert_eq!(manifest.semantic_profile.phases[0].percent_total, Some(80)); + assert_eq!(manifest.semantic_profile.phases[1].percent_total, Some(10)); + } + + #[test] + fn write_profile_session_outputs_persists_semantic_phase_sidecar() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut manifest = sample_manifest(); + manifest.semantic_profile.spans_path = Some( + dir.path() + .join("android-sample/artifacts/semantic/phases.json"), + ); + let args = ProfileRunArgs { + target: MobileTarget::Android, + function: "sample_fns::fibonacci".into(), + provider: ProfileProvider::Local, + backend: ProfileBackend::AndroidNative, + format: ProfileFormat::Both, + output_dir: dir.path().to_path_buf(), + crate_path: None, + device: None, + os_version: None, + profile: None, + device_matrix: None, + config: None, + warmup_mode: Some(CaptureWarmupMode::Warm), + }; + let run_output_dir = dir.path().join("android-sample"); + + write_profile_session_outputs(&args, &run_output_dir, &manifest) + .expect("write profile outputs"); + + let sidecar = std::fs::read_to_string( + manifest + .semantic_profile + .spans_path + .as_ref() + .expect("semantic spans path"), + ) + .expect("read semantic sidecar"); + assert!(sidecar.contains("\"prove\"")); + assert!(sidecar.contains("\"serialize\"")); + } + #[test] fn profile_manifest_serializes_partial_failure_state() { let manifest = sample_manifest(); @@ -1686,6 +2289,68 @@ mod tests { assert!(rendered.contains("Profile Summary")); } + #[test] + fn build_capture_plan_reserves_semantic_phase_sidecar_path() { + let args = sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ); + let target = resolve_profile_target(&args).expect("resolve target"); + let manifest = build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile")) + .expect("build capture plan"); + + assert_eq!( + manifest.semantic_profile.spans_path, + Some(PathBuf::from( + "target/mobench/profile/artifacts/semantic/phases.json" + )) + ); + } + + #[test] + fn semantic_profile_ingests_phase_timings_from_bench_report_json() { + let args = sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ); + let target = resolve_profile_target(&args).expect("resolve target"); + let mut manifest = + build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile")) + .expect("build capture plan"); + let bench_report = serde_json::json!({ + "spec": { + "name": "sample_fns::fibonacci", + "iterations": 2, + "warmup": 1 + }, + "samples": [ + {"duration_ns": 100}, + {"duration_ns": 300} + ], + "phases": [ + {"name": "prove", "duration_ns": 320}, + {"name": "serialize", "duration_ns": 40} + ] + }); + + populate_semantic_profile_from_benchmark_value(&mut manifest, &bench_report); + + assert_eq!( + manifest.semantic_profile.status, + SemanticCaptureStatus::Captured + ); + assert_eq!(manifest.semantic_profile.phases.len(), 2); + assert_eq!(manifest.semantic_profile.phases[0].name, "prove"); + assert_eq!(manifest.semantic_profile.phases[0].duration_ns, Some(320)); + assert_eq!(manifest.semantic_profile.phases[0].percent_total, Some(80)); + assert_eq!(manifest.semantic_profile.phases[1].name, "serialize"); + assert_eq!(manifest.semantic_profile.phases[1].percent_total, Some(10)); + } + #[test] fn android_native_offsets_are_symbolized_into_rust_frames() { let (symbolized, record, report) = symbolize_android_folded_stacks_with_resolver( @@ -2346,7 +3011,7 @@ mod tests { .as_ref() .map(|device| device.identifier.clone()), sample_duration_secs: Some(15), - warmup_mode: Some("warm".into()), + warmup_mode: Some(CaptureWarmupMode::Warm), capture_method: Some("simpleperf".into()), warnings: vec!["missing symbols".into()], }, diff --git a/crates/mobench/tests/profile_cli.rs b/crates/mobench/tests/profile_cli.rs index 87ff833..71fe72d 100644 --- a/crates/mobench/tests/profile_cli.rs +++ b/crates/mobench/tests/profile_cli.rs @@ -106,6 +106,10 @@ fn profile_run_help_mentions_planned_only_or_execution_scope() { stdout.contains("BrowserStack") || stdout.contains("browserstack"), "expected help to mention BrowserStack capability scope, got:\n{stdout}" ); + assert!( + stdout.contains("--warmup-mode"), + "expected help to expose warm/cold capture mode, got:\n{stdout}" + ); } #[test] From 782cb9bf5c57dd1f554d6fcd77ddcc8eb6b495e8 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 19:28:15 -0700 Subject: [PATCH 141/196] Publish profiling SDK support for CI --- crates/mobench-sdk/src/builders/android.rs | 152 ++++++++++++++++++++- crates/mobench-sdk/src/builders/ios.rs | 2 + crates/mobench-sdk/src/lib.rs | 2 +- crates/mobench-sdk/src/types.rs | 13 ++ 4 files changed, 164 insertions(+), 5 deletions(-) diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index 9f8ccce..d190114 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -56,12 +56,125 @@ //! ``` use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root}; -use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; +use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, NativeLibraryArtifact, Target}; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct AndroidStackSymbolization { + pub resolved: usize, + pub unresolved: usize, +} + +pub fn symbolize_android_native_stack_line_with_resolver( + line: &str, + mut resolve: F, +) -> Result<(String, AndroidStackSymbolization), E> +where + F: FnMut(&str, &str) -> Result, E>, +{ + let mut output = String::with_capacity(line.len()); + let mut remaining = line; + let mut stats = AndroidStackSymbolization::default(); + + while let Some(start) = remaining.find("lib") { + output.push_str(&remaining[..start]); + let candidate = &remaining[start..]; + if let Some((frame, library, offset)) = parse_android_native_frame(candidate) { + match resolve(library, offset)? { + Some(symbol) => { + stats.resolved += 1; + output.push_str(&symbol); + } + None => { + stats.unresolved += 1; + output.push_str(frame); + } + } + remaining = &candidate[frame.len()..]; + } else { + output.push('l'); + remaining = &candidate[1..]; + } + } + + output.push_str(remaining); + Ok((output, stats)) +} + +pub fn symbolize_android_native_stack_line( + line: &str, + addr2line_path: &Path, + libraries: &[NativeLibraryArtifact], +) -> Result<(String, AndroidStackSymbolization), BenchError> { + symbolize_android_native_stack_line_with_resolver(line, |library_name, offset| { + let library = libraries.iter().find(|artifact| { + artifact + .packaged_path + .file_name() + .and_then(|name| name.to_str()) + == Some(library_name) + }); + + let Some(library) = library else { + return Ok(None); + }; + + resolve_android_native_symbol_with_tool(addr2line_path, &library.unstripped_path, offset) + }) +} + +fn parse_android_native_frame<'a>(candidate: &'a str) -> Option<(&'a str, &'a str, &'a str)> { + let so_index = candidate.find(".so[")?; + let frame_end = candidate[so_index..].find(']')? + so_index + 1; + let frame = &candidate[..frame_end]; + let library = &candidate[..so_index + 3]; + let offset = candidate[so_index + 4..frame_end - 1] + .strip_prefix('+') + .unwrap_or(&candidate[so_index + 4..frame_end - 1]); + Some((frame, library, offset)) +} + +pub fn resolve_android_native_symbol_with_tool( + addr2line_path: &Path, + library_path: &Path, + offset: &str, +) -> Result, BenchError> { + let output = Command::new(addr2line_path) + .arg("-Cfpe") + .arg(library_path) + .arg(offset) + .output() + .map_err(|err| { + BenchError::Execution(format!( + "failed to run {} for {} at {}: {}", + addr2line_path.display(), + library_path.display(), + offset, + err + )) + })?; + + if !output.status.success() { + return Ok(None); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_android_addr2line_stdout(&stdout)) +} + +fn parse_android_addr2line_stdout(stdout: &str) -> Option { + stdout.lines().map(str::trim).find_map(|line| { + if line.is_empty() || line == "??" || line == "??:0" { + None + } else { + Some(line.to_string()) + } + }) +} + /// Android builder that handles the complete build pipeline. /// /// This builder automates the process of compiling Rust code to Android native @@ -249,6 +362,7 @@ impl AndroidBuilder { "app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk", profile_name, profile_name ))), + native_libraries: Vec::new(), }); } @@ -274,7 +388,7 @@ impl AndroidBuilder { // Step 3: Copy .so files to jniLibs println!("Copying native libraries to jniLibs..."); - self.copy_native_libraries(config)?; + let native_libraries = self.copy_native_libraries(config)?; // Step 4: Build APK with Gradle println!("Building Android APK with Gradle..."); @@ -289,6 +403,7 @@ impl AndroidBuilder { platform: Target::Android, app_path: apk_path, test_suite_path: Some(test_suite_path), + native_libraries, }; self.validate_build_artifacts(&result, config)?; @@ -677,7 +792,7 @@ impl AndroidBuilder { } /// Copies .so files to Android jniLibs directories - fn copy_native_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> { + fn copy_native_libraries(&self, config: &BuildConfig) -> Result, BenchError> { let crate_dir = self.find_crate_dir()?; let profile_dir = match config.profile { BuildProfile::Debug => "debug", @@ -703,6 +818,7 @@ impl AndroidBuilder { ("armv7-linux-androideabi", "armeabi-v7a"), ("x86_64-linux-android", "x86_64"), ]; + let mut native_libraries = Vec::new(); for (rust_target, android_abi) in abi_mappings { let src = target_dir @@ -736,6 +852,11 @@ impl AndroidBuilder { if self.verbose { println!(" Copied {} -> {}", src.display(), dest.display()); } + native_libraries.push(NativeLibraryArtifact { + abi: android_abi.to_string(), + packaged_path: dest.clone(), + unstripped_path: src.clone(), + }); } else { // Always warn about missing native libraries - this will cause runtime crashes eprintln!( @@ -748,7 +869,7 @@ impl AndroidBuilder { } } - Ok(()) + Ok(native_libraries) } /// Ensures local.properties exists with sdk.dir set @@ -1250,6 +1371,29 @@ impl AndroidBuilder { mod tests { use super::*; + #[test] + fn symbolize_android_native_stack_line_rewrites_offsets_to_symbols() { + let line = "sample.perf: libsample_fns.so[+0x1a2b] libsample_fns.so[+0x2b3c]"; + let (symbolized, stats) = symbolize_android_native_stack_line_with_resolver( + line, + |library, offset| -> Result, ()> { + match (library, offset) { + ("libsample_fns.so", "0x1a2b") => { + Ok(Some("sample_fns::fibonacci".into())) + } + ("libsample_fns.so", "0x2b3c") => Ok(Some("sample_fns::checksum".into())), + _ => Ok(None), + } + }, + ) + .expect("symbolize stack line"); + + assert!(symbolized.contains("sample_fns::fibonacci")); + assert!(symbolized.contains("sample_fns::checksum")); + assert_eq!(stats.resolved, 2); + assert_eq!(stats.unresolved, 0); + } + #[test] fn test_android_builder_creation() { let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index b364c81..96aca32 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -272,6 +272,7 @@ impl IosBuilder { platform: Target::Ios, app_path: xcframework_path, test_suite_path: None, + native_libraries: Vec::new(), }); } @@ -333,6 +334,7 @@ impl IosBuilder { platform: Target::Ios, app_path: xcframework_path, test_suite_path: None, + native_libraries: Vec::new(), }; self.validate_build_artifacts(&result, config)?; diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 6c4c615..4d3bde6 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -375,7 +375,7 @@ pub use types::{BenchError, BenchSample, BenchSpec, RunnerReport}; // Re-export types that require full feature #[cfg(feature = "full")] #[cfg_attr(docsrs, doc(cfg(feature = "full")))] -pub use types::{BuildConfig, BuildProfile, BuildResult, InitConfig, Target}; +pub use types::{BuildConfig, BuildProfile, BuildResult, InitConfig, NativeLibraryArtifact, Target}; // Re-export timing types at the crate root for convenience pub use timing::{BenchSummary, TimingError, run_closure}; diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs index 0617416..8046e65 100644 --- a/crates/mobench-sdk/src/types.rs +++ b/crates/mobench-sdk/src/types.rs @@ -287,4 +287,17 @@ pub struct BuildResult { /// - Android: Path to the androidTest APK (for Espresso) /// - iOS: Path to the XCUITest runner zip pub test_suite_path: Option, + /// Native library build artifacts, including the packaged and unstripped paths. + pub native_libraries: Vec, +} + +/// Native library provenance for a build result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeLibraryArtifact { + /// Android ABI directory name, e.g. `arm64-v8a`. + pub abi: String, + /// Path to the packaged library copied into `jniLibs`. + pub packaged_path: PathBuf, + /// Path to the unstripped Cargo output used for symbolization. + pub unstripped_path: PathBuf, } From 576f51f61d600e4e0a427012705d334767e88c53 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 19:41:03 -0700 Subject: [PATCH 142/196] feat: add local iOS flamegraph capture --- README.md | 6 +- crates/mobench-sdk/src/builders/ios.rs | 37 +- crates/mobench/src/lib.rs | 12 +- crates/mobench/src/profile.rs | 900 ++++++++++++++++++++++++- 4 files changed, 916 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index d1fcb57..bc1d4b3 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,10 @@ Profiling capability matrix: | Provider | Backend | Current behavior | Notes | |----------|---------|------------------|-------| | `local` | `android-native` | Attempts real native capture | Uses `simpleperf`, symbolized `stacks.folded`, `native-report.txt`, `flamegraph.html`, and semantic phase summaries when the benchmark emits `profile_phase` data and an `adb` device is available | -| `local` | `ios-instruments` | Planned manifest only | iOS output is an Instruments trace (`time-profiler.trace`) plus XML export (`time-profiler.xml`), not a flamegraph | +| `local` | `ios-instruments` | Attempts real native capture | Uses a simulator-host `sample` capture to write `sample.txt`, `stacks.folded`, `native-report.txt`, and `flamegraph.html`. Semantic phase summaries are merged when the benchmark JSON includes `phases`. | | `local` | `rust-tracing` | Planned manifest only | Structured trace output is local-only and still not implemented | | `browserstack` | `android-native` | Unsupported | Use `--provider local` for planning/local capture, or a normal BrowserStack benchmark for timing/memory metrics | -| `browserstack` | `ios-instruments` | Unsupported | BrowserStack does not provide retrievable native Instruments trace artifacts in this release | +| `browserstack` | `ios-instruments` | Unsupported | Use `--provider local` for simulator-host `sample` capture and flamegraphs. BrowserStack does not provide retrievable native iOS profile artifacts in this release. | | `browserstack` | `rust-tracing` | Unsupported | Use `--provider local` for trace-events output | For local native profiling, `profile run` also accepts `--warmup-mode warm|cold`. @@ -266,7 +266,7 @@ fn db_query(db: &Database) { - Clarified that profiling remains local-first in this release; BrowserStack native profiling is explicitly unsupported with actionable error text and a visible capability matrix. - Split `profile run` into target resolution, capture planning, and capture execution seams so planned manifests no longer imply that native capture actually ran. - Added device-selection inputs to `profile run` (`--device`, `--os-version`, `--profile`, `--device-matrix`) by reusing the existing deterministic device-resolution flow. -- Corrected the iOS artifact story: the planned output remains an Instruments trace/XML export contract, not a flamegraph. +- Added real local iOS native capture via simulator-host `sample`, with `sample.txt`, `stacks.folded`, `native-report.txt`, and `flamegraph.html` written into the normalized profile session layout. - Added regression coverage for profile help text, BrowserStack unsupported execution, dry-run planning semantics, and direct device target resolution. - Added experimental `cargo mobench profile run|summarize` commands for a normalized local profiling session contract across Android and iOS. - Profile sessions now write run-scoped artifacts under `target/mobench/profile//` and refresh top-level latest-session `profile.json` and `summary.md` convenience files. diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 4921c75..d70d194 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -677,7 +677,8 @@ impl IosBuilder { let crate_dir = self.find_crate_dir()?; let crate_name_underscored = self.crate_name.replace("-", "_"); - // Check if bindings already exist (for repository testing with pre-generated bindings) + // Prefer fresh bindings so schema changes in BenchReport stay in sync with the app. + // Fall back to pre-generated bindings only if generation tooling is unavailable. let bindings_path = self .output_dir .join("ios") @@ -685,12 +686,12 @@ impl IosBuilder { .join("BenchRunner") .join("Generated") .join(format!("{}.swift", crate_name_underscored)); - - if bindings_path.exists() { - if self.verbose { - println!(" Using existing Swift bindings at {:?}", bindings_path); - } - return Ok(()); + let had_existing_bindings = bindings_path.exists(); + if had_existing_bindings && self.verbose { + println!( + " Found existing Swift bindings at {:?}; regenerating to keep the UniFFI schema current", + bindings_path + ); } // Build host library to feed uniffi-bindgen @@ -752,6 +753,15 @@ impl IosBuilder { .unwrap_or(false); if !uniffi_available { + if had_existing_bindings { + if self.verbose { + println!( + " Warning: uniffi-bindgen is unavailable; keeping existing Swift bindings at {:?}", + bindings_path + ); + } + return Ok(()); + } return Err(BenchError::Build( "uniffi-bindgen not found and no pre-generated bindings exist.\n\n\ To fix this, either:\n\ @@ -774,7 +784,18 @@ impl IosBuilder { .arg("swift") .arg("--out-dir") .arg(&out_dir); - run_command(cmd, "uniffi-bindgen swift")?; + if let Err(error) = run_command(cmd, "uniffi-bindgen swift") { + if had_existing_bindings { + if self.verbose { + println!( + " Warning: failed to regenerate Swift bindings ({error}); keeping existing bindings at {:?}", + bindings_path + ); + } + return Ok(()); + } + return Err(error); + } } if self.verbose { diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 5f1346a..b1566a6 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -663,7 +663,7 @@ enum ReportCommand { #[derive(Subcommand, Debug)] enum ProfileCommand { #[command( - about = "Plan or execute a native profiling session; local android-native now performs real simpleperf capture" + about = "Plan or execute a native profiling session; local android-native and ios-instruments now attempt real native capture" )] Run(profile::ProfileRunArgs), /// Render markdown or JSON from a normalized profile manifest. @@ -8734,8 +8734,8 @@ project = "proj" let help = render_profile_run_help(); assert!( - help.contains("Plan or execute a native profiling session; local android-native now performs real simpleperf capture"), - "expected profile run help to describe the real local android-native execution scope, got:\n{help}" + help.contains("Plan or execute a native profiling session; local android-native and ios-instruments now attempt real native capture"), + "expected profile run help to describe the real local Android/iOS execution scope, got:\n{help}" ); assert!( help.contains( @@ -8743,6 +8743,12 @@ project = "proj" ), "expected profile run help to mention real Android native execution, got:\n{help}" ); + assert!( + help.contains( + "local + ios-instruments: attempts real simulator-host sample capture and flamegraph generation" + ), + "expected profile run help to mention real local iOS sample capture, got:\n{help}" + ); assert!( help.contains("--warmup-mode"), "expected profile run help to expose warm/cold profiling mode, got:\n{help}" diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 11c4864..146b2e3 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -10,7 +10,7 @@ use std::process::Command; use crate::{ DevicePlatform, MobileTarget, ProjectLayoutOptions, ResolvedMatrixDevice, RunSpec, load_dotenv_for_layout, persist_mobile_spec, resolve_devices_for_profile, - resolve_project_layout, run_android_build, validate_benchmark_function, + resolve_project_layout, run_android_build, run_ios_build, validate_benchmark_function, }; use mobench_sdk::types::NativeLibraryArtifact; @@ -63,11 +63,11 @@ impl CaptureWarmupMode { #[derive(Debug, Clone, Args)] #[command( - about = "Plan or execute a native profiling session; local android-native now performs real simpleperf capture", + about = "Plan or execute a native profiling session; local android-native and ios-instruments now attempt real native capture", after_help = concat!( "Capability matrix:\n", " local + android-native: attempts real simpleperf capture and symbolization\n", - " local + ios-instruments: planned manifest today; Instruments trace export capture is not implemented yet\n", + " local + ios-instruments: attempts real simulator-host sample capture and flamegraph generation\n", " local + rust-tracing: planned manifest today; structured trace output is local-only\n", " browserstack + android-native: unsupported for native capture in this release\n", " browserstack + ios-instruments: unsupported for native capture in this release\n", @@ -766,6 +766,14 @@ where } fn write_android_flamegraph_html(folded_stacks: &str, output_path: &Path) -> Result<()> { + write_flamegraph_html(folded_stacks, output_path, "Android Native Profile") +} + +fn write_ios_flamegraph_html(folded_stacks: &str, output_path: &Path) -> Result<()> { + write_flamegraph_html(folded_stacks, output_path, "iOS Native Profile") +} + +fn write_flamegraph_html(folded_stacks: &str, output_path: &Path, title: &str) -> Result<()> { if folded_stacks.trim().is_empty() { std::fs::write( output_path, @@ -775,7 +783,7 @@ fn write_android_flamegraph_html(folded_stacks: &str, output_path: &Path) -> Res } let mut options = inferno::flamegraph::Options::default(); - options.title = "Android Native Profile".into(); + options.title = title.into(); let mut rendered = Vec::new(); inferno::flamegraph::from_reader( &mut options, @@ -790,8 +798,25 @@ const DEFAULT_PROFILE_ITERATIONS: u32 = 20; const DEFAULT_PROFILE_WARMUP: u32 = 3; const DEFAULT_ANDROID_CAPTURE_DURATION_SECS: u64 = 10; const DEFAULT_ANDROID_WARMUP_TIMEOUT_SECS: u64 = 60; +const DEFAULT_IOS_CAPTURE_DURATION_SECS: u64 = 10; +const DEFAULT_IOS_BENCH_DELAY_MS: u64 = 1_500; +const DEFAULT_IOS_LOG_TIMEOUT_SECS: u64 = 60; const ANDROID_BENCH_LOG_MARKER: &str = "BENCH_JSON"; +#[derive(Debug, Clone, PartialEq, Eq)] +struct LocalIosSimulator { + udid: String, + name: String, + os_version: String, + state: String, +} + +impl LocalIosSimulator { + fn identifier(&self) -> String { + format!("{}-{}", self.name, self.os_version) + } +} + #[derive(Debug, Clone)] struct AndroidProfilerToolchain { sdk_root: PathBuf, @@ -1281,6 +1306,740 @@ fn android_log_contains_marker(logcat: &str, marker: &str) -> bool { logcat.lines().any(|line| line.contains(marker)) } +fn execute_local_ios_capture(args: &ProfileRunArgs, manifest: &mut ProfileManifest) -> Result<()> { + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: args.crate_path.as_deref(), + config_path: args.config.as_deref(), + })?; + load_dotenv_for_layout(&layout); + validate_benchmark_function(&layout, &args.function)?; + + let spec = RunSpec { + target: MobileTarget::Ios, + function: args.function.clone(), + iterations: DEFAULT_PROFILE_ITERATIONS, + warmup: DEFAULT_PROFILE_WARMUP, + devices: Vec::new(), + browserstack: None, + ios_xcuitest: None, + }; + persist_mobile_spec(&layout, &spec, false)?; + + let requested_device = resolve_profile_device(args)?; + let simulator = resolve_local_ios_simulator(requested_device.as_ref())?; + ensure_local_ios_simulator_booted(&simulator)?; + manifest.capture_metadata.device = Some(simulator.identifier()); + + run_ios_build(&layout, false, false)?; + let app_path = build_local_ios_simulator_app(&layout, &simulator)?; + install_local_ios_app(&simulator, &app_path)?; + + let bundle_id = local_ios_bundle_identifier(&layout.crate_name); + let warmup_mode = manifest + .capture_metadata + .warmup_mode + .unwrap_or(CaptureWarmupMode::Cold); + manifest.capture_metadata.warmup_mode = Some(warmup_mode); + + let raw_sample_path = manifest + .native_capture + .raw_artifacts + .iter() + .find(|artifact| artifact.label == "sample") + .map(|artifact| artifact.path.clone()) + .context("ios profile plan missing sample artifact")?; + let processed_root = manifest + .native_capture + .processed_artifacts + .iter() + .find_map(|artifact| artifact.path.parent().map(Path::to_path_buf)) + .context("ios profile plan missing processed artifact root")?; + if let Some(parent) = raw_sample_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::create_dir_all(&processed_root)?; + + if warmup_mode == CaptureWarmupMode::Warm + && let Err(error) = run_local_ios_warmup_pass(&simulator, &bundle_id, &raw_sample_path) + { + manifest.capture_metadata.warnings.push(format!( + "failed to complete the preparatory iOS warm launch cleanly; continuing with the recorded run cold-ish: {error}" + )); + } + + let log_dir = raw_sample_path + .parent() + .context("ios sample artifact missing parent directory")?; + let stdout_path = log_dir.join("app.stdout.log"); + let stderr_path = log_dir.join("app.stderr.log"); + let result_hold_ms = (DEFAULT_IOS_CAPTURE_DURATION_SECS + 2) * 1_000; + let env_pairs = [ + ( + "MOBENCH_BENCH_DELAY_MS", + DEFAULT_IOS_BENCH_DELAY_MS.to_string(), + ), + ("MOBENCH_PROFILE_RESULT_HOLD_MS", result_hold_ms.to_string()), + ]; + let pid = launch_local_ios_app( + &simulator, + &bundle_id, + &stdout_path, + &stderr_path, + &env_pairs, + )?; + + let sample_result = run_ios_sample_capture(pid, &raw_sample_path); + let log_wait_result = wait_for_ios_log_marker( + &[stdout_path.clone(), stderr_path.clone()], + "BENCH_REPORT_JSON_END", + DEFAULT_IOS_LOG_TIMEOUT_SECS, + ); + let terminate_result = terminate_local_ios_app(&simulator, &bundle_id); + + sample_result?; + if let Err(error) = log_wait_result { + manifest.capture_metadata.warnings.push(format!( + "semantic phase capture may be incomplete because the iOS benchmark log marker was not observed before timeout: {error}" + )); + } + if let Err(error) = terminate_result { + manifest.capture_metadata.warnings.push(format!( + "failed to terminate the profiled iOS simulator app after capture: {error}" + )); + } + + let sample_output = std::fs::read_to_string(&raw_sample_path) + .with_context(|| format!("reading iOS sample output at {}", raw_sample_path.display()))?; + let symbolization = write_ios_processed_outputs(&sample_output, &processed_root)?; + manifest.native_capture.symbolization = symbolization.clone(); + manifest.native_capture.status = match symbolization.status { + CaptureStatus::Planned | CaptureStatus::Captured => CaptureStatus::Captured, + CaptureStatus::Partial | CaptureStatus::Failed => CaptureStatus::Partial, + }; + manifest.capture_metadata.sample_duration_secs = Some(DEFAULT_IOS_CAPTURE_DURATION_SECS); + manifest.capture_metadata.capture_method = Some("sample/simctl".into()); + manifest.capture_metadata.warnings.push(format!( + "ios profile run used default benchmark settings: iterations={}, warmup={}", + DEFAULT_PROFILE_ITERATIONS, DEFAULT_PROFILE_WARMUP + )); + if warmup_mode == CaptureWarmupMode::Warm { + manifest.capture_metadata.warnings.push( + "performed one preparatory warm launch before recording so the measured sample de-emphasizes first-run bridge and UI setup costs".into(), + ); + } + + match read_combined_text_files(&[stdout_path, stderr_path]) { + Ok(logs) => { + if let Some(report) = extract_ios_benchmark_json(&logs) { + merge_semantic_profile_from_bench_report(manifest, &report)?; + } else { + manifest.capture_metadata.warnings.push( + "semantic phase capture was unavailable because the iOS log output did not contain BENCH_REPORT_JSON markers".into(), + ); + } + } + Err(error) => { + manifest.capture_metadata.warnings.push(format!( + "semantic phase capture was unavailable because iOS app logs could not be read: {error}" + )); + } + } + + Ok(()) +} + +fn run_local_ios_warmup_pass( + simulator: &LocalIosSimulator, + bundle_id: &str, + raw_sample_path: &Path, +) -> Result<()> { + let log_dir = raw_sample_path + .parent() + .context("ios sample artifact missing parent directory")?; + let stdout_path = log_dir.join("warmup.stdout.log"); + let stderr_path = log_dir.join("warmup.stderr.log"); + let env_pairs = [("MOBENCH_PROFILE_WARMUP_ONLY", "1".to_string())]; + let _pid = launch_local_ios_app(simulator, bundle_id, &stdout_path, &stderr_path, &env_pairs)?; + + let wait_result = wait_for_ios_log_marker( + &[stdout_path, stderr_path], + "BENCH_REPORT_JSON_END", + DEFAULT_IOS_LOG_TIMEOUT_SECS, + ); + let terminate_result = terminate_local_ios_app(simulator, bundle_id); + + wait_result?; + terminate_result?; + Ok(()) +} + +fn resolve_local_ios_simulator( + requested: Option<&ResolvedProfileDevice>, +) -> Result { + let output = Command::new("xcrun") + .args(["simctl", "list", "devices", "available", "--json"]) + .output() + .context("listing available iOS simulators with simctl")?; + if !output.status.success() { + bail!( + "xcrun simctl list devices available --json failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + let value: Value = serde_json::from_slice(&output.stdout) + .context("parsing iOS simulator list JSON from simctl")?; + let mut simulators = Vec::new(); + let Some(devices) = value.get("devices").and_then(Value::as_object) else { + bail!("simctl JSON did not contain a `devices` object"); + }; + + for (runtime_key, entries) in devices { + let Some(os_version) = parse_ios_runtime_version(runtime_key) else { + continue; + }; + let Some(entries) = entries.as_array() else { + continue; + }; + for entry in entries { + let Some(name) = entry.get("name").and_then(Value::as_str) else { + continue; + }; + let Some(udid) = entry.get("udid").and_then(Value::as_str) else { + continue; + }; + let Some(state) = entry.get("state").and_then(Value::as_str) else { + continue; + }; + if entry + .get("isAvailable") + .and_then(Value::as_bool) + .unwrap_or(true) + { + simulators.push(LocalIosSimulator { + udid: udid.to_string(), + name: name.to_string(), + os_version: os_version.clone(), + state: state.to_string(), + }); + } + } + } + + if simulators.is_empty() { + bail!("no available iOS simulators were returned by `xcrun simctl list devices available`"); + } + + if let Some(requested) = requested { + let mut matches: Vec<_> = simulators + .into_iter() + .filter(|simulator| { + simulator.name == requested.name + && ios_versions_match(&requested.os_version, &simulator.os_version) + }) + .collect(); + matches.sort_by_key(|simulator| simulator.state != "Booted"); + return matches.into_iter().next().ok_or_else(|| { + anyhow::anyhow!( + "requested local iOS simulator {} {} was not found; available simulators include: {}", + requested.name, + requested.os_version, + available_ios_simulator_summary(devices) + ) + }); + } + + simulators.sort_by_key(|simulator| simulator.state != "Booted"); + simulators + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("no iOS simulators were available for local profiling")) +} + +fn available_ios_simulator_summary(devices: &serde_json::Map) -> String { + let mut labels = Vec::new(); + for (runtime_key, entries) in devices { + let Some(os_version) = parse_ios_runtime_version(runtime_key) else { + continue; + }; + let Some(entries) = entries.as_array() else { + continue; + }; + for entry in entries { + if entry + .get("isAvailable") + .and_then(Value::as_bool) + .unwrap_or(true) + && let Some(name) = entry.get("name").and_then(Value::as_str) + { + labels.push(format!("{name} {os_version}")); + } + } + } + labels.sort(); + labels.dedup(); + labels.into_iter().take(6).collect::>().join(", ") +} + +fn parse_ios_runtime_version(runtime_key: &str) -> Option { + runtime_key + .strip_prefix("com.apple.CoreSimulator.SimRuntime.iOS-") + .map(|value| value.replace('-', ".")) +} + +fn ios_versions_match(requested: &str, candidate: &str) -> bool { + let requested = requested.trim(); + let candidate = candidate.trim(); + requested == candidate + || candidate.starts_with(&format!("{requested}.")) + || requested.starts_with(&format!("{candidate}.")) +} + +fn ensure_local_ios_simulator_booted(simulator: &LocalIosSimulator) -> Result<()> { + let output = Command::new("xcrun") + .args(["simctl", "bootstatus", &simulator.udid, "-b"]) + .output() + .with_context(|| format!("booting iOS simulator {}", simulator.identifier()))?; + if !output.status.success() { + bail!( + "xcrun simctl bootstatus {} -b failed with status {}\nstdout:\n{}\nstderr:\n{}", + simulator.udid, + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + +fn build_local_ios_simulator_app( + layout: &crate::ResolvedProjectLayout, + simulator: &LocalIosSimulator, +) -> Result { + let project_path = layout + .output_dir + .join("ios") + .join("BenchRunner") + .join("BenchRunner.xcodeproj"); + if !project_path.exists() { + bail!( + "generated BenchRunner project was not found at {}; run `cargo mobench build --target ios` or rerun the profile build step", + project_path.display() + ); + } + + let build_root = layout + .output_dir + .join("ios") + .join("profile-simulator-build"); + let mut cmd = Command::new("xcodebuild"); + cmd.arg("-project") + .arg(&project_path) + .arg("-target") + .arg("BenchRunner") + .arg("-sdk") + .arg("iphonesimulator") + .arg("-configuration") + .arg("Debug") + .arg("build") + .arg(format!("SYMROOT={}", build_root.display())) + .arg(format!("OBJROOT={}", build_root.display())) + .arg("CODE_SIGNING_ALLOWED=NO") + .arg("CODE_SIGNING_REQUIRED=NO"); + let output = cmd.output().with_context(|| { + format!( + "building the local iOS BenchRunner simulator app for {}", + simulator.identifier() + ) + })?; + let app_path = build_root + .join("Debug-iphonesimulator") + .join("BenchRunner.app"); + if !output.status.success() || !app_path.exists() { + bail!( + "xcodebuild simulator build failed for {}\nproject: {}\napp path: {}\nexit status: {}\nstdout:\n{}\nstderr:\n{}", + simulator.identifier(), + project_path.display(), + app_path.display(), + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(app_path) +} + +fn install_local_ios_app(simulator: &LocalIosSimulator, app_path: &Path) -> Result<()> { + let output = Command::new("xcrun") + .args(["simctl", "install", &simulator.udid]) + .arg(app_path) + .output() + .with_context(|| { + format!( + "installing {} on iOS simulator {}", + app_path.display(), + simulator.identifier() + ) + })?; + if !output.status.success() { + bail!( + "xcrun simctl install failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + +fn local_ios_bundle_identifier(crate_name: &str) -> String { + format!( + "dev.world.{}.BenchRunner", + mobench_sdk::codegen::sanitize_bundle_id_component(crate_name) + ) +} + +fn launch_local_ios_app( + simulator: &LocalIosSimulator, + bundle_id: &str, + stdout_path: &Path, + stderr_path: &Path, + env_pairs: &[(&str, String)], +) -> Result { + let stdout_path = absolutize_profile_path(stdout_path)?; + let stderr_path = absolutize_profile_path(stderr_path)?; + + if let Some(parent) = stdout_path.parent() { + std::fs::create_dir_all(parent)?; + } + if let Some(parent) = stderr_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&stdout_path, "")?; + std::fs::write(&stderr_path, "")?; + + let mut cmd = Command::new("xcrun"); + cmd.args(["simctl", "launch"]) + .arg(format!("--stdout={}", stdout_path.display())) + .arg(format!("--stderr={}", stderr_path.display())) + .arg("--terminate-running-process") + .arg(&simulator.udid) + .arg(bundle_id); + for (key, value) in env_pairs { + cmd.env(format!("SIMCTL_CHILD_{key}"), value); + } + + let output = cmd.output().with_context(|| { + format!( + "launching {} on iOS simulator {}", + bundle_id, + simulator.identifier() + ) + })?; + if !output.status.success() { + bail!( + "xcrun simctl launch failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + parse_simctl_launch_pid(&String::from_utf8_lossy(&output.stdout)) +} + +fn absolutize_profile_path(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir() + .context("resolving absolute path for iOS simulator logs")? + .join(path)) + } +} + +fn parse_simctl_launch_pid(stdout: &str) -> Result { + stdout + .split_whitespace() + .rev() + .find_map(|token| token.parse::().ok()) + .context("simctl launch did not report an application pid") +} + +fn terminate_local_ios_app(simulator: &LocalIosSimulator, bundle_id: &str) -> Result<()> { + let output = Command::new("xcrun") + .args(["simctl", "terminate", &simulator.udid, bundle_id]) + .output() + .with_context(|| { + format!( + "terminating {} on iOS simulator {}", + bundle_id, + simulator.identifier() + ) + })?; + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("found nothing to terminate") || stderr.contains("not running") { + return Ok(()); + } + + bail!( + "xcrun simctl terminate failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + stderr + ); +} + +fn run_ios_sample_capture(pid: u32, output_path: &Path) -> Result<()> { + let output = Command::new("sample") + .arg(pid.to_string()) + .arg(DEFAULT_IOS_CAPTURE_DURATION_SECS.to_string()) + .arg("1") + .arg("-mayDie") + .arg("-file") + .arg(output_path) + .output() + .with_context(|| format!("sampling iOS simulator process {pid}"))?; + if !output.status.success() { + bail!( + "sample failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + +fn wait_for_ios_log_marker(paths: &[PathBuf], marker: &str, timeout_secs: u64) -> Result<()> { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); + while std::time::Instant::now() < deadline { + if let Ok(logs) = read_combined_text_files(paths) + && logs.contains(marker) + { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + bail!("timed out waiting for iOS log marker `{marker}`"); +} + +fn read_combined_text_files(paths: &[PathBuf]) -> Result { + let mut combined = String::new(); + for path in paths { + if !path.exists() { + continue; + } + combined.push_str( + &std::fs::read_to_string(path) + .with_context(|| format!("reading text file at {}", path.display()))?, + ); + if !combined.ends_with('\n') { + combined.push('\n'); + } + } + Ok(combined) +} + +fn write_ios_processed_outputs( + sample_output: &str, + processed_root: &Path, +) -> Result { + std::fs::create_dir_all(processed_root)?; + std::fs::write(processed_root.join("native-report.txt"), sample_output)?; + + match collapse_ios_sample_call_graph(sample_output) { + Ok((folded_stacks, mut record)) => { + std::fs::write(processed_root.join("stacks.folded"), &folded_stacks)?; + write_ios_flamegraph_html(&folded_stacks, &processed_root.join("flamegraph.html"))?; + if record.tool.is_none() { + record.tool = Some("sample".into()); + } + Ok(record) + } + Err(error) => { + std::fs::write(processed_root.join("stacks.folded"), "")?; + write_ios_flamegraph_html("", &processed_root.join("flamegraph.html"))?; + Ok(SymbolizationRecord { + status: CaptureStatus::Failed, + tool: Some("sample".into()), + resolved_frames: 0, + unresolved_frames: 0, + notes: vec![format!("failed to collapse iOS sample call graph: {error}")], + }) + } + } +} + +#[cfg(test)] +fn collapse_ios_sample_call_graph_to_folded_stacks(sample_output: &str) -> Result { + collapse_ios_sample_call_graph(sample_output).map(|(folded_stacks, _record)| folded_stacks) +} + +fn collapse_ios_sample_call_graph(sample_output: &str) -> Result<(String, SymbolizationRecord)> { + #[derive(Clone)] + struct StackFrame { + indent: usize, + frame: String, + unresolved: bool, + } + + #[derive(Clone)] + struct ParsedNode { + depth: usize, + count: u64, + frames: Vec, + unresolved_frames: u64, + } + + let mut saw_call_graph = false; + let mut in_call_graph = false; + let mut stack: Vec = Vec::new(); + let mut nodes = Vec::new(); + + for line in sample_output.lines() { + let trimmed = line.trim(); + if !in_call_graph { + if trimmed == "Call graph:" { + saw_call_graph = true; + in_call_graph = true; + } + continue; + } + if trimmed.starts_with("Total number in stack") + || trimmed.starts_with("Sort by top of stack") + || trimmed.starts_with("Binary Images:") + { + break; + } + let Some(parsed) = parse_ios_sample_call_graph_line(line) else { + continue; + }; + if parsed.is_thread_root { + stack.clear(); + continue; + } + + if !parsed.is_plus { + while stack + .last() + .is_some_and(|existing| existing.indent >= parsed.indent) + { + stack.pop(); + } + } + + stack.push(StackFrame { + indent: parsed.indent, + frame: parsed.frame.clone(), + unresolved: parsed.frame == "???", + }); + nodes.push(ParsedNode { + depth: stack.len(), + count: parsed.count, + frames: stack.iter().map(|frame| frame.frame.clone()).collect(), + unresolved_frames: stack.iter().filter(|frame| frame.unresolved).count() as u64, + }); + } + + if !saw_call_graph { + bail!("iOS sample output did not contain a `Call graph:` section"); + } + if nodes.is_empty() { + bail!("iOS sample output did not contain any callable frames"); + } + + let mut folded_lines = Vec::new(); + let mut resolved_frames = 0_u64; + let mut unresolved_frames = 0_u64; + for (index, node) in nodes.iter().enumerate() { + let next_depth = nodes.get(index + 1).map(|next| next.depth).unwrap_or(0); + if next_depth > node.depth { + continue; + } + if node.frames.is_empty() { + continue; + } + folded_lines.push(format!("{} {}", node.frames.join(";"), node.count)); + unresolved_frames += node.unresolved_frames; + resolved_frames += node.frames.len() as u64 - node.unresolved_frames; + } + + let mut notes = Vec::new(); + let status = if folded_lines.is_empty() { + notes.push("no leaf frames were emitted from the iOS sample call graph".into()); + CaptureStatus::Failed + } else if unresolved_frames > 0 { + notes.push(format!( + "iOS sample capture retained {unresolved_frames} unresolved frame(s) as `???`" + )); + CaptureStatus::Partial + } else { + CaptureStatus::Captured + }; + + Ok(( + folded_lines.join("\n"), + SymbolizationRecord { + status, + tool: Some("sample".into()), + resolved_frames, + unresolved_frames, + notes, + }, + )) +} + +struct ParsedIosSampleLine { + indent: usize, + count: u64, + frame: String, + is_plus: bool, + is_thread_root: bool, +} + +fn parse_ios_sample_call_graph_line(line: &str) -> Option { + let indent = line.chars().take_while(|ch| *ch == ' ').count(); + let mut remainder = &line[indent..]; + let is_plus = remainder.starts_with("+ "); + if is_plus { + remainder = &remainder[2..]; + } + let remainder = remainder.trim_start(); + let digits_end = remainder.find(|ch: char| !ch.is_ascii_digit())?; + let count = remainder[..digits_end].parse().ok()?; + let frame_part = remainder[digits_end..].trim_start(); + let frame = frame_part + .split(" (in ") + .next() + .unwrap_or(frame_part) + .split(" [") + .next() + .unwrap_or(frame_part) + .trim(); + if frame.is_empty() { + return None; + } + let is_thread_root = frame.starts_with("Thread_"); + + Some(ParsedIosSampleLine { + indent, + count, + frame: frame.to_string(), + is_plus, + is_thread_root, + }) +} + fn extract_benchmark_reports_from_logs(logs: &str) -> Vec { let mut results = Vec::new(); if let Some(json) = extract_ios_benchmark_json(logs) { @@ -1581,13 +2340,23 @@ fn build_capture_plan( ), ProfileBackend::IosInstruments => ( vec![ArtifactRecord { - label: "time-profiler".into(), - path: raw_root.join("time-profiler.trace"), - }], - vec![ArtifactRecord { - label: "xctrace-export".into(), - path: processed_root.join("time-profiler.xml"), + label: "sample".into(), + path: raw_root.join("sample.txt"), }], + vec![ + ArtifactRecord { + label: "collapsed-stacks".into(), + path: processed_root.join("stacks.folded"), + }, + ArtifactRecord { + label: "native-report".into(), + path: processed_root.join("native-report.txt"), + }, + ArtifactRecord { + label: "flamegraph".into(), + path: processed_root.join("flamegraph.html"), + }, + ], ), ProfileBackend::RustTracing => ( vec![ArtifactRecord { @@ -1632,7 +2401,7 @@ fn build_capture_plan( warmup_mode: resolve_capture_warmup_mode(args.provider, backend, args.warmup_mode), capture_method: Some(match backend { ProfileBackend::AndroidNative => "simpleperf".into(), - ProfileBackend::IosInstruments => "instruments".into(), + ProfileBackend::IosInstruments => "sample".into(), ProfileBackend::RustTracing => "trace-events".into(), ProfileBackend::Auto => unreachable!("auto backend should resolve before planning"), }), @@ -1788,9 +2557,13 @@ fn execute_capture( execute_local_android_capture, ); } - (ProfileProvider::Local, ProfileBackend::IosInstruments) => Some( - "local ios-instruments capture is not implemented yet; this session records the planned Instruments trace/XML artifact contract only", - ), + (ProfileProvider::Local, ProfileBackend::IosInstruments) => { + return execute_capture_with_local_ios_executor( + args, + manifest, + execute_local_ios_capture, + ); + } (ProfileProvider::Local, ProfileBackend::RustTracing) => Some( "local rust-tracing capture is not implemented yet; this session records the planned trace-events artifact contract only", ), @@ -1803,7 +2576,7 @@ fn execute_capture( (ProfileProvider::Browserstack, ProfileBackend::IosInstruments) => { bail!(browserstack_native_capture_unsupported_message( "ios-instruments", - "local iOS profiling produces Instruments traces (`time-profiler.trace`) and XML exports (`time-profiler.xml`), not flamegraphs", + "local iOS profiling produces raw sample output (`sample.txt`), collapsed stacks, and `flamegraph.html` from a simulator-hosted capture", )); } (ProfileProvider::Browserstack, ProfileBackend::RustTracing) => { @@ -1835,6 +2608,21 @@ where Ok(()) } +fn execute_capture_with_local_ios_executor( + args: &ProfileRunArgs, + manifest: &mut ProfileManifest, + execute: E, +) -> Result<()> +where + E: FnOnce(&ProfileRunArgs, &mut ProfileManifest) -> Result<()>, +{ + if let Err(error) = execute(args, manifest) { + mark_ios_capture_attempt_failed(manifest, &error); + return Err(error); + } + Ok(()) +} + fn mark_android_capture_attempt_failed(manifest: &mut ProfileManifest, error: &anyhow::Error) { manifest.native_capture.status = CaptureStatus::Failed; manifest.native_capture.symbolization.status = CaptureStatus::Failed; @@ -1863,6 +2651,34 @@ fn mark_android_capture_attempt_failed(manifest: &mut ProfileManifest, error: &a } } +fn mark_ios_capture_attempt_failed(manifest: &mut ProfileManifest, error: &anyhow::Error) { + manifest.native_capture.status = CaptureStatus::Failed; + manifest.native_capture.symbolization.status = CaptureStatus::Failed; + + let failure_note = format!("local ios-instruments capture failed: {error}"); + if !manifest + .native_capture + .symbolization + .notes + .iter() + .any(|note| note == &failure_note) + { + manifest + .native_capture + .symbolization + .notes + .push(failure_note.clone()); + } + if !manifest + .capture_metadata + .warnings + .iter() + .any(|warning| warning == &failure_note) + { + manifest.capture_metadata.warnings.push(failure_note); + } +} + fn browserstack_native_capture_unsupported_message( backend_label: &str, artifact_guidance: &str, @@ -1891,13 +2707,12 @@ fn select_viewer_hint( } } ProfileBackend::IosInstruments => { - if !raw_artifacts.is_empty() { - Some("Open artifacts/raw/time-profiler.trace in Instruments".into()) + if format != ProfileFormat::Native && !processed_artifacts.is_empty() { + Some("Open artifacts/processed/flamegraph.html in a browser".into()) + } else if !raw_artifacts.is_empty() { + Some("Inspect artifacts/raw/sample.txt for the raw iOS sample call graph".into()) } else if !processed_artifacts.is_empty() { - Some( - "Inspect artifacts/processed/time-profiler.xml or rerun with --format both to keep the .trace bundle" - .into(), - ) + Some("Open artifacts/processed/flamegraph.html in a browser".into()) } else { None } @@ -2695,7 +3510,7 @@ mod tests { } #[test] - fn ios_backend_allocates_trace_bundle_and_export_paths() { + fn ios_backend_allocates_sample_and_flamegraph_artifacts() { let plan = build_capture_plan( &sample_run_args( MobileTarget::Ios, @@ -2718,14 +3533,48 @@ mod tests { plan.native_capture .raw_artifacts .iter() - .any(|p| p.path.ends_with("time-profiler.trace")) + .any(|p| p.path.ends_with("sample.txt")) ); assert!( plan.native_capture .processed_artifacts .iter() - .any(|p| p.path.ends_with("time-profiler.xml")) + .any(|p| p.path.ends_with("stacks.folded")) ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("native-report.txt")) + ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("flamegraph.html")) + ); + } + + #[test] + fn ios_sample_call_graph_collapses_into_folded_stacks() { + let sample = r#"Call graph: + 778 Thread_27177597 DispatchQueue_1: com.apple.main-thread (serial) + 778 start (in dyld) + 7184 [0x18ac81d54] + 776 uniffi_sample_fns_fn_func_run_benchmark (in sample_fns) + 88 [0x100000588] + + 776 sample_fns::run_benchmark (in sample_fns) + 40 [0x100000610] + + 776 mobench_sdk::timing::run_closure (in sample_fns) + 24 [0x100000650] + + 776 sample_fns::fibonacci_batch (in sample_fns) + 24 [0x100000710] + + 776 sample_fns::fibonacci (in sample_fns) + 24 [0x100000780] + 2 write (in libsystem_kernel.dylib) + 40 [0x18b00c840] +"#; + + let folded = + collapse_ios_sample_call_graph_to_folded_stacks(sample).expect("collapse sample"); + + assert!(folded.contains( + "start;uniffi_sample_fns_fn_func_run_benchmark;sample_fns::run_benchmark;mobench_sdk::timing::run_closure;sample_fns::fibonacci_batch;sample_fns::fibonacci 776" + )); + assert!(folded.contains("start;write 2")); } #[test] @@ -2831,7 +3680,8 @@ mod tests { ios_args.output_dir = dir.path().to_path_buf(); cmd_profile_run(&android_args, true).expect("write first planned profile session"); - cmd_profile_run(&ios_args, false).expect("write second planned profile session"); + run_profile_session_with_executor(&ios_args, false, |_args, _target, _manifest| Ok(())) + .expect("write second profile session"); let android_run_dir = dir.path().join("android-sample_fns--fibonacci"); let ios_run_dir = dir.path().join("ios-sample_fns--checksum"); From 20a4b7b87db98e5a6056c22f49b8f8cb0b71e2ef Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 20:14:46 -0700 Subject: [PATCH 143/196] ci: add shared mobench plot reporting --- .github/workflows/mobile-bench-selftest.yml | 2 +- .github/workflows/mobile-bench.yml | 2 +- .github/workflows/reusable-bench.yml | 359 +++++++++++++++++--- 3 files changed, 321 insertions(+), 42 deletions(-) diff --git a/.github/workflows/mobile-bench-selftest.yml b/.github/workflows/mobile-bench-selftest.yml index c0e878f..ea37f8c 100644 --- a/.github/workflows/mobile-bench-selftest.yml +++ b/.github/workflows/mobile-bench-selftest.yml @@ -22,7 +22,7 @@ on: permissions: actions: read - contents: read + contents: write pull-requests: write jobs: diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index fa52a84..aff6bf5 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -55,7 +55,7 @@ on: permissions: actions: read - contents: read + contents: write issues: write pull-requests: write diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index d6fd0cf..76b84cc 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -105,7 +105,7 @@ on: permissions: actions: read - contents: read + contents: write pull-requests: write env: @@ -596,28 +596,297 @@ jobs: name: mobench-results-android path: results/android - - name: Summarize iOS results - if: needs.ios.result == 'success' + - name: Setup Python for plot rendering + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install plot rendering dependencies shell: bash run: | - echo "## iOS Benchmark Results" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" + set -euo pipefail + python -m pip install --upgrade pip + python -m pip install matplotlib + + - name: Render plot-capable platform summaries + shell: bash + run: | + set -euo pipefail + mkdir -p rendered + rendered_count=0 + + render_platform_summary() { + local platform="$1" + local results_dir="results/${platform}" + if [ ! -d "${results_dir}" ]; then + return 0 + fi + + local summary_json + summary_json=$(find "${results_dir}" -type f -path "*/mobench/ci/${platform}/summary.json" | head -1) + if [ -z "${summary_json}" ]; then + echo "::warning::No ${platform} summary.json found under ${results_dir}" + return 0 + fi + + mkdir -p "rendered/${platform}" - if [ -d results/ios ]; then cargo-mobench ci summarize \ - --results-dir results/ios \ + --results-dir "${results_dir}" \ --output-format table || true - cargo-mobench ci summarize \ - --results-dir results/ios \ - --output-format markdown \ - --output-file /tmp/ios-summary.md || true + cargo-mobench report summarize \ + --summary "${summary_json}" \ + --output "rendered/${platform}/summary.md" \ + --plots require + } + + for platform in ios android; do + if render_platform_summary "${platform}"; then + if [ -f "rendered/${platform}/summary.md" ]; then + rendered_count=$((rendered_count + 1)) + fi + fi + done + + if [ "${rendered_count}" -eq 0 ]; then + echo "::error::No benchmark summaries were rendered." + exit 1 + fi + + - name: Build combined plot summary JSON + shell: bash + run: | + set -euo pipefail + python - <<'PY' + import json + from pathlib import Path + + rendered_root = Path("rendered") + rendered_root.mkdir(parents=True, exist_ok=True) + combined_dir = rendered_root / "combined" + combined_dir.mkdir(parents=True, exist_ok=True) + + platform_summaries = [] + for platform in ("ios", "android"): + results_dir = Path("results") / platform + if not results_dir.is_dir(): + continue + matches = sorted( + path for path in results_dir.rglob("summary.json") + if path.as_posix().endswith(f"/mobench/ci/{platform}/summary.json") + ) + if matches: + platform_summaries.append((platform, matches[0])) + + if not platform_summaries: + raise SystemExit("No platform summary.json files found for combined plots") + + def collect_benchmark_results(value, combined): + if isinstance(value, dict): + benchmark_results = value.get("benchmark_results") + if isinstance(benchmark_results, dict): + for device_label, entries in benchmark_results.items(): + bucket = combined.setdefault(device_label, []) + if isinstance(entries, list): + bucket.extend(entries) + for nested in value.values(): + collect_benchmark_results(nested, combined) + elif isinstance(value, list): + for nested in value: + collect_benchmark_results(nested, combined) + + combined_device_summaries = [] + combined_devices = [] + combined_benchmark_results = {} + primary_target = None + generated_at = None + generated_at_unix = 0 + iterations = 0 + warmup = 0 + + for platform, summary_path in platform_summaries: + payload = json.loads(summary_path.read_text()) + summary = payload.get("summary") + if not isinstance(summary, dict): + raise SystemExit(f"Missing top-level summary in {summary_path}") + + if primary_target is None: + primary_target = summary.get("target") or platform + iterations = int(summary.get("iterations") or 0) + warmup = int(summary.get("warmup") or 0) + + for device in summary.get("devices") or []: + if device not in combined_devices: + combined_devices.append(device) + combined_device_summaries.extend(summary.get("device_summaries") or []) + + ts = int(summary.get("generated_at_unix") or 0) + if generated_at is None or ts >= generated_at_unix: + generated_at_unix = ts + generated_at = summary.get("generated_at") + + collect_benchmark_results(payload, combined_benchmark_results) + + if primary_target is None: + raise SystemExit("Unable to infer a target for combined plots") + + combined_summary = { + "summary": { + "generated_at": generated_at or "", + "generated_at_unix": generated_at_unix, + "target": primary_target, + "function": "combined-device-comparison", + "iterations": iterations, + "warmup": warmup, + "devices": combined_devices, + "device_summaries": combined_device_summaries, + }, + "benchmark_results": combined_benchmark_results, + } + + output_path = combined_dir / "summary.json" + output_path.write_text(json.dumps(combined_summary, indent=2) + "\n") + PY + + - name: Render shared device comparison plots + shell: bash + run: | + set -euo pipefail + cargo-mobench report summarize \ + --summary rendered/combined/summary.json \ + --output rendered/combined/summary.md \ + --plots require + + - name: Publish plot assets + id: publish_plots + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + ASSET_BRANCH: mobench-plots + run: | + set -euo pipefail + + if ! find rendered -type f -path "*/plots/*.svg" | grep -q .; then + echo "::error::Expected rendered plots under rendered/**/plots, but none were found." + exit 1 + fi + + remote="https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" + asset_path="runs/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + + publish_once() { + local publish_root="$1" + + if git clone --quiet --branch "${ASSET_BRANCH}" "${remote}" "${publish_root}" 2>/dev/null; then + : + else + git clone --quiet "${remote}" "${publish_root}" + git -C "${publish_root}" checkout --orphan "${ASSET_BRANCH}" + git -C "${publish_root}" rm -rf . >/dev/null 2>&1 || true + fi + + git -C "${publish_root}" config user.name "github-actions[bot]" + git -C "${publish_root}" config user.email "41898282+github-actions[bot]@users.noreply.github.com" + mkdir -p "${publish_root}/${asset_path}" + + for platform in ios android combined; do + if [ -d "rendered/${platform}/plots" ]; then + mkdir -p "${publish_root}/${asset_path}/${platform}" + rm -rf "${publish_root}/${asset_path}/${platform}/plots" + cp -R "rendered/${platform}/plots" "${publish_root}/${asset_path}/${platform}/plots" + fi + done + + git -C "${publish_root}" add "${asset_path}" + if git -C "${publish_root}" diff --cached --quiet; then + echo "::error::No rendered plot asset changes were staged for publish." + return 1 + fi + + if git -C "${publish_root}" commit -m "mobench plots for run ${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >/dev/null \ + && git -C "${publish_root}" push origin "${ASSET_BRANCH}" >/dev/null; then + return 0 + fi - if [ -f /tmp/ios-summary.md ]; then - cat /tmp/ios-summary.md >> "$GITHUB_STEP_SUMMARY" + return 1 + } + + published=0 + for attempt in 1 2 3; do + publish_root="$(mktemp -d)" + if publish_once "${publish_root}"; then + published=1 + break fi + echo "::warning::Plot asset publish attempt ${attempt} failed; retrying with the latest branch state." + done + + if [ "${published}" -ne 1 ]; then + echo "::error::Unable to publish plot assets after 3 attempts." + exit 1 + fi + + echo "base_url=https://raw.githubusercontent.com/${REPO}/${ASSET_BRANCH}/${asset_path}" >> "$GITHUB_OUTPUT" + + - name: Rewrite rendered summaries for GitHub markdown + shell: bash + env: + PLOT_BASE_URL: ${{ steps.publish_plots.outputs.base_url }} + run: | + set -euo pipefail + python - <<'PY' + import os + from pathlib import Path + + plot_base_url = os.environ.get("PLOT_BASE_URL", "").rstrip("/") + if not plot_base_url: + raise SystemExit("Missing published plot base URL") + + marker = "### Device Comparison Plots\n" + + def strip_plot_block(text: str) -> str: + index = text.find(marker) + if index == -1: + return text.rstrip() + "\n" + return text[:index].rstrip() + "\n" + + for platform in ("ios", "android"): + input_path = Path(f"rendered/{platform}/summary.md") + output_path = Path(f"rendered/{platform}/github-summary.md") + if not input_path.exists(): + continue + output_path.write_text(strip_plot_block(input_path.read_text())) + + combined_input = Path("rendered/combined/summary.md") + if combined_input.exists(): + summary_markdown = combined_input.read_text() + index = summary_markdown.find(marker) + if index == -1: + raise SystemExit("Combined summary markdown did not include a Device Comparison Plots section") + plot_section = summary_markdown[index + len(marker):].lstrip() + plot_section = plot_section.replace( + "](plots/", + f"]({plot_base_url}/combined/plots/", + ) + combined_output = Path("rendered/combined/github-summary.md") + combined_output.write_text( + "## Device Comparison Plots\n\n" + plot_section.rstrip() + "\n" + ) + PY + + - name: Summarize iOS results + if: needs.ios.result == 'success' + shell: bash + run: | + echo "## iOS Benchmark Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if [ -f rendered/ios/github-summary.md ]; then + cat rendered/ios/github-summary.md >> "$GITHUB_STEP_SUMMARY" else - echo "No iOS results found." >> "$GITHUB_STEP_SUMMARY" + echo "No iOS benchmark summary produced." >> "$GITHUB_STEP_SUMMARY" fi echo "" >> "$GITHUB_STEP_SUMMARY" @@ -628,24 +897,24 @@ jobs: echo "## Android Benchmark Results" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" - if [ -d results/android ]; then - cargo-mobench ci summarize \ - --results-dir results/android \ - --output-format table || true - - cargo-mobench ci summarize \ - --results-dir results/android \ - --output-format markdown \ - --output-file /tmp/android-summary.md || true - - if [ -f /tmp/android-summary.md ]; then - cat /tmp/android-summary.md >> "$GITHUB_STEP_SUMMARY" - fi + if [ -f rendered/android/github-summary.md ]; then + cat rendered/android/github-summary.md >> "$GITHUB_STEP_SUMMARY" else - echo "No Android results found." >> "$GITHUB_STEP_SUMMARY" + echo "No Android benchmark summary produced." >> "$GITHUB_STEP_SUMMARY" fi echo "" >> "$GITHUB_STEP_SUMMARY" + - name: Summarize shared device comparison plots + shell: bash + run: | + if [ -f rendered/combined/github-summary.md ]; then + cat rendered/combined/github-summary.md >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + else + echo "::error::No combined plot summary was produced." + exit 1 + fi + # NOTE: This step requires the caller workflow to have `pull-requests: write` permission # so that github.token can post/update PR comments. - name: Post sticky PR comment @@ -657,40 +926,45 @@ jobs: run: | MARKER="" - # Build markdown body BODY="${MARKER} ## Mobench Benchmark Results " - for dir in results/ios results/android; do - if [ -d "$dir" ]; then - PLATFORM_MD=$(cargo-mobench ci summarize --results-dir "$dir" --output-format markdown 2>/dev/null || true) - if [ -n "$PLATFORM_MD" ]; then - BODY="${BODY}${PLATFORM_MD} + if [ -f rendered/ios/github-summary.md ]; then + BODY="${BODY}## iOS Benchmark Results + + $(cat rendered/ios/github-summary.md) " - fi - fi - done + fi + if [ -f rendered/android/github-summary.md ]; then + BODY="${BODY}## Android Benchmark Results + + $(cat rendered/android/github-summary.md) + + " + fi + if [ -f rendered/combined/github-summary.md ]; then + BODY="${BODY}$(cat rendered/combined/github-summary.md) + + " + fi BODY="${BODY} --- *Posted by [mobench](https://github.com/worldcoin/mobile-bench-rs) · $(date -u '+%Y-%m-%d %H:%M UTC')*" - # Find existing comment with marker EXISTING_COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ 2>/dev/null | head -1) if [ -n "$EXISTING_COMMENT_ID" ]; then - # Update existing comment gh api "repos/${REPO}/issues/comments/${EXISTING_COMMENT_ID}" \ -X PATCH \ -f body="${BODY}" \ --silent echo "Updated existing PR comment #${EXISTING_COMMENT_ID}" else - # Create new comment gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ -f body="${BODY}" \ --silent @@ -799,6 +1073,11 @@ jobs: fi done + if [ -d rendered ]; then + mkdir -p history-bundle/rendered + cp -R rendered/. history-bundle/rendered/ + fi + # Generate manifest jq -n \ --arg ts "$TIMESTAMP" \ From 53cb3428f8789ae33e2f0c19e42d3747138dd42b Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 20:29:41 -0700 Subject: [PATCH 144/196] docs: add flamegraph viewer design --- .../2026-03-26-flamegraph-viewer-design.md | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 docs/plans/2026-03-26-flamegraph-viewer-design.md diff --git a/docs/plans/2026-03-26-flamegraph-viewer-design.md b/docs/plans/2026-03-26-flamegraph-viewer-design.md new file mode 100644 index 0000000..b5f40f9 --- /dev/null +++ b/docs/plans/2026-03-26-flamegraph-viewer-design.md @@ -0,0 +1,175 @@ +# Flamegraph Viewer Design + +## Context + +The current `mobench` flamegraph artifact is now technically correct enough to show the benchmark stack, but it is still a poor primary UX. The page opens as a raw `inferno` SVG with minimal controls, full-process noise dominates the default view, and common analysis tasks such as switching between benchmark-only and full-process stacks, brushing a range, and navigating zoom history are cumbersome. + +This design changes the flamegraph artifact from "a standalone SVG file" into "a standalone flamegraph viewer page" while preserving the existing raw profiling outputs. + +## Goals + +- Make the default profiling artifact easy to navigate without external tooling. +- Default the user to a benchmark-focused view while preserving access to full-process data. +- Support toggling between benchmark-only and full-process flamegraphs in one artifact. +- Add explicit interaction affordances for click zoom, drag-to-select range zoom, reset, and zoom history. +- Keep the output standalone and local-file friendly. + +## Non-Goals + +- Rebuild flamegraph layout in the browser from raw stack data. +- Replace `inferno` with a custom canvas renderer. +- Remove or hide the existing raw artifacts such as `sample.txt`, `stacks.folded`, or `native-report.txt`. + +## Recommended Approach + +Generate a custom standalone HTML shell around two pre-rendered flamegraph SVGs: + +- `Benchmark Only` +- `Full Process` + +The HTML shell becomes the primary artifact (`flamegraph.html`) and provides the controls, summaries, and navigation state. The SVGs remain pre-rendered by `inferno`, which keeps generation deterministic and avoids moving layout logic into client-side JavaScript. + +## Alternatives Considered + +### Extend `inferno`'s emitted SVG in place + +This is the lightest possible patch, but the embedded script is not structured for dual datasets, brush-zoom, or a real control surface. It would become brittle quickly. + +### Replace the output with a custom canvas app + +This has the highest interaction ceiling, but it is unnecessary for the current scope and would create a much larger maintenance burden. + +## Product Shape + +The generated viewer page should contain: + +- A sticky toolbar with: + - `Benchmark Only` + - `Full Process` + - `Back` + - `Forward` + - `Reset` + - `Search` +- A summary strip showing: + - current mode + - current root frame + - current selection width + - visible sample count +- A details pane or summary section listing the hottest frames in the current mode and zoom root +- The current flamegraph visualization +- Links to supporting artifacts: + - `native-report.txt` + - `stacks.folded` + - `benchmark.focused.folded` + - `flamegraph.full.svg` + - `flamegraph.focused.svg` + +The default mode should be `Benchmark Only`. + +## Architecture + +The implementation has three layers. + +### 1. Dataset generation + +Keep generating the existing full folded stack dataset. Add a second derived folded dataset, `benchmark.focused.folded`, by trimming each stack down to the first benchmark anchor and everything below it. + +### 2. Rendering + +Render two SVG flamegraphs with `inferno`: + +- full process +- benchmark focused + +These become `flamegraph.full.svg` and `flamegraph.focused.svg`. + +### 3. Viewer shell + +Generate a standalone HTML page that embeds both SVGs, shows only one at a time, and layers our own navigation and summary UI on top. + +## Benchmark-Focused Derivation + +The benchmark-only dataset is produced by matching the first benchmark anchor in each stack and dropping all frames above that anchor. + +Candidate anchors include: + +- iOS: + - `runBenchmark(spec:)` + - `uniffi_*` + - `sample_fns::run_benchmark` + - `mobench_sdk::timing::run_closure` +- Android: + - UniFFI/JNA entrypoints + - Rust `run_benchmark` + - `mobench_sdk::timing::run_closure` + +The anchor list should be data-driven so new benchmark surfaces can be added without changing the renderer. + +If a stack has no benchmark anchor, it is excluded from the focused dataset. If no stacks match at all, the viewer should surface that fact and degrade to the full-process view cleanly. + +## Interaction Model + +The viewer should support: + +### Click zoom + +Clicking a frame zooms into that frame. + +### Brush zoom + +Dragging horizontally across the graph selects an x-axis sample range. On mouse-up, that range expands to the full graph width. + +### Navigation history + +Every click zoom or brush zoom creates a history entry. The toolbar exposes: + +- `Back` +- `Forward` +- `Reset` + +Each mode should preserve its own history stack. + +## Output Contract + +Processed artifacts should expand to include: + +- `stacks.folded` +- `benchmark.focused.folded` +- `native-report.txt` +- `flamegraph.full.svg` +- `flamegraph.focused.svg` +- `flamegraph.html` + +The profile manifest should record both flamegraph datasets and the viewer artifact explicitly so summaries and downstream tooling can refer to them unambiguously. + +## Failure Handling + +- If the focused dataset is empty, the `Benchmark Only` tab should remain available but show a warning and allow a one-click switch to `Full Process`. +- If only one SVG renders successfully, the viewer should still load and surface the failure inline. +- If a brush selection is too small or resolves to zero samples, ignore it and keep the current view. + +## Performance Constraints + +- Keep flamegraph rendering static and generation-time only. +- Do not recompute flamegraph layout in browser JavaScript. +- The browser should manage mode switching, zoom state, history, and local summaries only. + +## Testing Strategy + +- Unit tests for benchmark-anchor trimming on representative Android and iOS folded stacks +- HTML generation tests for toolbar controls, dual-view shell, and artifact links +- Snapshot-style tests for `flamegraph.html` structure +- Smoke tests confirming: + - `flamegraph.full.svg` exists + - `flamegraph.focused.svg` exists + - `flamegraph.html` references both + - focused stacks contain benchmark frames such as `run_benchmark` and `fibonacci` + +## Recommendation + +Ship the viewer in two steps: + +1. Benchmark-focused dataset derivation plus dual-view standalone HTML shell +2. Brush zoom, history controls, and hot-frame summaries + +This keeps the first version grounded in the current pipeline while directly fixing the biggest UX problem: the default artifact should lead with the benchmark, not the entire process. From e1ab0d522302bfacd68915b013975dcf6d1aa0a6 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 20:45:10 -0700 Subject: [PATCH 145/196] Fix basic example mobile CI --- .github/workflows/compile-gate.yml | 2 +- .../workflows/mobile-bench-action-example.yml | 4 +- .../workflows/mobile-bench-plot-fixtures.yml | 6 +- .github/workflows/reusable-bench.yml | 30 ++--- Cargo.lock | 3 + crates/mobench-sdk/src/codegen.rs | 42 ++++++ examples/basic-benchmark/Cargo.toml | 7 +- examples/basic-benchmark/build.rs | 2 +- examples/basic-benchmark/src/lib.rs | 125 +++++++++++++++++- 9 files changed, 196 insertions(+), 25 deletions(-) diff --git a/.github/workflows/compile-gate.yml b/.github/workflows/compile-gate.yml index 92f47fc..b65f45e 100644 --- a/.github/workflows/compile-gate.yml +++ b/.github/workflows/compile-gate.yml @@ -15,7 +15,7 @@ jobs: compile: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable - name: Compile the workspace run: cargo test --workspace --locked --no-run diff --git a/.github/workflows/mobile-bench-action-example.yml b/.github/workflows/mobile-bench-action-example.yml index 4fe3ac3..b7c9edf 100644 --- a/.github/workflows/mobile-bench-action-example.yml +++ b/.github/workflows/mobile-bench-action-example.yml @@ -16,7 +16,7 @@ jobs: if: ${{ github.event.inputs.platform == 'android' || github.event.inputs.platform == '' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android @@ -41,7 +41,7 @@ jobs: if: ${{ github.event.inputs.platform == 'ios' }} runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios diff --git a/.github/workflows/mobile-bench-plot-fixtures.yml b/.github/workflows/mobile-bench-plot-fixtures.yml index f02c56a..30e391e 100644 --- a/.github/workflows/mobile-bench-plot-fixtures.yml +++ b/.github/workflows/mobile-bench-plot-fixtures.yml @@ -21,11 +21,11 @@ jobs: fixture: [basic, ffi] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6.0.2 - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6.2.0 with: python-version: "3.12" @@ -43,7 +43,7 @@ jobs: - name: Upload plot artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: name: mobench-plot-${{ matrix.fixture }} path: target/mobench/plot-fixtures/${{ matrix.fixture }} diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 76b84cc..daa19f0 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -133,7 +133,7 @@ jobs: steps: - name: Checkout caller repo - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 with: path: caller ref: ${{ inputs.head_sha || github.sha }} @@ -152,7 +152,7 @@ jobs: rustup target list --installed | grep -E "ios|apple" - name: Cache cargo registry/git - uses: actions/cache@v4 + uses: actions/cache@v5.0.4 with: path: | ~/.cargo/registry @@ -162,7 +162,7 @@ jobs: ${{ runner.os }}-cargo-ios- - name: Cache cargo target - uses: actions/cache@v4 + uses: actions/cache@v5.0.4 with: path: caller/target key: ${{ runner.os }}-target-ios-${{ hashFiles('caller/**/Cargo.lock') }} @@ -309,7 +309,7 @@ jobs: - name: Upload iOS results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: name: mobench-results-ios path: | @@ -332,7 +332,7 @@ jobs: steps: - name: Checkout caller repo - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 with: path: caller ref: ${{ inputs.head_sha || github.sha }} @@ -352,7 +352,7 @@ jobs: rustup target list --installed | grep -E "android" - name: Cache cargo registry/git - uses: actions/cache@v4 + uses: actions/cache@v5.0.4 with: path: | ~/.cargo/registry @@ -362,7 +362,7 @@ jobs: ${{ runner.os }}-cargo-android- - name: Cache cargo target - uses: actions/cache@v4 + uses: actions/cache@v5.0.4 with: path: caller/target key: ${{ runner.os }}-target-android-${{ hashFiles('caller/**/Cargo.lock') }} @@ -370,7 +370,7 @@ jobs: ${{ runner.os }}-target-android- - name: Setup Android SDK/NDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4.0.0 - name: Install SDK packages and resolve NDK shell: bash @@ -529,7 +529,7 @@ jobs: - name: Upload Android results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: name: mobench-results-android path: | @@ -549,7 +549,7 @@ jobs: steps: - name: Checkout caller repo - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 with: path: caller ref: ${{ inputs.head_sha || github.sha }} @@ -584,20 +584,20 @@ jobs: - name: Download iOS results if: needs.ios.result == 'success' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8.0.1 with: name: mobench-results-ios path: results/ios - name: Download Android results if: needs.android.result == 'success' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8.0.1 with: name: mobench-results-android path: results/android - name: Setup Python for plot rendering - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: "3.11" @@ -975,7 +975,7 @@ jobs: id: app-token if: inputs.pr_number != '' continue-on-error: true - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3.0.0 with: app-id: ${{ secrets.MOBENCH_APP_ID }} private-key: ${{ secrets.MOBENCH_APP_PRIVATE_KEY }} @@ -1112,7 +1112,7 @@ jobs: - name: Upload history bundle if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: name: mobench-history-bundle path: history-bundle/ diff --git a/Cargo.lock b/Cargo.lock index 9759f8f..0ee8904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,6 +146,9 @@ version = "0.1.0" dependencies = [ "inventory", "mobench-sdk", + "serde", + "thiserror 1.0.69", + "uniffi", ] [[package]] diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 6ec6afa..af970b5 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -301,6 +301,7 @@ pub fn generate_android_project( default_function: &str, ) -> Result<(), BenchError> { let target_dir = output_dir.join("android"); + reset_generated_project_dir(&target_dir)?; let library_name = project_slug.replace('-', "_"); let project_pascal = to_pascal_case(project_slug); // Use sanitized bundle ID component (alphanumeric only) for consistency with iOS @@ -346,6 +347,18 @@ pub fn generate_android_project( Ok(()) } +fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> { + if target_dir.exists() { + fs::remove_dir_all(target_dir).map_err(|e| { + BenchError::Build(format!( + "Failed to clear existing generated project at {:?}: {}", + target_dir, e + )) + })?; + } + Ok(()) +} + /// Moves Kotlin source files to the correct package directory structure /// /// Android requires source files to be in directories matching their package declaration. @@ -431,6 +444,7 @@ pub fn generate_ios_project( default_function: &str, ) -> Result<(), BenchError> { let target_dir = output_dir.join("ios"); + reset_generated_project_dir(&target_dir)?; // Sanitize bundle ID components to ensure they only contain alphanumeric characters // iOS bundle identifiers should not contain hyphens or underscores let sanitized_bundle_prefix = { @@ -1240,6 +1254,34 @@ mod tests { fs::remove_dir_all(&temp_dir).ok(); } + #[test] + fn test_generate_android_project_replaces_previous_package_tree() { + let temp_dir = env::temp_dir().join("mobench-sdk-android-regenerate-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci") + .unwrap(); + let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark"); + assert!(old_package_dir.exists(), "expected first package tree to exist"); + + generate_android_project( + &temp_dir, + "basic_benchmark", + "basic_benchmark::bench_fibonacci", + ) + .unwrap(); + + let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark"); + assert!(new_package_dir.exists(), "expected new package tree to exist"); + assert!( + !old_package_dir.exists(), + "old package tree should be removed when regenerating the Android scaffold" + ); + + fs::remove_dir_all(&temp_dir).ok(); + } + #[test] fn test_is_template_file() { assert!(is_template_file(Path::new("settings.gradle"))); diff --git a/examples/basic-benchmark/Cargo.toml b/examples/basic-benchmark/Cargo.toml index a89e5fe..22c4514 100644 --- a/examples/basic-benchmark/Cargo.toml +++ b/examples/basic-benchmark/Cargo.toml @@ -9,6 +9,11 @@ name = "basic_benchmark" crate-type = ["lib", "cdylib", "staticlib"] [dependencies] -# Use mobench-sdk for the #[benchmark] macro and registry mobench-sdk = { path = "../../crates/mobench-sdk" } inventory.workspace = true +serde.workspace = true +thiserror.workspace = true +uniffi = { workspace = true, features = ["cli"] } + +[build-dependencies] +uniffi = { workspace = true, features = ["build"] } diff --git a/examples/basic-benchmark/build.rs b/examples/basic-benchmark/build.rs index c003aab..77b11c1 100644 --- a/examples/basic-benchmark/build.rs +++ b/examples/basic-benchmark/build.rs @@ -1,3 +1,3 @@ fn main() { - // No build-time steps required for the minimal example. + // UniFFI proc-macro mode does not need build-time scaffolding generation. } diff --git a/examples/basic-benchmark/src/lib.rs b/examples/basic-benchmark/src/lib.rs index b756ae6..72a4c25 100644 --- a/examples/basic-benchmark/src/lib.rs +++ b/examples/basic-benchmark/src/lib.rs @@ -1,13 +1,112 @@ //! Basic benchmark examples demonstrating mobench-sdk usage. //! //! This example keeps things minimal: register functions with #[benchmark] and -//! let the SDK handle discovery and execution. See `examples/ffi-benchmark` for -//! a full UniFFI-based FFI surface. +//! let the SDK handle discovery and execution. It also exposes the minimal +//! UniFFI/mobile entrypoint needed by the example CI so the generated Android +//! and iOS apps can execute the benchmarks end to end. use mobench_sdk::benchmark; const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; +/// Specification for a benchmark run. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchSpec { + pub name: String, + pub iterations: u32, + pub warmup: u32, +} + +/// A single benchmark sample with timing information. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchSample { + pub duration_ns: u64, +} + +/// Complete benchmark report with spec and timing samples. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct BenchReport { + pub spec: BenchSpec, + pub samples: Vec, +} + +/// Error types for benchmark operations. +#[derive(Debug, thiserror::Error, uniffi::Error)] +#[uniffi(flat_error)] +pub enum BenchError { + #[error("iterations must be greater than zero")] + InvalidIterations, + + #[error("unknown benchmark function: {name}")] + UnknownFunction { name: String }, + + #[error("benchmark execution failed: {reason}")] + ExecutionFailed { reason: String }, +} + +uniffi::setup_scaffolding!(); + +impl From for BenchSpec { + fn from(spec: mobench_sdk::BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for mobench_sdk::BenchSpec { + fn from(spec: BenchSpec) -> Self { + Self { + name: spec.name, + iterations: spec.iterations, + warmup: spec.warmup, + } + } +} + +impl From for BenchSample { + fn from(sample: mobench_sdk::BenchSample) -> Self { + Self { + duration_ns: sample.duration_ns, + } + } +} + +impl From for BenchReport { + fn from(report: mobench_sdk::RunnerReport) -> Self { + Self { + spec: report.spec.into(), + samples: report.samples.into_iter().map(Into::into).collect(), + } + } +} + +impl From for BenchError { + fn from(err: mobench_sdk::BenchError) -> Self { + match err { + mobench_sdk::BenchError::Runner(runner_err) => BenchError::ExecutionFailed { + reason: runner_err.to_string(), + }, + mobench_sdk::BenchError::UnknownFunction(name, _available) => { + BenchError::UnknownFunction { name } + } + _ => BenchError::ExecutionFailed { + reason: err.to_string(), + }, + } + } +} + +/// Run a benchmark by name with the given specification. +#[uniffi::export] +pub fn run_benchmark(spec: BenchSpec) -> Result { + let sdk_spec: mobench_sdk::BenchSpec = spec.into(); + let report = mobench_sdk::run_benchmark(sdk_spec)?; + Ok(report.into()) +} + /// Compute fibonacci number iteratively. pub fn fibonacci(n: u32) -> u64 { match n { @@ -96,4 +195,26 @@ mod tests { let report = mobench_sdk::run_benchmark(spec).unwrap(); assert_eq!(report.samples.len(), 3); } + + #[test] + fn test_run_benchmark_via_mobile_ffi() { + let spec = BenchSpec { + name: "basic_benchmark::bench_checksum".to_string(), + iterations: 2, + warmup: 0, + }; + let report = run_benchmark(spec).unwrap(); + assert_eq!(report.samples.len(), 2); + } + + #[test] + fn test_unknown_function_maps_to_mobile_ffi_error() { + let spec = BenchSpec { + name: "basic_benchmark::does_not_exist".to_string(), + iterations: 1, + warmup: 0, + }; + let result = run_benchmark(spec); + assert!(matches!(result, Err(BenchError::UnknownFunction { .. }))); + } } From 32ace5352b4a84660a2c4adcfba2842826f6fa4d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 21:09:10 -0700 Subject: [PATCH 146/196] Increase example benchmark workload --- .github/workflows/mobile-bench.yml | 4 +- .github/workflows/reusable-pr-auto.yml | 2 +- .github/workflows/reusable-pr-command.yml | 2 +- examples/basic-benchmark/src/lib.rs | 95 ++++++++++++++++++++--- examples/ffi-benchmark/src/lib.rs | 95 ++++++++++++++++++++--- 5 files changed, 174 insertions(+), 24 deletions(-) diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index aff6bf5..7760f3a 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -31,12 +31,12 @@ on: description: "Number of benchmark iterations" required: false type: string - default: "5" + default: "50" warmup: description: "Number of warmup iterations" required: false type: string - default: "1" + default: "5" pr_number: description: "PR number for sticky comment publishing" required: false diff --git a/.github/workflows/reusable-pr-auto.yml b/.github/workflows/reusable-pr-auto.yml index b835e58..ff34ddf 100644 --- a/.github/workflows/reusable-pr-auto.yml +++ b/.github/workflows/reusable-pr-auto.yml @@ -36,7 +36,7 @@ on: description: "Default iteration count" required: false type: string - default: "30" + default: "50" default_warmup: description: "Default warmup count" required: false diff --git a/.github/workflows/reusable-pr-command.yml b/.github/workflows/reusable-pr-command.yml index 3b71b61..6be6728 100644 --- a/.github/workflows/reusable-pr-command.yml +++ b/.github/workflows/reusable-pr-command.yml @@ -27,7 +27,7 @@ on: description: "Default iteration count" required: false type: string - default: "30" + default: "50" default_warmup: description: "Default warmup count" required: false diff --git a/examples/basic-benchmark/src/lib.rs b/examples/basic-benchmark/src/lib.rs index 72a4c25..591c093 100644 --- a/examples/basic-benchmark/src/lib.rs +++ b/examples/basic-benchmark/src/lib.rs @@ -7,7 +7,25 @@ use mobench_sdk::benchmark; -const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; +const CHECKSUM_INPUT_LEN: usize = 64 * 1024; +const CHECKSUM_WINDOW_LEN: usize = 8 * 1024; +const CHECKSUM_SWEEP_ITERATIONS: usize = 2_048; +const FIBONACCI_START: u32 = 28; +const FIBONACCI_SPAN: u32 = 6; +const FIBONACCI_SWEEP_ITERATIONS: u32 = 200_000; +const CHECKSUM_INPUT: [u8; CHECKSUM_INPUT_LEN] = build_checksum_input(); + +const fn build_checksum_input() -> [u8; CHECKSUM_INPUT_LEN] { + let mut bytes = [0u8; CHECKSUM_INPUT_LEN]; + let mut i = 0; + let mut state = 0x1234_5678u32; + while i < CHECKSUM_INPUT_LEN { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + bytes[i] = (state >> 16) as u8; + i += 1; + } + bytes +} /// Specification for a benchmark run. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] @@ -139,27 +157,61 @@ pub fn checksum(bytes: &[u8]) -> u64 { bytes.iter().map(|&b| b as u64).sum() } +/// Sweep across a small fibonacci range to avoid a trivially constant workload. +pub fn fibonacci_sweep(start: u32, span: u32, iterations: u32) -> u64 { + let span = if span == 0 { 1 } else { span }; + let mut result = 0u64; + for i in 0..iterations { + result = result.wrapping_add(fibonacci(start + (i % span))); + } + result +} + +/// Sweep overlapping windows across the input buffer to keep the checksum work realistic. +pub fn checksum_sweep(bytes: &[u8], window_len: usize, iterations: usize) -> u64 { + assert!(window_len > 0, "window_len must be greater than zero"); + assert!( + bytes.len() >= window_len, + "window_len must fit within the input buffer" + ); + + let max_start = bytes.len() - window_len; + let mut sum = 0u64; + for i in 0..iterations { + let start = if max_start == 0 { + 0 + } else { + i % (max_start + 1) + }; + sum = sum.wrapping_add(checksum(&bytes[start..start + window_len])); + } + sum +} + // ============================================================================ // Benchmark Functions // ============================================================================ // These functions are marked with #[benchmark] and automatically registered // with mobench-sdk's registry system. -/// Benchmark: Fibonacci calculation (30th number, 1000 iterations) +/// Benchmark: Fibonacci sweep with enough work to make mobile samples meaningful. #[benchmark] pub fn bench_fibonacci() { - let result = fibonacci_batch(30, 1000); + let start = std::hint::black_box(FIBONACCI_START); + let span = std::hint::black_box(FIBONACCI_SPAN); + let iterations = std::hint::black_box(FIBONACCI_SWEEP_ITERATIONS); + let result = fibonacci_sweep(start, span, iterations); std::hint::black_box(result); } -/// Benchmark: Checksum calculation on 1KB data (10000 iterations) +/// Benchmark: Sliding-window checksum over a larger input buffer. #[benchmark] pub fn bench_checksum() { - let mut sum = 0u64; - for _ in 0..10000 { - sum = sum.wrapping_add(checksum(&CHECKSUM_INPUT)); - } - std::hint::black_box(sum); + let bytes = std::hint::black_box(&CHECKSUM_INPUT); + let window_len = std::hint::black_box(CHECKSUM_WINDOW_LEN); + let iterations = std::hint::black_box(CHECKSUM_SWEEP_ITERATIONS); + let result = checksum_sweep(bytes, window_len, iterations); + std::hint::black_box(result); } #[cfg(test)] @@ -176,7 +228,30 @@ mod tests { #[test] fn checksum_matches() { - assert_eq!(checksum(&CHECKSUM_INPUT), 1024); + let expected = CHECKSUM_INPUT[..4].iter().map(|&b| b as u64).sum::(); + assert_eq!(checksum(&CHECKSUM_INPUT[..4]), expected); + } + + #[test] + fn checksum_input_has_entropy() { + assert_ne!(CHECKSUM_INPUT[0], CHECKSUM_INPUT[1]); + assert_ne!(CHECKSUM_INPUT[512], CHECKSUM_INPUT[513]); + } + + #[test] + fn fibonacci_sweep_walks_expected_range() { + let expected = fibonacci(4) + fibonacci(5) + fibonacci(6) + fibonacci(4) + fibonacci(5); + assert_eq!(fibonacci_sweep(4, 3, 5), expected); + } + + #[test] + fn checksum_sweep_wraps_windows() { + let bytes = [1u8, 2, 3, 4]; + let expected = checksum(&bytes[0..2]) + + checksum(&bytes[1..3]) + + checksum(&bytes[2..4]) + + checksum(&bytes[0..2]); + assert_eq!(checksum_sweep(&bytes, 2, 4), expected); } #[test] diff --git a/examples/ffi-benchmark/src/lib.rs b/examples/ffi-benchmark/src/lib.rs index 3d5e369..2703164 100644 --- a/examples/ffi-benchmark/src/lib.rs +++ b/examples/ffi-benchmark/src/lib.rs @@ -14,7 +14,25 @@ use mobench_sdk::benchmark; -const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; +const CHECKSUM_INPUT_LEN: usize = 64 * 1024; +const CHECKSUM_WINDOW_LEN: usize = 8 * 1024; +const CHECKSUM_SWEEP_ITERATIONS: usize = 2_048; +const FIBONACCI_START: u32 = 28; +const FIBONACCI_SPAN: u32 = 6; +const FIBONACCI_SWEEP_ITERATIONS: u32 = 200_000; +const CHECKSUM_INPUT: [u8; CHECKSUM_INPUT_LEN] = build_checksum_input(); + +const fn build_checksum_input() -> [u8; CHECKSUM_INPUT_LEN] { + let mut bytes = [0u8; CHECKSUM_INPUT_LEN]; + let mut i = 0; + let mut state = 0x1234_5678u32; + while i < CHECKSUM_INPUT_LEN { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + bytes[i] = (state >> 16) as u8; + i += 1; + } + bytes +} /// Specification for a benchmark run. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] @@ -150,27 +168,61 @@ pub fn checksum(bytes: &[u8]) -> u64 { bytes.iter().map(|&b| b as u64).sum() } +/// Sweep across a small fibonacci range to avoid a trivially constant workload. +pub fn fibonacci_sweep(start: u32, span: u32, iterations: u32) -> u64 { + let span = if span == 0 { 1 } else { span }; + let mut result = 0u64; + for i in 0..iterations { + result = result.wrapping_add(fibonacci(start + (i % span))); + } + result +} + +/// Sweep overlapping windows across the input buffer to keep the checksum work realistic. +pub fn checksum_sweep(bytes: &[u8], window_len: usize, iterations: usize) -> u64 { + assert!(window_len > 0, "window_len must be greater than zero"); + assert!( + bytes.len() >= window_len, + "window_len must fit within the input buffer" + ); + + let max_start = bytes.len() - window_len; + let mut sum = 0u64; + for i in 0..iterations { + let start = if max_start == 0 { + 0 + } else { + i % (max_start + 1) + }; + sum = sum.wrapping_add(checksum(&bytes[start..start + window_len])); + } + sum +} + // ============================================================================ // Benchmark Functions // ============================================================================ // These functions are marked with #[benchmark] and automatically registered // with mobench-sdk's registry system. -/// Benchmark: Fibonacci calculation (30th number, 1000 iterations) +/// Benchmark: Fibonacci sweep with enough work to make mobile samples meaningful. #[benchmark] pub fn bench_fibonacci() { - let result = fibonacci_batch(30, 1000); + let start = std::hint::black_box(FIBONACCI_START); + let span = std::hint::black_box(FIBONACCI_SPAN); + let iterations = std::hint::black_box(FIBONACCI_SWEEP_ITERATIONS); + let result = fibonacci_sweep(start, span, iterations); std::hint::black_box(result); } -/// Benchmark: Checksum calculation on 1KB data (10000 iterations) +/// Benchmark: Sliding-window checksum over a larger input buffer. #[benchmark] pub fn bench_checksum() { - let mut sum = 0u64; - for _ in 0..10000 { - sum = sum.wrapping_add(checksum(&CHECKSUM_INPUT)); - } - std::hint::black_box(sum); + let bytes = std::hint::black_box(&CHECKSUM_INPUT); + let window_len = std::hint::black_box(CHECKSUM_WINDOW_LEN); + let iterations = std::hint::black_box(CHECKSUM_SWEEP_ITERATIONS); + let result = checksum_sweep(bytes, window_len, iterations); + std::hint::black_box(result); } #[cfg(test)] @@ -187,7 +239,30 @@ mod tests { #[test] fn checksum_matches() { - assert_eq!(checksum(&CHECKSUM_INPUT), 1024); + let expected = CHECKSUM_INPUT[..4].iter().map(|&b| b as u64).sum::(); + assert_eq!(checksum(&CHECKSUM_INPUT[..4]), expected); + } + + #[test] + fn checksum_input_has_entropy() { + assert_ne!(CHECKSUM_INPUT[0], CHECKSUM_INPUT[1]); + assert_ne!(CHECKSUM_INPUT[512], CHECKSUM_INPUT[513]); + } + + #[test] + fn fibonacci_sweep_walks_expected_range() { + let expected = fibonacci(4) + fibonacci(5) + fibonacci(6) + fibonacci(4) + fibonacci(5); + assert_eq!(fibonacci_sweep(4, 3, 5), expected); + } + + #[test] + fn checksum_sweep_wraps_windows() { + let bytes = [1u8, 2, 3, 4]; + let expected = checksum(&bytes[0..2]) + + checksum(&bytes[1..3]) + + checksum(&bytes[2..4]) + + checksum(&bytes[0..2]); + assert_eq!(checksum_sweep(&bytes, 2, 4), expected); } #[test] From aa7a5dfa6564cff38f6c7390cdc644750dc51461 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 20:02:59 -0700 Subject: [PATCH 147/196] Clear cached CI baselines before runs --- .github/workflows/reusable-bench.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index daa19f0..8fe2412 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -293,6 +293,7 @@ jobs: set -euo pipefail device_spec="${IOS_DEVICE}-${IOS_OS_VERSION}" echo "Running iOS benchmarks on: ${device_spec}" + rm -rf target/mobench/ci/ios cargo-mobench ci run \ --target ios \ @@ -513,6 +514,7 @@ jobs: set -euo pipefail device_spec="${ANDROID_DEVICE}-${ANDROID_OS_VERSION}" echo "Running Android benchmarks on: ${device_spec}" + rm -rf target/mobench/ci/android cargo-mobench ci run \ --target android \ From 70a9096d0a139ce68d2c747567614dd0ecc4408c Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 20:10:13 -0700 Subject: [PATCH 148/196] docs: add flamegraph tower collapse design --- ...-03-27-flamegraph-tower-collapse-design.md | 129 ++++++++ ...lamegraph-tower-collapse-implementation.md | 280 ++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 docs/plans/2026-03-27-flamegraph-tower-collapse-design.md create mode 100644 docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md diff --git a/docs/plans/2026-03-27-flamegraph-tower-collapse-design.md b/docs/plans/2026-03-27-flamegraph-tower-collapse-design.md new file mode 100644 index 0000000..a26df96 --- /dev/null +++ b/docs/plans/2026-03-27-flamegraph-tower-collapse-design.md @@ -0,0 +1,129 @@ +# Flamegraph Tower Collapse Design + +## Goal + +Make the flamegraph viewer readable by default when a profile contains tall, thin towers of low-self-time frames. The viewer should hide those towers initially, show compact `+` expand affordances at their x-ranges, and let the user zoom into them on demand. + +## Problem + +The current flamegraph viewer renders every frame at full depth. For profiles with narrow, mostly vertical branches, that produces a graph that is too tall, forces long scrolling, and makes the durable branches hard to compare. Even when the benchmark-only capture is good, the default presentation still overemphasizes thin call towers that contribute little self time. + +## Chosen Approach + +Use a viewer-layer collapse pass driven by `self time`, not a preprocessing pass that mutates the stored folded-stack artifacts. + +The viewer already has: + +- dual modes: `Benchmark Only` and `Full Process` +- zoom history +- brush range selection +- SVG helper functions inside the embedded flamegraph document + +The new behavior should extend that existing interaction model: + +1. Analyze the rendered SVG frames in the current zoomed view. +2. Detect contiguous vertical towers that are both visually thin and low in `self time`. +3. Hide those towers from the default graph layout. +4. Render a `+` chip at each hidden tower’s x-range. +5. Clicking the chip zooms that tower range to full width and reveals the hidden frames for that region. + +## Why This Approach + +This preserves the raw outputs: + +- `stacks.folded` +- `benchmark.focused.folded` +- `flamegraph.full.svg` +- `flamegraph.focused.svg` + +It also makes the behavior dynamic per zoom level. A tower that is too thin in the default view may become readable after zooming; the collapse logic should therefore rerun after every zoom instead of baking the collapse into the artifact generation pipeline. + +## Collapse Heuristic + +Base the heuristic on `self time`. + +Recommended defaults: + +- collapse a tower when frame width in the current view is below about `18px` +- and frame self time is below about `0.5%` of visible samples +- and the tower depth is at least `6` frames + +The detector should: + +- reconstruct parent/child relationships from the flamegraph SVG coordinates (`fg:x`, `fg:w`, `y`) +- estimate each frame’s `self_samples` from inclusive width minus direct child coverage +- identify mostly vertical, low-self-time chains +- group each chain into one collapsed tower rooted at the first durable ancestor + +## Interaction Model + +Default behavior: + +- `Hide Thin Towers` is enabled by default +- hidden towers do not contribute to graph height +- the graph height is cropped to the highest remaining visible frame + +Expansion behavior: + +- each hidden tower renders as a compact `+ N` chip +- clicking the chip zooms into that tower’s x-range +- after zoom, the collapse pass reruns for the new viewport +- `Back` returns to the prior durable-only view +- `Reset` returns to the default durable-only view for the current mode + +Mode behavior: + +- `Benchmark Only` and `Full Process` keep independent zoom/collapse history +- the collapse toggle state is shared unless later evidence shows mode-specific defaults are better + +## UI Additions + +Add to the toolbar: + +- `Hide Thin Towers` toggle, default `on` + +Add to the sidebar or toolbar metadata: + +- the active thresholds: + - `self < 0.5%` + - `width < 18px` + +The graph itself should show: + +- `+` chips positioned at hidden tower x-ranges +- hidden tower depth or hidden frame count in the chip label if space allows + +## Architecture + +Keep this feature inside the existing flamegraph viewer layer in: + +- `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` + +Do not introduce a new persisted artifact format. + +Implementation should likely touch: + +- the generated viewer HTML shell +- the embedded helper script inside the standalone SVG document +- viewer tests for HTML generation and tower-detection behavior + +## Testing + +Add: + +- unit tests for tower detection on a synthetic flamegraph tree +- HTML regression tests for: + - `Hide Thin Towers` toggle + - `+` expand chip support + - per-view collapse metadata +- smoke verification on the iOS sample artifact to confirm: + - initial graph height is shorter + - collapsed towers render with `+` affordances + - expanding a tower zooms to a legible range + +## Non-Goals + +- do not rewrite folded-stack artifacts on disk +- do not remove access to raw full-process detail +- do not rely on `inclusive time` as the primary collapse criterion + diff --git a/docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md b/docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md new file mode 100644 index 0000000..1d88e8f --- /dev/null +++ b/docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md @@ -0,0 +1,280 @@ +# Flamegraph Tower Collapse Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make the flamegraph viewer hide tall thin low-self-time towers by default, show `+` expand affordances for those ranges, and let users zoom into the hidden range to inspect it. + +**Architecture:** Keep the collapse logic in the viewer layer. Analyze rendered flamegraph frames from SVG coordinates at the current zoom level, compute low-self-time tower candidates, hide them from the default graph height, and inject expand chips that zoom into the hidden range. Preserve raw folded-stack and SVG artifacts exactly as they are today. + +**Tech Stack:** Rust, inferno-generated SVG flamegraphs, embedded viewer HTML/JS in `crates/mobench/src/flamegraph_viewer.rs`, Rust unit tests, local CLI smoke runs + +--- + +### Task 1: Freeze The New Viewer Behavior With Tests + +**Files:** +- Modify: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` + +**Step 1: Add failing viewer-shell tests** + +Add tests that assert the generated viewer HTML includes: + +- a `Hide Thin Towers` control +- expand-chip support +- tower-collapse metadata/hooks for the embedded graph + +**Step 2: Add a failing tower-detector test** + +Add a small synthetic flamegraph tree test that proves: + +- a deep thin low-self-time chain is detected as collapsible +- a durable wide branch is not collapsed + +**Step 3: Run the focused tests** + +Run: + +```bash +cargo test -p mobench flamegraph_viewer -- --nocapture +``` + +Expected: + +- new tests fail before implementation + +**Step 4: Commit** + +```bash +git add crates/mobench/src/flamegraph_viewer.rs +git commit -m "test: freeze flamegraph tower collapse behavior" +``` + +### Task 2: Add Tower Analysis And Collapse Metadata + +**Files:** +- Modify: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` + +**Step 1: Add SVG-frame analysis structures** + +Implement internal structs/functions to represent: + +- frame bounds +- depth +- parent/child coverage +- computed `self_samples` +- collapsible tower groups + +**Step 2: Implement the collapse heuristic** + +Use these default thresholds: + +- width `< 18px` +- self time `< 0.5%` +- tower depth `>= 6` + +The detector should return grouped tower ranges, not just individual frames. + +**Step 3: Add unit tests for heuristic edge cases** + +Cover: + +- tower just below threshold collapses +- wide branch does not collapse +- shallow tower does not collapse +- zoomed wider range no longer collapses + +**Step 4: Run tests** + +Run: + +```bash +cargo test -p mobench flamegraph_viewer -- --nocapture +``` + +Expected: + +- tower-analysis tests pass + +**Step 5: Commit** + +```bash +git add crates/mobench/src/flamegraph_viewer.rs +git commit -m "feat: detect low-self-time flamegraph towers" +``` + +### Task 3: Hide Towers And Add Expand Chips In The Embedded SVG + +**Files:** +- Modify: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` + +**Step 1: Extend the standalone SVG helper script** + +Add helper functions that: + +- inspect current visible frames +- mark collapsible towers +- hide their `` groups +- inject `+` chip overlays at the tower x-ranges +- recompute the visible SVG height after collapse + +**Step 2: Wire chip clicks to range zoom** + +Clicking a chip should: + +- call the existing zoom helper with that tower’s x-range +- rerun collapse analysis after zoom + +**Step 3: Preserve existing interactions** + +Ensure these still work: + +- click zoom +- brush zoom +- back +- forward +- reset +- search + +**Step 4: Run tests** + +Run: + +```bash +cargo test -p mobench flamegraph_viewer -- --nocapture +``` + +Expected: + +- SVG/viewer tests pass + +**Step 5: Commit** + +```bash +git add crates/mobench/src/flamegraph_viewer.rs +git commit -m "feat: collapse thin flamegraph towers by default" +``` + +### Task 4: Add Viewer Controls And State For Tower Hiding + +**Files:** +- Modify: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` + +**Step 1: Add the toolbar toggle** + +Add `Hide Thin Towers` to the viewer toolbar, default `on`. + +**Step 2: Add threshold/status display** + +Surface: + +- active threshold values +- number of collapsed towers or hidden frames when available + +**Step 3: Maintain per-mode state** + +Ensure `Benchmark Only` and `Full Process` retain independent zoom/collapse history. + +**Step 4: Add HTML regression tests** + +Assert the viewer HTML contains: + +- the toggle +- threshold display +- collapse-status placeholders + +**Step 5: Run tests** + +Run: + +```bash +cargo test -p mobench flamegraph_viewer -- --nocapture +``` + +Expected: + +- viewer-shell tests pass + +**Step 6: Commit** + +```bash +git add crates/mobench/src/flamegraph_viewer.rs +git commit -m "feat: add flamegraph tower collapse controls" +``` + +### Task 5: Verify With A Real iOS Profile Artifact + +**Files:** +- Modify if needed: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` + +**Step 1: Regenerate the iOS smoke artifact** + +Run: + +```bash +cargo run -p mobench --bin mobench -- profile run --target ios --provider local --backend ios-instruments --crate-path crates/sample-fns --function sample_fns::fibonacci --output-dir target/mobench/profile-smoke-ios +``` + +Expected: + +- fresh artifacts under `target/mobench/profile-smoke-ios/ios-sample_fns--fibonacci/artifacts/processed` + +**Step 2: Verify collapse behavior in the generated viewer** + +Check: + +- the initial graph height is materially shorter +- `+` expand chips appear +- clicking a chip zooms to a readable tower range +- `Back` and `Reset` restore the prior/default view + +**Step 3: Re-run relevant tests** + +Run: + +```bash +cargo test -p mobench flamegraph_viewer -- --nocapture +cargo test -p mobench profile_ -- --nocapture +``` + +Expected: + +- all tests pass + +**Step 4: Commit** + +```bash +git add crates/mobench/src/flamegraph_viewer.rs +git commit -m "feat: verify flamegraph tower collapse on iOS artifact" +``` + +### Task 6: Update Docs If Viewer Semantics Changed + +**Files:** +- Modify if needed: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/README.md` + +**Step 1: Document the new viewer affordance** + +Add a short note that: + +- the interactive viewer hides tall thin low-self-time towers by default +- `+` chips expand hidden tower ranges +- `Reset` returns to the durable-only view + +**Step 2: Verify docs** + +Run: + +```bash +cargo run -p mobench --bin mobench -- profile run --help +``` + +Expected: + +- help remains accurate + +**Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: describe flamegraph tower collapse viewer" +``` From 99e7eefdab87a8e5150cb97166f4384eaf13084a Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 21:21:35 -0700 Subject: [PATCH 149/196] Harden CI workflows, fix flaky test, and add resource usage coverage - Replace grep -oP with portable sed in PR command parser and validate all parsed fields (allowlist for platform/device_profile, regex for numeric iterations/warmup) - Fix jq path(.) dead code in history bundle manifest by using shell directory checks for platform detection - Emit ::warning:: on sdkmanager install failure instead of silent || true - Fix expression injection in reusable-pr-auto.yml and reusable-pr-command.yml by moving ${{ }} expressions to env vars - Fix flaky device_discovery test by setting accepted TCP streams to blocking mode and increasing mock server idle timeout - Add BenchmarkResourceUsage unit tests for extraction and JSON round-trip - Add _MAX_PACK_RETRIES bound to _pack_device_offsets loop - Add required-key validation to _validate_plot_spec - Gracefully skip uniffi-bindgen install when no Cargo.lock exists - Fix compile gate job check name in mobile-bench-pr-auto.yml --- .github/workflows/mobile-bench-pr-auto.yml | 2 +- .github/workflows/reusable-bench.yml | 47 ++-- .github/workflows/reusable-pr-auto.yml | 22 +- .github/workflows/reusable-pr-command.yml | 41 ++- crates/mobench/python/render_sina_plot.py | 24 +- crates/mobench/src/browserstack.rs | 10 +- crates/mobench/src/lib.rs | 104 +++++++- crates/mobench/src/profile.rs | 249 ++++++++++++++++-- crates/mobench/src/summarize.rs | 14 +- .../android/bench_alpha/summary.json | 2 +- .../android/bench_beta/summary.json | 2 +- .../ci-artifact-root/android/summary.json | 2 +- 12 files changed, 443 insertions(+), 76 deletions(-) diff --git a/.github/workflows/mobile-bench-pr-auto.yml b/.github/workflows/mobile-bench-pr-auto.yml index 3b92800..efa1506 100644 --- a/.github/workflows/mobile-bench-pr-auto.yml +++ b/.github/workflows/mobile-bench-pr-auto.yml @@ -18,6 +18,6 @@ jobs: uses: ./.github/workflows/reusable-pr-auto.yml with: benchmark_workflow: .github/workflows/mobile-bench.yml - compile_gate_workflow_name: "Compile Gate" + compile_gate_workflow_name: "compile" crate_path: examples/ffi-benchmark functions: ffi_benchmark::bench_fibonacci,ffi_benchmark::bench_checksum diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 8fe2412..5f6bdff 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -198,6 +198,11 @@ jobs: shell: bash run: | set -euo pipefail + lockfile=$(find caller -name Cargo.lock -print -quit) + if [ -z "${lockfile}" ]; then + echo "No Cargo.lock found under caller/; skipping global uniffi-bindgen install" + exit 0 + fi uniffi_version=$(awk ' $0 == "[[package]]" { in_pkg=0 } $0 == "name = \"uniffi_bindgen\"" { in_pkg=1; next } @@ -206,7 +211,7 @@ jobs: print $0 exit } - ' caller/Cargo.lock) + ' "${lockfile}") if [ -n "${uniffi_version}" ]; then cargo install \ --git https://github.com/mozilla/uniffi-rs \ @@ -386,7 +391,9 @@ jobs: echo "Using sdkmanager: $SDKMGR" # Install packages (best-effort — runner may already have them) - $SDKMGR --install "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;26.1.10909125" 2>&1 || true + if ! $SDKMGR --install "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;26.1.10909125" 2>&1; then + echo "::warning::sdkmanager install returned non-zero; will attempt to use pre-installed packages" + fi # Resolve NDK: prefer 26.1.10909125, fall back to latest available echo "Available NDKs:" @@ -441,6 +448,11 @@ jobs: shell: bash run: | set -euo pipefail + lockfile=$(find caller -name Cargo.lock -print -quit) + if [ -z "${lockfile}" ]; then + echo "No Cargo.lock found under caller/; skipping global uniffi-bindgen install" + exit 0 + fi uniffi_version=$(awk ' $0 == "[[package]]" { in_pkg=0 } $0 == "name = \"uniffi_bindgen\"" { in_pkg=1; next } @@ -449,7 +461,7 @@ jobs: print $0 exit } - ' caller/Cargo.lock) + ' "${lockfile}") if [ -n "${uniffi_version}" ]; then cargo install \ --git https://github.com/mozilla/uniffi-rs \ @@ -1080,6 +1092,16 @@ jobs: cp -R rendered/. history-bundle/rendered/ fi + # Detect which platform result directories exist + platforms="[]" + has_ios=false; has_android=false + [ -d "results/ios" ] && has_ios=true + [ -d "results/android" ] && has_android=true + if $has_ios && $has_android; then platforms='["ios","android"]' + elif $has_ios; then platforms='["ios"]' + elif $has_android; then platforms='["android"]' + fi + # Generate manifest jq -n \ --arg ts "$TIMESTAMP" \ @@ -1087,29 +1109,14 @@ jobs: --arg pr "${PR_NUMBER:-}" \ --arg by "${REQUESTED_BY:-ci}" \ --arg ver "mobench-history-v1" \ + --argjson platforms "$platforms" \ '{ schema_version: $ver, timestamp: $ts, commit_sha: $sha, pr_number: (if $pr != "" then ($pr | tonumber) else null end), requested_by: $by, - platforms: [ - (if ("results/ios" | path(.) // empty) then "ios" else empty end), - (if ("results/android" | path(.) // empty) then "android" else empty end) - ] - }' > history-bundle/manifest.json 2>/dev/null || \ - jq -n \ - --arg ts "$TIMESTAMP" \ - --arg sha "$HEAD_SHA" \ - --arg pr "${PR_NUMBER:-}" \ - --arg by "${REQUESTED_BY:-ci}" \ - --arg ver "mobench-history-v1" \ - '{ - schema_version: $ver, - timestamp: $ts, - commit_sha: $sha, - pr_number: (if $pr != "" then ($pr | tonumber) else null end), - requested_by: $by + platforms: $platforms }' > history-bundle/manifest.json - name: Upload history bundle diff --git a/.github/workflows/reusable-pr-auto.yml b/.github/workflows/reusable-pr-auto.yml index ff34ddf..f43c8db 100644 --- a/.github/workflows/reusable-pr-auto.yml +++ b/.github/workflows/reusable-pr-auto.yml @@ -63,15 +63,20 @@ jobs: GH_TOKEN: ${{ github.token }} BENCH_LABEL: ${{ inputs.bench_label }} GATE_WORKFLOW: ${{ inputs.compile_gate_workflow_name }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER_EVENT: ${{ github.event.pull_request.number }} + HEAD_SHA_PR: ${{ github.event.pull_request.head.sha }} + HEAD_SHA_WR: ${{ github.event.workflow_run.head_sha }} + REPO: ${{ github.repository }} run: | # For label events, use the PR directly - if [ "${{ github.event_name }}" = "pull_request" ]; then - PR_NUMBER="${{ github.event.pull_request.number }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" + if [ "$EVENT_NAME" = "pull_request" ]; then + PR_NUMBER="$PR_NUMBER_EVENT" + HEAD_SHA="$HEAD_SHA_PR" else # For workflow_run events, find the associated PR - HEAD_SHA="${{ github.event.workflow_run.head_sha }}" - PR_NUMBER=$(gh api "repos/${{ github.repository }}/pulls?state=open&sort=updated&direction=desc&per_page=20" \ + HEAD_SHA="$HEAD_SHA_WR" + PR_NUMBER=$(gh api "repos/${REPO}/pulls?state=open&sort=updated&direction=desc&per_page=20" \ --jq ".[] | select(.head.sha == \"${HEAD_SHA}\") | .number" | head -1) if [ -z "$PR_NUMBER" ]; then echo "::notice::No open PR found for SHA ${HEAD_SHA}, skipping" @@ -81,7 +86,7 @@ jobs: fi # Check if bench label is present - HAS_LABEL=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/labels" \ + HAS_LABEL=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/labels" \ --jq ".[].name" | grep -qx "$BENCH_LABEL" && echo "true" || echo "false") if [ "$HAS_LABEL" != "true" ]; then echo "::notice::PR #${PR_NUMBER} does not have '${BENCH_LABEL}' label, skipping" @@ -90,7 +95,7 @@ jobs: fi # Check if compile gate passed for this SHA - GATE_STATUS=$(gh api "repos/${{ github.repository }}/commits/${HEAD_SHA}/check-runs" \ + GATE_STATUS=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs" \ --jq ".check_runs[] | select(.name == \"${GATE_WORKFLOW}\" or (.app.name == \"GitHub Actions\" and .name == \"${GATE_WORKFLOW}\")) | .conclusion" \ | head -1) if [ "$GATE_STATUS" != "success" ]; then @@ -115,9 +120,10 @@ jobs: DEVICE_PROFILE: ${{ inputs.default_device_profile }} ITERATIONS: ${{ inputs.default_iterations }} WARMUP: ${{ inputs.default_warmup }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | gh workflow run "$WORKFLOW" \ - --ref "${{ github.event.repository.default_branch }}" \ + --ref "$DEFAULT_BRANCH" \ -f head_sha="$HEAD_SHA" \ -f crate_path="$CRATE_PATH" \ -f functions="$FUNCTIONS" \ diff --git a/.github/workflows/reusable-pr-command.yml b/.github/workflows/reusable-pr-command.yml index 6be6728..57622c8 100644 --- a/.github/workflows/reusable-pr-command.yml +++ b/.github/workflows/reusable-pr-command.yml @@ -72,10 +72,40 @@ jobs: # Extract key=value pairs from /mobench command LINE=$(echo "$COMMENT_BODY" | head -1) - platform=$(echo "$LINE" | grep -oP 'platform=\K\S+' || echo "both") - device_profile=$(echo "$LINE" | grep -oP 'device_profile=\K\S+' || echo "low-spec") - iterations=$(echo "$LINE" | grep -oP 'iterations=\K\S+' || echo "$DEFAULT_ITERATIONS") - warmup=$(echo "$LINE" | grep -oP 'warmup=\K\S+' || echo "$DEFAULT_WARMUP") + # Parse with sed (portable, no grep -P dependency) + extract_val() { + echo "$LINE" | sed -n "s/.*${1}=\([^ ]*\).*/\1/p" + } + + platform=$(extract_val platform) + device_profile=$(extract_val device_profile) + iterations=$(extract_val iterations) + warmup=$(extract_val warmup) + + # Validate platform against allowlist + case "${platform:-both}" in + android|ios|both) platform="${platform:-both}" ;; + *) echo "::warning::Invalid platform '${platform}', defaulting to 'both'"; platform="both" ;; + esac + + # Validate device_profile against allowlist + case "${device_profile:-low-spec}" in + low-spec|mid-spec|high-spec|flagship) device_profile="${device_profile:-low-spec}" ;; + *) echo "::warning::Invalid device_profile '${device_profile}', defaulting to 'low-spec'"; device_profile="low-spec" ;; + esac + + # Validate numeric fields + if ! [[ "${iterations:-$DEFAULT_ITERATIONS}" =~ ^[0-9]+$ ]]; then + echo "::warning::Invalid iterations '${iterations}', defaulting to '${DEFAULT_ITERATIONS}'" + iterations="$DEFAULT_ITERATIONS" + fi + iterations="${iterations:-$DEFAULT_ITERATIONS}" + + if ! [[ "${warmup:-$DEFAULT_WARMUP}" =~ ^[0-9]+$ ]]; then + echo "::warning::Invalid warmup '${warmup}', defaulting to '${DEFAULT_WARMUP}'" + warmup="$DEFAULT_WARMUP" + fi + warmup="${warmup:-$DEFAULT_WARMUP}" echo "platform=${platform}" >> "$GITHUB_OUTPUT" echo "device_profile=${device_profile}" >> "$GITHUB_OUTPUT" @@ -106,9 +136,10 @@ jobs: REQUESTED_BY: ${{ github.event.comment.user.login }} FUNCTIONS: ${{ inputs.functions }} CRATE_PATH: ${{ inputs.crate_path }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | gh workflow run "$WORKFLOW" \ - --ref "${{ github.event.repository.default_branch }}" \ + --ref "$DEFAULT_BRANCH" \ -f head_sha="$HEAD_SHA" \ -f crate_path="$CRATE_PATH" \ -f functions="$FUNCTIONS" \ diff --git a/crates/mobench/python/render_sina_plot.py b/crates/mobench/python/render_sina_plot.py index ffa6e5d..af4446d 100644 --- a/crates/mobench/python/render_sina_plot.py +++ b/crates/mobench/python/render_sina_plot.py @@ -181,19 +181,31 @@ def _normalize_plot_spec(spec: dict[str, object]) -> dict[str, object]: def _validate_plot_spec(plot: dict[str, object]) -> None: - devices = plot.get("devices") + for key in ("function_label", "target", "devices"): + if key not in plot: + raise ValueError(f"plot spec missing required key: {key}") + + devices = plot["devices"] if not isinstance(devices, list) or not devices: raise ValueError("plot spec must contain at least one device") - for device in devices: - samples_ns = device.get("samples_ns") if isinstance(device, dict) else None + for i, device in enumerate(devices): + if not isinstance(device, dict): + raise ValueError(f"device[{i}] must be a JSON object") + for key in ("device_name", "os_version", "samples_ns"): + if key not in device: + raise ValueError(f"device[{i}] missing required key: {key}") + samples_ns = device["samples_ns"] if not isinstance(samples_ns, list) or not samples_ns: raise ValueError("each device must contain at least one sample") +_MAX_PACK_RETRIES = 20 + + def _pack_device_offsets(normalized_samples: Sequence[float]) -> list[float]: max_width = _BASE_HALF_WIDTH - while True: + for _ in range(_MAX_PACK_RETRIES): try: return pack_strip( normalized_samples, @@ -203,6 +215,10 @@ def _pack_device_offsets(normalized_samples: Sequence[float]) -> list[float]: ) except ValueError: max_width *= 2 + raise ValueError( + f"unable to pack {len(normalized_samples)} points after " + f"{_MAX_PACK_RETRIES} width expansions" + ) def _compute_device_centers(half_widths: Sequence[float]) -> list[float]: diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index 729500e..4876e17 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -788,9 +788,10 @@ impl BrowserStackClient { Ok(snapshots) } - /// Wait for build completion and fetch all results including performance metrics + /// Wait for build completion and fetch all results including performance metrics. /// - /// Returns both benchmark results and performance metrics + /// Convenience wrapper around [`Self::wait_and_fetch_all_results_with_poll`] + /// with default poll interval. #[allow(dead_code)] pub fn wait_and_fetch_all_results( &self, @@ -1927,6 +1928,9 @@ mod tests { match listener.accept() { Ok((mut stream, _peer)) => { last_activity = Instant::now(); + stream + .set_nonblocking(false) + .expect("set stream blocking"); let mut buf = [0_u8; 4096]; let bytes_read = stream.read(&mut buf).expect("read request"); @@ -1959,7 +1963,7 @@ mod tests { .expect("write response"); } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - if last_activity.elapsed() >= Duration::from_millis(250) { + if last_activity.elapsed() >= Duration::from_secs(2) { break; } thread::sleep(Duration::from_millis(10)); diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index b779750..c39d731 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -3924,8 +3924,8 @@ fn resolve_browserstack_credentials( } Ok(ResolvedBrowserStack { - username: username.unwrap(), - access_key: access_key.unwrap(), + username: username.context("BrowserStack username resolved to None")?, + access_key: access_key.context("BrowserStack access key resolved to None")?, project, }) } @@ -5007,10 +5007,24 @@ fn summary_markdown_output_dir(summary_path: &Path, output: Option<&Path>) -> Pa } fn upsert_github_pr_comment(pr_number: &str, marker: &str, body: &str) -> Result<()> { + // Validate inputs to prevent URL path injection + if pr_number.is_empty() || !pr_number.chars().all(|c| c.is_ascii_digit()) { + bail!("PR number must be numeric, got: {}", pr_number); + } let token = env::var("GITHUB_TOKEN").context("provider_error: GITHUB_TOKEN is required for publish")?; let repository = env::var("GITHUB_REPOSITORY") .context("provider_error: GITHUB_REPOSITORY is required for publish")?; + if repository.matches('/').count() != 1 + || repository + .chars() + .any(|c| !c.is_ascii_alphanumeric() && !matches!(c, '/' | '-' | '_' | '.')) + { + bail!( + "GITHUB_REPOSITORY must be owner/repo format, got: {}", + repository + ); + } let comments_url = format!( "https://api.github.com/repos/{}/issues/{}/comments", repository, pr_number @@ -5771,7 +5785,7 @@ fn cmd_list(project_root: Option, crate_path: Option) -> Resul println!("Usage:"); println!( " cargo mobench run --target android --function {} --iterations 100", - all_benchmarks.first().unwrap() + all_benchmarks.first().map(|s| s.as_str()).unwrap_or("my_benchmark") ); } @@ -9619,3 +9633,87 @@ mod init_sdk_tests { ); } } + +#[cfg(test)] +mod resource_usage_tests { + use super::*; + use serde_json::json; + + #[test] + fn test_extract_resource_usage_from_entry_fields() { + let entry = json!({ + "resources": { + "elapsed_cpu_ms": 120, + "total_pss_kb": 4096, + "private_dirty_kb": 2048, + "native_heap_kb": 1024, + "java_heap_kb": 512 + } + }); + + let usage = extract_benchmark_resource_usage(&entry, None).unwrap(); + assert_eq!(usage.cpu_total_ms, Some(120)); + assert_eq!(usage.total_pss_kb, Some(4096)); + assert_eq!(usage.private_dirty_kb, Some(2048)); + assert_eq!(usage.native_heap_kb, Some(1024)); + assert_eq!(usage.java_heap_kb, Some(512)); + // peak_memory_kb derived from raw fields when no perf_metrics + assert!(usage.peak_memory_kb.is_some()); + } + + #[test] + fn test_extract_resource_usage_with_perf_metrics_overrides_peak() { + let entry = json!({ + "resources": { + "total_pss_kb": 4096 + } + }); + let perf = browserstack::PerformanceMetrics { + sample_count: 5, + memory: Some(browserstack::AggregateMemoryMetrics { + peak_mb: 10.0, + average_mb: 8.0, + min_mb: 6.0, + }), + cpu: None, + snapshots: vec![], + }; + + let usage = extract_benchmark_resource_usage(&entry, Some(&perf)).unwrap(); + // peak_memory_kb should come from perf_metrics (10.0 * 1024 = 10240) + assert_eq!(usage.peak_memory_kb, Some(10240)); + assert_eq!(usage.total_pss_kb, Some(4096)); + } + + #[test] + fn test_extract_resource_usage_empty_returns_none() { + let entry = json!({}); + let usage = extract_benchmark_resource_usage(&entry, None); + assert!(usage.is_none()); + } + + #[test] + fn test_resource_usage_json_round_trip() { + let usage = BenchmarkResourceUsage { + cpu_total_ms: Some(250), + peak_memory_kb: Some(8192), + total_pss_kb: Some(4096), + private_dirty_kb: Some(2048), + native_heap_kb: Some(1024), + java_heap_kb: None, + }; + + let json_str = serde_json::to_string(&usage).unwrap(); + let deserialized: BenchmarkResourceUsage = serde_json::from_str(&json_str).unwrap(); + + assert_eq!(deserialized.cpu_total_ms, Some(250)); + assert_eq!(deserialized.peak_memory_kb, Some(8192)); + assert_eq!(deserialized.total_pss_kb, Some(4096)); + assert_eq!(deserialized.private_dirty_kb, Some(2048)); + assert_eq!(deserialized.native_heap_kb, Some(1024)); + assert_eq!(deserialized.java_heap_kb, None); + + // java_heap_kb should be absent in JSON due to skip_serializing_if + assert!(!json_str.contains("java_heap_kb")); + } +} diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index dd0eb1d..dfa8a22 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -124,16 +124,18 @@ pub fn render_profile_markdown(manifest: &ProfileManifest) -> String { let _ = writeln!(markdown, "- Run ID: `{}`", manifest.run_id); let _ = writeln!(markdown, "- Target: `{}`", manifest.target.as_str()); let _ = writeln!(markdown, "- Function: `{}`", manifest.function); - let _ = writeln!( - markdown, - "- Provider: `{}`", - manifest.provider.to_possible_value().unwrap().get_name() - ); - let _ = writeln!( - markdown, - "- Backend: `{}`", - manifest.backend.to_possible_value().unwrap().get_name() - ); + let provider_label = manifest + .provider + .to_possible_value() + .map(|v| v.get_name().to_owned()) + .unwrap_or_else(|| format!("{:?}", manifest.provider)); + let backend_label = manifest + .backend + .to_possible_value() + .map(|v| v.get_name().to_owned()) + .unwrap_or_else(|| format!("{:?}", manifest.backend)); + let _ = writeln!(markdown, "- Provider: `{}`", provider_label); + let _ = writeln!(markdown, "- Backend: `{}`", backend_label); let _ = writeln!( markdown, "- Status: `{}`", @@ -499,8 +501,9 @@ where .map(|artifact| artifact.path.clone()) .unwrap_or_else(|| processed_root.join("flamegraph.html")); - let package_name = "dev.world.bench"; - let activity_name = "dev.world.bench/.MainActivity"; + let package_name = android_profile_package_name(&layout.crate_name); + let activity_name = format!("{package_name}/.MainActivity"); + let simpleperf_path = resolve_android_simpleperf_path(); let install_output = run_adb_command(runner, ["install", "-r"], &build.app_path, None)?; if !install_output.status.success() { @@ -517,11 +520,11 @@ where bail!("adb install failed for {}: {}", build.app_path.display(), stderr.trim()); } - let mut record_command = Command::new("simpleperf"); + let mut record_command = Command::new(&simpleperf_path); record_command .arg("record") .arg("--app") - .arg(package_name) + .arg(&package_name) .arg("--call-graph") .arg("fp") .arg("--duration") @@ -532,7 +535,7 @@ where .arg("am") .arg("start") .arg("-n") - .arg(activity_name) + .arg(&activity_name) .arg("--es") .arg("bench_function") .arg(&args.function); @@ -572,7 +575,7 @@ where bail!("failed to pull simpleperf capture: {}", stderr.trim()); } - let mut report_command = Command::new("simpleperf"); + let mut report_command = Command::new(&simpleperf_path); report_command.arg("report").arg("-i").arg(&raw_perf_path); let report_output = runner.output(&mut report_command)?; if !report_output.status.success() { @@ -592,9 +595,7 @@ where } let report_text = String::from_utf8_lossy(&report_output.stdout).to_string(); - let addr2line_path = std::env::var("LLVM_ADDR2LINE") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("llvm-addr2line")); + let addr2line_path = resolve_android_addr2line_path(); let (symbolized_report, symbolization) = symbolize_android_capture_report( &report_text, &addr2line_path, @@ -689,6 +690,54 @@ struct AndroidSymbolizationTotals { unresolved: usize, } +fn android_profile_package_name(crate_name: &str) -> String { + format!( + "dev.world.{}", + mobench_sdk::codegen::sanitize_bundle_id_component(crate_name) + ) +} + +fn resolve_android_simpleperf_path() -> PathBuf { + if let Ok(path) = std::env::var("SIMPLEPERF") { + return PathBuf::from(path); + } + + let candidate = std::env::var("ANDROID_NDK_HOME") + .ok() + .map(PathBuf::from) + .map(|ndk_home| ndk_home.join("simpleperf/simpleperf")); + match candidate { + Some(path) if path.exists() => path, + _ => PathBuf::from("simpleperf"), + } +} + +fn resolve_android_addr2line_path() -> PathBuf { + if let Ok(path) = std::env::var("LLVM_ADDR2LINE") { + return PathBuf::from(path); + } + + let prebuilt_root = std::env::var("ANDROID_NDK_HOME") + .ok() + .map(PathBuf::from) + .map(|ndk_home| ndk_home.join("toolchains/llvm/prebuilt")); + + if let Some(prebuilt_root) = prebuilt_root + && let Ok(entries) = std::fs::read_dir(prebuilt_root) + { + let mut entries = entries.filter_map(|entry| entry.ok()).collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let candidate = entry.path().join("bin/llvm-addr2line"); + if candidate.exists() { + return candidate; + } + } + } + + PathBuf::from("llvm-addr2line") +} + fn render_android_flamegraph_html(report_text: &str, run_id: &str) -> String { let escaped = report_text .replace('&', "&") @@ -850,7 +899,11 @@ fn slugify_function_name(function: &str) -> String { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; use std::process::{Command, Output}; + use std::sync::Mutex; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); #[test] fn profile_manifest_serializes_partial_failure_state() { @@ -921,6 +974,7 @@ mod tests { #[test] fn local_android_capture_attempts_symbolization_and_updates_manifest_status() { + let _env_lock = env_lock(); let temp_dir = tempfile::tempdir().expect("temp dir"); let output_root = temp_dir.path().join("profile"); let run_id = "android-ffi_benchmark--bench_fibonacci-123"; @@ -963,9 +1017,7 @@ mod tests { .expect("write addr2line shim"); make_executable(&addr2line_shim); - unsafe { - std::env::set_var("LLVM_ADDR2LINE", &addr2line_shim); - } + let _addr2line_guard = EnvVarGuard::set("LLVM_ADDR2LINE", &addr2line_shim); let build_result = mobench_sdk::BuildResult { platform: mobench_sdk::Target::Android, @@ -1022,18 +1074,119 @@ mod tests { .join(run_id) .join("artifacts/processed/flamegraph.html") .exists()); + assert!(runner.commands.iter().any(|command| { + command.contains("simpleperf record") + && command.contains("dev.world.ffibenchmark") + && command.contains("dev.world.ffibenchmark/.MainActivity") + })); + assert!(runner + .commands + .iter() + .any(|command| command.contains("simpleperf report"))); + } + + #[test] + fn local_android_capture_resolves_tools_from_android_ndk_home() { + let _env_lock = env_lock(); + let temp_dir = tempfile::tempdir().expect("temp dir"); + let output_root = temp_dir.path().join("profile"); + let run_id = "android-ffi_benchmark--bench_fibonacci-ndk"; + let args = ProfileRunArgs { + target: MobileTarget::Android, + function: "ffi_benchmark::bench_fibonacci".into(), + crate_path: Some(workspace_root().join("examples/ffi-benchmark")), + config: None, + output_dir: output_root.clone(), + provider: ProfileProvider::Local, + backend: ProfileBackend::AndroidNative, + format: ProfileFormat::Both, + }; + let mut manifest = build_capture_plan(&args, &output_root.join(run_id), run_id) + .expect("build capture plan"); + + let layout = crate::resolve_project_layout(crate::ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: args.crate_path.as_deref(), + config_path: args.config.as_deref(), + }) + .expect("resolve layout"); + + let ndk_root = temp_dir.path().join("android-ndk"); + let simpleperf_path = ndk_root.join("simpleperf/simpleperf"); + std::fs::create_dir_all(simpleperf_path.parent().expect("simpleperf parent")) + .expect("create simpleperf dir"); + std::fs::write(&simpleperf_path, b"#!/bin/sh\nexit 0\n").expect("write simpleperf shim"); + make_executable(&simpleperf_path); + + let addr2line_path = ndk_root.join("toolchains/llvm/prebuilt/test-host/bin/llvm-addr2line"); + std::fs::create_dir_all(addr2line_path.parent().expect("addr2line parent")) + .expect("create addr2line dir"); + std::fs::write( + &addr2line_path, + b"#!/bin/sh\nprintf 'sample_fns::fibonacci\\n/opt/sample_fns.rs:123\\n'\n", + ) + .expect("write addr2line shim"); + make_executable(&addr2line_path); + + let limited_path = PathBuf::from("/bin"); + let _ndk_guard = EnvVarGuard::set("ANDROID_NDK_HOME", &ndk_root); + let _llvm_guard = EnvVarGuard::remove("LLVM_ADDR2LINE"); + let _path_guard = EnvVarGuard::set("PATH", &limited_path); + + let app_path = temp_dir.path().join("app.apk"); + std::fs::write(&app_path, b"apk").expect("write app artifact"); + let packaged_lib = temp_dir.path().join("libsample_fns.so"); + let unstripped_lib = temp_dir.path().join("libsample_fns.so.unstripped"); + std::fs::write(&packaged_lib, b"so").expect("write packaged lib"); + std::fs::write(&unstripped_lib, b"so").expect("write unstripped lib"); + let build_result = mobench_sdk::BuildResult { + platform: mobench_sdk::Target::Android, + app_path: app_path.clone(), + test_suite_path: Some(temp_dir.path().join("androidTest.apk")), + native_libraries: vec![mobench_sdk::NativeLibraryArtifact { + abi: "arm64-v8a".into(), + packaged_path: packaged_lib.clone(), + unstripped_path: unstripped_lib.clone(), + }], + }; + + let mut runner = RecordingRunner { + outputs: vec![ + success_output(""), + success_output(""), + success_output(""), + success_output("libsample_fns.so[+0x1a2b]\n"), + ], + commands: Vec::new(), + }; + + execute_local_android_capture_with_runner( + &args, + &output_root.join(run_id), + &mut manifest, + &layout, + |_| Ok(build_result.clone()), + &mut runner, + ) + .expect("android capture attempt"); + assert!(runner .commands .iter() - .any(|command| command.contains("simpleperf record"))); + .any(|command| command.starts_with(&format!("{} record", simpleperf_path.display())))); assert!(runner .commands .iter() - .any(|command| command.contains("simpleperf report"))); - - unsafe { - std::env::remove_var("LLVM_ADDR2LINE"); - } + .any(|command| command.starts_with(&format!("{} report", simpleperf_path.display())))); + assert_eq!( + manifest + .symbolization + .as_ref() + .expect("symbolization record") + .tool, + addr2line_path.display().to_string() + ); } #[test] @@ -1434,6 +1587,46 @@ mod tests { } } + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + ENV_MUTEX.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } + + fn remove(key: &'static str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::remove_var(key); + } + Self { key, original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => unsafe { + std::env::set_var(self.key, value); + }, + None => unsafe { + std::env::remove_var(self.key); + }, + } + } + } + struct RecordingRunner { outputs: Vec, commands: Vec, diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index 6116e8d..36a41bc 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -285,7 +285,16 @@ pub fn load_results_dir(dir: &Path) -> Result { Ok(report) } +const MAX_DIR_DEPTH: usize = 10; + fn collect_json_files(dir: &Path, out: &mut Vec) -> Result<()> { + collect_json_files_inner(dir, out, 0) +} + +fn collect_json_files_inner(dir: &Path, out: &mut Vec, depth: usize) -> Result<()> { + if depth > MAX_DIR_DEPTH { + return Ok(()); + } let mut entries = std::fs::read_dir(dir) .with_context(|| format!("Failed to read results directory {}", dir.display()))? .collect::, _>>() @@ -294,8 +303,11 @@ fn collect_json_files(dir: &Path, out: &mut Vec) -> Result<()> { for entry in entries { let path = entry.path(); + if path.is_symlink() { + continue; + } if path.is_dir() { - collect_json_files(&path, out)?; + collect_json_files_inner(&path, out, depth + 1)?; } else if path.extension().is_some_and(|ext| ext == "json") { out.push(path); } diff --git a/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_alpha/summary.json b/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_alpha/summary.json index 5c0c3af..2b57751 100644 --- a/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_alpha/summary.json +++ b/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_alpha/summary.json @@ -19,7 +19,7 @@ }, "summary": { "generated_at": "2026-03-01T09:00:00Z", - "generated_at_unix": 1740819600, + "generated_at_unix": 1772355600, "target": "android", "function": "bench_alpha", "iterations": 20, diff --git a/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_beta/summary.json b/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_beta/summary.json index b404af9..78186e5 100644 --- a/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_beta/summary.json +++ b/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_beta/summary.json @@ -19,7 +19,7 @@ }, "summary": { "generated_at": "2026-03-01T09:05:00Z", - "generated_at_unix": 1740819900, + "generated_at_unix": 1772355900, "target": "android", "function": "bench_beta", "iterations": 20, diff --git a/crates/mobench/tests/fixtures/ci-artifact-root/android/summary.json b/crates/mobench/tests/fixtures/ci-artifact-root/android/summary.json index 77b4310..9313c11 100644 --- a/crates/mobench/tests/fixtures/ci-artifact-root/android/summary.json +++ b/crates/mobench/tests/fixtures/ci-artifact-root/android/summary.json @@ -1,7 +1,7 @@ { "summary": { "generated_at": "2026-03-01T10:00:00Z", - "generated_at_unix": 1740823200, + "generated_at_unix": 1772359200, "target": "android", "function": "multiple", "iterations": 20, From 9c2d56cffb3c9f55be8038572f33972d6c436654 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 21:23:56 -0700 Subject: [PATCH 150/196] feat: finalize local flamegraph profiling viewer --- crates/mobench-sdk/src/codegen.rs | 43 +- .../android/app/src/main/AndroidManifest.xml | 1 + .../BenchRunner/ContentView.swift.template | 81 +- crates/mobench/src/flamegraph_viewer.rs | 1550 +++++++++++++++++ crates/mobench/src/lib.rs | 1 + crates/mobench/src/profile.rs | 382 +++- 6 files changed, 1997 insertions(+), 61 deletions(-) create mode 100644 crates/mobench/src/flamegraph_viewer.rs diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index f512315..dae1f2a 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -1083,11 +1083,16 @@ pub fn ensure_ios_project_with_options( crate_dir: Option<&Path>, ) -> Result<(), BenchError> { let library_name = crate_name.replace('-', "_"); - if ios_project_exists(output_dir) && ios_project_matches_library(output_dir, &library_name) { - return Ok(()); + let project_exists = ios_project_exists(output_dir); + let project_matches = ios_project_matches_library(output_dir, &library_name); + if project_exists && !project_matches { + println!("Existing iOS scaffolding does not match library, regenerating..."); + } else if project_exists { + println!("Refreshing generated iOS scaffolding..."); + } else { + println!("iOS project not found, generating scaffolding..."); } - println!("iOS project not found, generating scaffolding..."); // Use fixed "BenchRunner" for project/scheme name to match template directory structure let project_pascal = "BenchRunner"; // Derive library name and bundle prefix from crate name @@ -1451,6 +1456,38 @@ pub fn public_bench() { fs::remove_dir_all(&temp_dir).ok(); } + #[test] + fn test_ensure_ios_project_refreshes_existing_content_view_template() { + let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None) + .expect("initial iOS project generation should succeed"); + + let content_view_path = temp_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift"); + assert!(content_view_path.exists(), "ContentView.swift should exist"); + + fs::write(&content_view_path, "stale generated content").unwrap(); + + ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None) + .expect("refreshing existing iOS project should succeed"); + + let refreshed = fs::read_to_string(&content_view_path).unwrap(); + assert!( + refreshed.contains("ProfileLaunchOptions"), + "refreshed ContentView.swift should contain the latest profiling template, got:\n{}", + refreshed + ); + assert!( + refreshed.contains("repeatUntilMs"), + "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}", + refreshed + ); + + fs::remove_dir_all(&temp_dir).ok(); + } + #[test] fn test_cross_platform_naming_consistency() { // Test that Android and iOS use the same naming convention for package/bundle IDs diff --git a/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml b/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml index eda071a..24aa990 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml +++ b/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ android:roundIcon="@android:drawable/ic_dialog_info" android:supportsRtl="true" android:theme="@style/Theme.{{PROJECT_NAME_PASCAL}}"> + diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template index 0ea80de..07fa8e4 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template @@ -1,5 +1,59 @@ import SwiftUI +private struct ProfileLaunchOptions { + let benchDelayMs: UInt64 + let resultHoldMs: UInt64 + let repeatUntilMs: UInt64 + let warmupOnly: Bool + + static func resolved() -> ProfileLaunchOptions { + let info = ProcessInfo.processInfo + + var benchDelayMs = UInt64(info.environment["MOBENCH_BENCH_DELAY_MS"] ?? "0") ?? 0 + var resultHoldMs = UInt64( + info.environment["MOBENCH_PROFILE_RESULT_HOLD_MS"] ?? "5000" + ) ?? 5000 + var repeatUntilMs = UInt64( + info.environment["MOBENCH_PROFILE_REPEAT_UNTIL_MS"] ?? "0" + ) ?? 0 + var warmupOnly = info.environment["MOBENCH_PROFILE_WARMUP_ONLY"] == "1" + + for arg in info.arguments { + if arg.hasPrefix("--mobench-profile-bench-delay-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + benchDelayMs = parsed + } else if arg.hasPrefix("--mobench-profile-result-hold-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + resultHoldMs = parsed + } else if arg.hasPrefix("--mobench-profile-repeat-until-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + repeatUntilMs = parsed + } else if arg == "--mobench-profile-warmup-only" + || arg == "--mobench-profile-warmup-only=1" { + warmupOnly = true + } + } + + NSLog( + "[BenchRunner] Profile launch options: delayMs=%llu, repeatUntilMs=%llu, resultHoldMs=%llu, warmupOnly=%@", + benchDelayMs, + repeatUntilMs, + resultHoldMs, + warmupOnly ? "true" : "false" + ) + + return ProfileLaunchOptions( + benchDelayMs: benchDelayMs, + resultHoldMs: resultHoldMs, + repeatUntilMs: repeatUntilMs, + warmupOnly: warmupOnly + ) + } +} + struct ContentView: View { @State private var report: String = "Running benchmarks..." @State private var reportJSON: String = "" @@ -36,7 +90,19 @@ struct ContentView: View { } .onAppear { Task { - let result = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + let options = ProfileLaunchOptions.resolved() + if options.benchDelayMs > 0 { + try? await Task.sleep(nanoseconds: options.benchDelayMs * 1_000_000) + } + let repeatDeadline = Date().addingTimeInterval( + Double(options.repeatUntilMs) / 1_000.0 + ) + var repeatedRuns = 1 + var result = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + while !options.warmupOnly && options.repeatUntilMs > 0 && Date() < repeatDeadline { + result = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + repeatedRuns += 1 + } report = result.displayText reportJSON = result.jsonReport isCompleted = true @@ -45,10 +111,17 @@ struct ContentView: View { NSLog("BENCH_REPORT_JSON_START") NSLog("%@", result.jsonReport) NSLog("BENCH_REPORT_JSON_END") + if repeatedRuns > 1 { + NSLog("Repeated benchmark %d time(s) during profile capture", repeatedRuns) + } + + if options.warmupOnly { + NSLog("Warmup-only profile run complete") + return + } - // Keep the report on screen for at least 5 seconds so BrowserStack video captures it - NSLog("Displaying results for 5 seconds for video capture...") - try? await Task.sleep(nanoseconds: 5_000_000_000) + NSLog("Displaying results for \(options.resultHoldMs) ms for capture output...") + try? await Task.sleep(nanoseconds: options.resultHoldMs * 1_000_000) NSLog("Display hold complete") } } diff --git a/crates/mobench/src/flamegraph_viewer.rs b/crates/mobench/src/flamegraph_viewer.rs new file mode 100644 index 0000000..2554b02 --- /dev/null +++ b/crates/mobench/src/flamegraph_viewer.rs @@ -0,0 +1,1550 @@ +use anyhow::{Context, Result}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Cursor; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FocusedFoldedStacks { + pub folded: String, + pub matched_stack_count: usize, + pub excluded_stack_count: usize, + pub included_samples: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct FrameBreakdown { + pub frame: String, + pub samples: u64, + pub percent_total: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ModeSummary { + pub total_samples: u64, + pub visible_stack_count: usize, + pub matched_stack_count: usize, + pub excluded_stack_count: usize, + pub warning: Option, + pub self_frames: Vec, + pub inclusive_frames: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ArtifactLink { + pub label: String, + pub path: String, +} + +impl ArtifactLink { + pub(crate) fn new(label: impl Into, path: impl Into) -> Self { + Self { + label: label.into(), + path: path.into(), + } + } +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FlamegraphMode { + Focused, + Full, +} + +impl FlamegraphMode { + fn as_str(self) -> &'static str { + match self { + Self::Focused => "focused", + Self::Full => "full", + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct FlamegraphViewerDoc { + pub title: String, + pub full_svg_document: String, + pub focused_svg_document: String, + pub full_summary: ModeSummary, + pub focused_summary: ModeSummary, + pub default_mode: FlamegraphMode, + pub artifact_links: Vec, +} + +pub(crate) fn derive_benchmark_focused_folded_stacks( + folded: &str, + anchors: &[&str], +) -> FocusedFoldedStacks { + let mut lines = Vec::new(); + let mut matched_stack_count = 0_usize; + let mut excluded_stack_count = 0_usize; + let mut included_samples = 0_u64; + + for line in folded.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Some((stack, count)) = split_folded_stack_line(trimmed) else { + excluded_stack_count += 1; + continue; + }; + let frames: Vec<&str> = stack.split(';').collect(); + let Some(trimmed_frames) = trim_stack_to_first_anchor(&frames, anchors) else { + excluded_stack_count += 1; + continue; + }; + matched_stack_count += 1; + included_samples += count; + lines.push(format!("{} {}", trimmed_frames.join(";"), count)); + } + + FocusedFoldedStacks { + folded: lines.join("\n"), + matched_stack_count, + excluded_stack_count, + included_samples, + } +} + +pub(crate) fn summarize_folded_stacks( + folded: &str, + matched_stack_count: usize, + excluded_stack_count: usize, + warning: Option, +) -> ModeSummary { + let mut total_samples = 0_u64; + let mut visible_stack_count = 0_usize; + let mut inclusive_samples: BTreeMap = BTreeMap::new(); + let mut self_samples: BTreeMap = BTreeMap::new(); + + for line in folded.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Some((stack, count)) = split_folded_stack_line(trimmed) else { + continue; + }; + let frames: Vec = stack.split(';').map(prettify_frame_label).collect(); + visible_stack_count += 1; + total_samples += count; + let mut seen_frames = BTreeSet::new(); + for frame in &frames { + if seen_frames.insert(frame.clone()) { + *inclusive_samples.entry(frame.clone()).or_default() += count; + } + } + if let Some(leaf_frame) = frames.last() { + *self_samples.entry(leaf_frame.clone()).or_default() += count; + } + } + + ModeSummary { + total_samples, + visible_stack_count, + matched_stack_count, + excluded_stack_count, + warning, + self_frames: build_frame_breakdown_list(self_samples, total_samples), + inclusive_frames: build_frame_breakdown_list(inclusive_samples, total_samples), + } +} + +pub(crate) fn count_folded_stack_lines(folded: &str) -> usize { + folded.lines().filter(|line| !line.trim().is_empty()).count() +} + +pub(crate) fn render_standalone_flamegraph_svg( + folded_stacks: &str, + title: &str, +) -> Result { + if folded_stacks.trim().is_empty() { + return Ok("

No native frames were symbolized.

" + .into()); + } + + let mut options = inferno::flamegraph::Options::default(); + options.title = title.into(); + let mut rendered = Vec::new(); + let display_stacks = prettify_folded_stacks_for_display(folded_stacks); + inferno::flamegraph::from_reader( + &mut options, + Cursor::new(display_stacks.as_bytes()), + &mut rendered, + )?; + let rendered = String::from_utf8(rendered).context("inferno produced non-UTF-8 flamegraph")?; + Ok(finalize_standalone_flamegraph_document(rendered)) +} + +pub(crate) fn render_flamegraph_viewer_html(doc: FlamegraphViewerDoc) -> String { + let default_mode = doc.default_mode.as_str(); + let full_svg = escape_json_for_inline_script( + &serde_json::to_string(&doc.full_svg_document).expect("serialize full svg"), + ); + let focused_svg = escape_json_for_inline_script( + &serde_json::to_string(&doc.focused_svg_document).expect("serialize focused svg"), + ); + let full_summary = escape_json_for_inline_script( + &serde_json::to_string(&doc.full_summary).expect("serialize full mode summary"), + ); + let focused_summary = escape_json_for_inline_script( + &serde_json::to_string(&doc.focused_summary).expect("serialize focused mode summary"), + ); + let artifact_links = escape_json_for_inline_script( + &serde_json::to_string(&doc.artifact_links).expect("serialize flamegraph artifact links"), + ); + let default_mode_json = escape_json_for_inline_script( + &serde_json::to_string(default_mode).expect("serialize default flamegraph mode"), + ); + + format!( + r#" + + + + + {title} + + + +
+
+
+ {title} + + +
+
+ + + + + +
+
+
+
+ Mode + Benchmark Only +
+
+ Current Root + all +
+
+ Visible Samples + - +
+
+ Selection Width + 100% +
+
+
+
+
+ + +
+
+
+
Drag across the graph to zoom the current x-axis range
+
+
+ +
+
+ + +"#, + title = escape_html(&doc.title), + focused_svg = focused_svg, + full_svg = full_svg, + focused_summary = focused_summary, + full_summary = full_summary, + artifact_links = artifact_links, + default_mode_json = default_mode_json + ) +} + +fn escape_json_for_inline_script(json: &str) -> String { + json.replace(", + total_samples: u64, +) -> Vec { + let mut frames: Vec = frame_samples + .into_iter() + .map(|(frame, samples)| FrameBreakdown { + frame, + samples, + percent_total: if total_samples == 0 { + 0 + } else { + samples.saturating_mul(100) / total_samples + }, + }) + .collect(); + frames.sort_by(|left, right| { + right + .samples + .cmp(&left.samples) + .then_with(|| left.frame.cmp(&right.frame)) + }); + frames.truncate(12); + frames +} + +fn prettify_folded_stacks_for_display(folded_stacks: &str) -> String { + let mut lines = Vec::new(); + for line in folded_stacks.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Some((stack, count)) = split_folded_stack_line(trimmed) else { + lines.push(trimmed.to_string()); + continue; + }; + let pretty_stack = stack + .split(';') + .map(prettify_frame_label) + .collect::>() + .join(";"); + lines.push(format!("{pretty_stack} {count}")); + } + lines.join("\n") +} + +fn prettify_frame_label(frame: &str) -> String { + let mut pretty = frame.to_string(); + for (needle, replacement) in [ + ("_$LT$", "<"), + ("$LT$", "<"), + ("$GT$", ">"), + ("$u20$", " "), + ("$u7b$", "{"), + ("$u7d$", "}"), + ("$u5b$", "["), + ("$u5d$", "]"), + ("$LP$", "("), + ("$RP$", ")"), + ("$C$", ","), + ("$RF$", "&"), + ] { + pretty = pretty.replace(needle, replacement); + } + pretty = pretty.replace("..", "::"); + + if let Some(hash_idx) = pretty.rfind("::h") { + let hash = &pretty[hash_idx + 3..]; + if !hash.is_empty() && hash.chars().all(|ch| ch.is_ascii_hexdigit()) { + pretty.truncate(hash_idx); + } + } + + pretty +} + +fn trim_stack_to_first_anchor<'a>(frames: &'a [&'a str], anchors: &[&str]) -> Option<&'a [&'a str]> { + frames + .iter() + .position(|frame| anchors.iter().any(|anchor| frame.contains(anchor))) + .map(|idx| &frames[idx..]) +} + +fn split_folded_stack_line(line: &str) -> Option<(&str, u64)> { + let split = line.rfind(' ')?; + let count = line[split + 1..].parse().ok()?; + Some((&line[..split], count)) +} + +fn finalize_standalone_flamegraph_document(rendered: String) -> String { + let rendered = rendered.replacen( + " String { + let Some(index) = document.rfind("") else { + return document; + }; + let mut output = String::with_capacity(document.len() + script.len() + 48); + output.push_str(&document[..index]); + output.push_str(""); + output.push_str(&document[index..]); + output +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +const MOBENCH_SVG_HELPER_SCRIPT: &str = r#" +(function () { + function mobenchTotalSamples() { + return typeof total_samples === "number" + ? total_samples + : parseInt(frames.attributes.total_samples.value || "0", 10); + } + + function mobenchTitleForNode(node) { + try { + var title = find_child(node, "title"); + if (!title || !title.textContent) return "selected range"; + return title.textContent.replace(/\s+\([^)]*\)$/, ""); + } catch (_error) { + return "selected range"; + } + } + + function mobenchNormalizeView(view) { + var total = mobenchTotalSamples(); + var xmin = Math.max(0, Math.floor(view.xmin || 0)); + var width = Math.max(1, Math.floor(view.width || total || 1)); + if (xmin + width > total && total > 0) { + width = total - xmin; + } + if (width <= 0) { + xmin = 0; + width = Math.max(1, total); + } + return { + xmin: xmin, + width: width, + label: view.label || "selected range" + }; + } + + var mobenchState = { + history: [], + index: -1, + current: null + }; + + var mobenchCollapsedTowerState = { + originalViewBox: null, + originalHeight: null, + originalBodyHeight: "", + originalDocumentHeight: "" + }; + + function mobenchSvgRoot() { + return document.querySelector("svg"); + } + + function mobenchRememberViewport() { + var svg = mobenchSvgRoot(); + if (!svg) return null; + if (mobenchCollapsedTowerState.originalViewBox === null) { + mobenchCollapsedTowerState.originalViewBox = + svg.getAttribute("viewBox") + || ("0 0 " + (svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal.width : 1200) + + " " + + (svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal.height : parseFloat(svg.getAttribute("height") || "900"))); + mobenchCollapsedTowerState.originalHeight = svg.getAttribute("height"); + mobenchCollapsedTowerState.originalBodyHeight = document.body.style.height || ""; + mobenchCollapsedTowerState.originalDocumentHeight = document.documentElement.style.height || ""; + } + return svg; + } + + window.mobenchClearCollapsedTowerPresentation = function () { + var svg = mobenchRememberViewport(); + var elements = frames.children; + for (var i = 0; i < elements.length; i++) { + if (elements[i].dataset.mobenchTowerHidden === "1") { + elements[i].style.display = ""; + delete elements[i].dataset.mobenchTowerHidden; + } + } + if (svg) { + if (mobenchCollapsedTowerState.originalViewBox !== null) { + svg.setAttribute("viewBox", mobenchCollapsedTowerState.originalViewBox); + } + if (mobenchCollapsedTowerState.originalHeight !== null) { + svg.setAttribute("height", mobenchCollapsedTowerState.originalHeight); + } else { + svg.removeAttribute("height"); + } + document.body.style.height = mobenchCollapsedTowerState.originalBodyHeight; + document.documentElement.style.height = mobenchCollapsedTowerState.originalDocumentHeight; + } + }; + + window.mobenchGetVisibleFrames = function () { + var visible = []; + var elements = frames.children; + for (var i = 0; i < elements.length; i++) { + var element = elements[i]; + if (element.classList.contains("hide") || element.dataset.mobenchTowerHidden === "1") { + continue; + } + var rect = find_child(element, "rect"); + if (!rect || !rect.attributes["fg:x"] || !rect.attributes["fg:w"]) { + continue; + } + var titleNode = find_child(element, "title"); + var titleText = titleNode && titleNode.textContent ? titleNode.textContent : ""; + var samplesMatch = titleText.match(/\(([0-9,]+)\s+samples?/); + visible.push({ + index: i, + label: mobenchTitleForNode(element), + title: titleText, + xPct: parseFloat(rect.attributes.x.value || "0"), + widthPct: parseFloat(rect.attributes.width.value || "0"), + x: parseInt(rect.attributes["fg:x"].value || "0", 10), + width: parseInt(rect.attributes["fg:w"].value || "0", 10), + y: parseFloat(rect.attributes.y.value || "0"), + samples: samplesMatch ? parseInt(samplesMatch[1].replace(/,/g, ""), 10) : 0 + }); + } + return visible; + }; + + window.mobenchSetCollapsedTowerPresentation = function (presentation) { + window.mobenchClearCollapsedTowerPresentation(); + if (!presentation || !presentation.hiddenIndexes || !presentation.hiddenIndexes.length) { + return false; + } + var svg = mobenchRememberViewport(); + var elements = frames.children; + for (var i = 0; i < presentation.hiddenIndexes.length; i++) { + var index = presentation.hiddenIndexes[i]; + if (!elements[index]) continue; + elements[index].style.display = "none"; + elements[index].dataset.mobenchTowerHidden = "1"; + } + if (svg && presentation.viewport) { + var originalViewBox = (mobenchCollapsedTowerState.originalViewBox || "0 0 1200 900") + .split(/\s+/) + .map(function (value) { return parseFloat(value) || 0; }); + var minY = Math.max(0, presentation.viewport.minY || 0); + var height = Math.max(220, presentation.viewport.height || originalViewBox[3] || 900); + svg.setAttribute("viewBox", [originalViewBox[0], minY, originalViewBox[2], height].join(" ")); + svg.setAttribute("height", String(Math.round(height))); + document.body.style.height = Math.round(height) + "px"; + document.documentElement.style.height = Math.round(height) + "px"; + } + return true; + }; + + function mobenchResetDom() { + window.mobenchClearCollapsedTowerPresentation(); + var elements = frames.children; + for (var i = 0; i < elements.length; i++) { + elements[i].classList.remove("parent"); + elements[i].classList.remove("hide"); + zoom_reset(elements[i]); + } + update_text_for_elements(elements); + } + + function mobenchNotifyParent() { + try { + parent.postMessage({ + type: "mobench:view-change", + label: mobenchState.current ? mobenchState.current.label : "all", + start: mobenchState.current ? mobenchState.current.xmin : 0, + width: mobenchState.current ? mobenchState.current.width : mobenchTotalSamples(), + total: mobenchTotalSamples() + }, "*"); + } catch (_error) {} + } + + function mobenchPushHistory(view) { + if ( + mobenchState.index >= 0 && + mobenchState.history[mobenchState.index] && + mobenchState.history[mobenchState.index].xmin === view.xmin && + mobenchState.history[mobenchState.index].width === view.width && + mobenchState.history[mobenchState.index].label === view.label + ) { + return; + } + mobenchState.history = mobenchState.history.slice(0, mobenchState.index + 1); + mobenchState.history.push(view); + mobenchState.index = mobenchState.history.length - 1; + } + + function mobenchApplyAbsoluteRange(xmin, width, label, pushHistory) { + if (!frames) return false; + var total = mobenchTotalSamples(); + var view = mobenchNormalizeView({ + xmin: xmin, + width: width, + label: label + }); + mobenchResetDom(); + var elements = frames.children; + var toUpdate = []; + var xmax = view.xmin + view.width; + for (var i = 0; i < elements.length; i++) { + var element = elements[i]; + var rect = find_child(element, "rect"); + if (!rect || !rect.attributes["fg:x"] || !rect.attributes["fg:w"]) { + continue; + } + var ex = parseInt(rect.attributes["fg:x"].value, 10); + var ew = parseInt(rect.attributes["fg:w"].value, 10); + var ix0 = Math.max(ex, view.xmin); + var ix1 = Math.min(ex + ew, xmax); + if (!(ix1 > ix0)) { + element.classList.add("hide"); + continue; + } + rect.attributes.x.value = format_percent(100 * (ix0 - view.xmin) / view.width); + rect.attributes.width.value = format_percent(100 * (ix1 - ix0) / view.width); + toUpdate.push(element); + } + update_text_for_elements(toUpdate); + mobenchState.current = view; + if (pushHistory !== false) { + mobenchPushHistory(view); + } + mobenchNotifyParent(); + return view.width < total; + } + + window.mobenchResetView = function () { + var view = { + xmin: 0, + width: Math.max(1, mobenchTotalSamples()), + label: "all" + }; + mobenchResetDom(); + mobenchState.history = [view]; + mobenchState.index = 0; + mobenchState.current = view; + mobenchNotifyParent(); + }; + + window.mobenchCanGoBack = function () { + return mobenchState.index > 0; + }; + + window.mobenchCanGoForward = function () { + return mobenchState.index >= 0 && mobenchState.index < mobenchState.history.length - 1; + }; + + window.mobenchHistoryBack = function () { + if (!window.mobenchCanGoBack()) return false; + mobenchState.index -= 1; + var view = mobenchState.history[mobenchState.index]; + return mobenchApplyAbsoluteRange(view.xmin, view.width, view.label, false); + }; + + window.mobenchHistoryForward = function () { + if (!window.mobenchCanGoForward()) return false; + mobenchState.index += 1; + var view = mobenchState.history[mobenchState.index]; + return mobenchApplyAbsoluteRange(view.xmin, view.width, view.label, false); + }; + + window.mobenchZoomToFrame = function (node, pushHistory) { + var rect = find_child(node, "rect"); + if (!rect || !rect.attributes["fg:x"] || !rect.attributes["fg:w"]) { + return false; + } + return mobenchApplyAbsoluteRange( + parseInt(rect.attributes["fg:x"].value, 10), + parseInt(rect.attributes["fg:w"].value, 10), + mobenchTitleForNode(node), + pushHistory !== false + ); + }; + + window.mobenchZoomVisibleFraction = function (from, to) { + var total = mobenchTotalSamples(); + if (!mobenchState.current) { + mobenchState.current = { xmin: 0, width: total, label: "all" }; + } + var start = Math.min(from, to); + var end = Math.max(from, to); + if (!isFinite(start) || !isFinite(end) || (end - start) < 0.015) { + return false; + } + var xmin = Math.floor(mobenchState.current.xmin + mobenchState.current.width * start); + var width = Math.max(1, Math.floor(mobenchState.current.width * (end - start))); + return mobenchApplyAbsoluteRange(xmin, width, "selected range", true); + }; + + window.mobenchZoomAbsoluteRange = function (xmin, width, label) { + if (!isFinite(xmin) || !isFinite(width) || width <= 0) { + return false; + } + return mobenchApplyAbsoluteRange(xmin, width, label || "selected range", true); + }; + + window.mobenchSearch = function (term) { + if (!term) { + if (typeof reset_search === "function") { + reset_search(); + } + searching = 0; + mobenchNotifyParent(); + return; + } + if (typeof search === "function") { + search(term); + mobenchNotifyParent(); + } + }; + + window.mobenchGetViewState = function () { + if (!mobenchState.current) { + return { + label: "all", + start: 0, + width: mobenchTotalSamples(), + total: mobenchTotalSamples() + }; + } + return { + label: mobenchState.current.label, + start: mobenchState.current.xmin, + width: mobenchState.current.width, + total: mobenchTotalSamples() + }; + }; + + zoom = function (node) { + return window.mobenchZoomToFrame(node, true); + }; + + unzoom = function () { + window.mobenchResetView(); + }; + + window.addEventListener("load", function () { + setTimeout(function () { + window.mobenchResetView(); + }, 0); + }); +})(); +"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_doc() -> FlamegraphViewerDoc { + FlamegraphViewerDoc { + title: "iOS Native Profile".into(), + full_svg_document: "".into(), + focused_svg_document: "".into(), + full_summary: ModeSummary { + total_samples: 10, + visible_stack_count: 2, + matched_stack_count: 2, + excluded_stack_count: 0, + warning: None, + self_frames: vec![FrameBreakdown { + frame: "start".into(), + samples: 10, + percent_total: 100, + }], + inclusive_frames: vec![FrameBreakdown { + frame: "start".into(), + samples: 10, + percent_total: 100, + }], + }, + focused_summary: ModeSummary { + total_samples: 5, + visible_stack_count: 1, + matched_stack_count: 1, + excluded_stack_count: 1, + warning: Some("focused warning".into()), + self_frames: vec![FrameBreakdown { + frame: "sample_fns::fibonacci".into(), + samples: 5, + percent_total: 100, + }], + inclusive_frames: vec![FrameBreakdown { + frame: "sample_fns::run_benchmark".into(), + samples: 5, + percent_total: 100, + }], + }, + default_mode: FlamegraphMode::Focused, + artifact_links: vec![ArtifactLink::new("native-report.txt", "native-report.txt")], + } + } + + #[test] + fn focused_stack_derivation_returns_empty_when_no_anchor_matches() { + let folded = "start;UIKitMain 1\n"; + let focused = + derive_benchmark_focused_folded_stacks(folded, &["sample_fns::run_benchmark"]); + assert!(focused.folded.is_empty()); + assert_eq!(focused.matched_stack_count, 0); + } + + #[test] + fn derive_benchmark_focused_folded_stacks_trims_ios_stack_to_benchmark_anchor() { + let folded = concat!( + "start;UIApplicationMain;runBenchmark(spec:);uniffi_sample_fns_fn_func_run_benchmark;", + "sample_fns::run_benchmark;mobench_sdk::timing::profile_phase;", + "sample_fns::fibonacci 5\n" + ); + + let focused = derive_benchmark_focused_folded_stacks( + folded, + &["runBenchmark(spec:)", "sample_fns::run_benchmark"], + ); + + assert_eq!( + focused.folded, + "runBenchmark(spec:);uniffi_sample_fns_fn_func_run_benchmark;sample_fns::run_benchmark;mobench_sdk::timing::profile_phase;sample_fns::fibonacci 5" + ); + } + + #[test] + fn derive_benchmark_focused_folded_stacks_trims_android_stack_to_rust_anchor() { + let folded = concat!( + "all;uniffi.sample_fns.Sample_fnsKt.runBenchmark;", + "libsample_fns.so;sample_fns::run_benchmark;mobench_sdk::timing::run_closure;", + "sample_fns::fibonacci 3\n" + ); + + let focused = derive_benchmark_focused_folded_stacks( + folded, + &["sample_fns::run_benchmark", "mobench_sdk::timing::run_closure"], + ); + + assert_eq!( + focused.folded, + "sample_fns::run_benchmark;mobench_sdk::timing::run_closure;sample_fns::fibonacci 3" + ); + } + + #[test] + fn standalone_viewer_html_embeds_full_and_focused_modes() { + let html = render_flamegraph_viewer_html(sample_doc()); + + assert!(html.contains("Benchmark Only")); + assert!(html.contains("Full Process")); + assert!(html.contains("data-mode=\"focused\"")); + assert!(html.contains("data-mode=\"full\"")); + assert!(html.contains("<\\/svg>")); + assert!(html.contains("<\\/svg>")); + } + + #[test] + fn viewer_html_includes_history_and_brush_zoom_controls() { + let html = render_flamegraph_viewer_html(sample_doc()); + assert!(html.contains("id=\"viewer-back\"")); + assert!(html.contains("id=\"viewer-forward\"")); + assert!(html.contains("id=\"viewer-reset\"")); + assert!(html.contains("id=\"viewer-search\"")); + assert!(html.contains("id=\"viewer-select-range\"")); + assert!(html.contains("id=\"selection-overlay\"")); + assert!(html.contains("data-history-scope=\"focused\"")); + assert!(!html.contains("target=\"_blank\"")); + } + + #[test] + fn viewer_html_omits_experimental_tower_controls() { + let html = render_flamegraph_viewer_html(sample_doc()); + assert!(!html.contains("id=\"viewer-hide-towers\"")); + assert!(!html.contains("Hide Thin Towers")); + assert!(!html.contains("id=\"tower-overlay\"")); + assert!(!html.contains("id=\"tower-meta\"")); + assert!(!html.contains("applyTowerCollapse")); + } + + #[test] + fn viewer_html_renders_hot_frame_summary_for_each_mode() { + let html = render_flamegraph_viewer_html(sample_doc()); + assert!(html.contains("Self Time")); + assert!(html.contains("Inclusive Time")); + assert!(html.contains("sample_fns::fibonacci")); + assert!(html.contains("sample_fns::run_benchmark")); + assert!(html.contains("focused warning")); + } + + #[test] + fn summarize_folded_stacks_caps_inclusive_percent_for_repeated_frames() { + let summary = summarize_folded_stacks( + "root;repeat;repeat 4\nroot;repeat;leaf 1\n", + 2, + 0, + None, + ); + + let repeat = summary + .inclusive_frames + .iter() + .find(|frame| frame.frame == "repeat") + .expect("repeat frame"); + let leaf = summary + .self_frames + .iter() + .find(|frame| frame.frame == "leaf") + .expect("leaf frame"); + + assert_eq!(repeat.samples, 5); + assert_eq!(repeat.percent_total, 100); + assert_eq!(leaf.samples, 1); + assert_eq!(leaf.percent_total, 20); + } + + #[test] + fn summarize_folded_stacks_prettifies_rust_symbol_noise() { + let summary = summarize_folded_stacks( + "root;sample_fns::fibonacci::ha1ebbae54edac99d 3\nroot;_$LT$u32$u20$as$u20$core..iter..range..Step$GT$::forward_unchecked::h2f57f430431a1dbe 2\n", + 2, + 0, + None, + ); + + assert!(summary + .self_frames + .iter() + .any(|frame| frame.frame == "sample_fns::fibonacci")); + assert!(summary + .self_frames + .iter() + .any(|frame| frame.frame == "::forward_unchecked")); + } + + #[test] + fn viewer_html_escapes_embedded_svg_script_terminators() { + let mut doc = sample_doc(); + doc.focused_svg_document = "".into(); + doc.full_svg_document = "".into(); + + let html = render_flamegraph_viewer_html(doc); + + assert!(html.contains("<\\/script>")); + assert!(!html.contains("alert('focused'),\n full:")); + } + + #[test] + fn standalone_svg_defaults_to_viewport_width_and_custom_helpers() { + let svg = + render_standalone_flamegraph_svg("root;sample_fns::fibonacci 1", "Test Flamegraph") + .expect("render svg"); + assert!(svg.contains("var fluiddrawing = false;")); + assert!(svg.contains("width:100vw")); + assert!(svg.contains("mobenchZoomVisibleFraction")); + } + + #[test] + fn standalone_svg_includes_tower_expand_hooks() { + let svg = + render_standalone_flamegraph_svg("root;sample_fns::fibonacci 1", "Test Flamegraph") + .expect("render svg"); + assert!(svg.contains("mobenchGetVisibleFrames")); + assert!(svg.contains("mobenchSetCollapsedTowerPresentation")); + assert!(svg.contains("mobenchClearCollapsedTowerPresentation")); + assert!(svg.contains("mobenchZoomAbsoluteRange")); + } +} diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index b1566a6..be9e4e8 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -138,6 +138,7 @@ use browserstack::{BrowserStackAuth, BrowserStackClient}; mod browserstack; pub mod config; +mod flamegraph_viewer; mod github; mod plots; mod profile; diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 146b2e3..436d19d 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -3,12 +3,17 @@ use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fmt::Write; -use std::io::Cursor; use std::path::{Path, PathBuf}; use std::process::Command; use crate::{ DevicePlatform, MobileTarget, ProjectLayoutOptions, ResolvedMatrixDevice, RunSpec, + flamegraph_viewer::{ + ArtifactLink as ViewerArtifactLink, FlamegraphMode, FlamegraphViewerDoc, + count_folded_stack_lines, derive_benchmark_focused_folded_stacks, + render_flamegraph_viewer_html, render_standalone_flamegraph_svg, + summarize_folded_stacks, + }, load_dotenv_for_layout, persist_mobile_spec, resolve_devices_for_profile, resolve_project_layout, run_android_build, run_ios_build, validate_benchmark_function, }; @@ -751,7 +756,7 @@ where { std::fs::create_dir_all(processed_root)?; - let (symbolized_stacks, record, report) = symbolize_android_folded_stacks_with_native_libraries( + let (symbolized_stacks, mut record, report) = symbolize_android_folded_stacks_with_native_libraries( folded_stacks, native_libraries, runtime_abi, @@ -760,38 +765,93 @@ where std::fs::write(processed_root.join("stacks.folded"), &symbolized_stacks)?; std::fs::write(processed_root.join("native-report.txt"), &report)?; - write_android_flamegraph_html(&symbolized_stacks, &processed_root.join("flamegraph.html"))?; + if let Some(warning) = write_dual_view_flamegraph_bundle( + &symbolized_stacks, + processed_root, + "Android Native Profile", + ANDROID_BENCHMARK_ANCHORS, + "../raw/sample.perf", + "Raw sample.perf", + )? { + record.notes.push(warning); + } Ok(record) } -fn write_android_flamegraph_html(folded_stacks: &str, output_path: &Path) -> Result<()> { - write_flamegraph_html(folded_stacks, output_path, "Android Native Profile") -} +fn write_dual_view_flamegraph_bundle( + full_folded_stacks: &str, + processed_root: &Path, + title: &str, + anchors: &[&str], + raw_artifact_path: &str, + raw_artifact_label: &str, +) -> Result> { + std::fs::create_dir_all(processed_root)?; + std::fs::write(processed_root.join("stacks.folded"), full_folded_stacks)?; -fn write_ios_flamegraph_html(folded_stacks: &str, output_path: &Path) -> Result<()> { - write_flamegraph_html(folded_stacks, output_path, "iOS Native Profile") -} + let focused = derive_benchmark_focused_folded_stacks(full_folded_stacks, anchors); + std::fs::write( + processed_root.join("benchmark.focused.folded"), + &focused.folded, + )?; -fn write_flamegraph_html(folded_stacks: &str, output_path: &Path, title: &str) -> Result<()> { - if folded_stacks.trim().is_empty() { - std::fs::write( - output_path, - "

No native frames were symbolized.

", - )?; - return Ok(()); - } + let full_svg = render_standalone_flamegraph_svg(full_folded_stacks, title)?; + std::fs::write(processed_root.join("flamegraph.full.svg"), &full_svg)?; - let mut options = inferno::flamegraph::Options::default(); - options.title = title.into(); - let mut rendered = Vec::new(); - inferno::flamegraph::from_reader( - &mut options, - Cursor::new(folded_stacks.as_bytes()), - &mut rendered, - )?; - std::fs::write(output_path, rendered)?; - Ok(()) + let full_summary = summarize_folded_stacks( + full_folded_stacks, + count_folded_stack_lines(full_folded_stacks), + 0, + None, + ); + + let focused_warning = if focused.folded.trim().is_empty() { + Some( + "No benchmark anchor frames were detected; the benchmark-only view is falling back to the full-process flamegraph." + .to_string(), + ) + } else { + None + }; + + let focused_svg = if focused.folded.trim().is_empty() { + full_svg.clone() + } else { + render_standalone_flamegraph_svg(&focused.folded, title)? + }; + std::fs::write(processed_root.join("flamegraph.focused.svg"), &focused_svg)?; + + let focused_summary = summarize_folded_stacks( + if focused.folded.trim().is_empty() { + full_folded_stacks + } else { + &focused.folded + }, + focused.matched_stack_count, + focused.excluded_stack_count, + focused_warning.clone(), + ); + + let viewer_html = render_flamegraph_viewer_html(FlamegraphViewerDoc { + title: title.to_string(), + full_svg_document: full_svg, + focused_svg_document: focused_svg, + full_summary, + focused_summary, + default_mode: FlamegraphMode::Focused, + artifact_links: vec![ + ViewerArtifactLink::new(raw_artifact_label, raw_artifact_path), + ViewerArtifactLink::new("Native report", "native-report.txt"), + ViewerArtifactLink::new("Full folded stacks", "stacks.folded"), + ViewerArtifactLink::new("Benchmark-focused folded stacks", "benchmark.focused.folded"), + ViewerArtifactLink::new("Full-process SVG", "flamegraph.full.svg"), + ViewerArtifactLink::new("Benchmark-only SVG", "flamegraph.focused.svg"), + ], + }); + std::fs::write(processed_root.join("flamegraph.html"), viewer_html)?; + + Ok(focused_warning) } const DEFAULT_PROFILE_ITERATIONS: u32 = 20; @@ -800,8 +860,23 @@ const DEFAULT_ANDROID_CAPTURE_DURATION_SECS: u64 = 10; const DEFAULT_ANDROID_WARMUP_TIMEOUT_SECS: u64 = 60; const DEFAULT_IOS_CAPTURE_DURATION_SECS: u64 = 10; const DEFAULT_IOS_BENCH_DELAY_MS: u64 = 1_500; +const DEFAULT_IOS_PROFILE_REPEAT_UNTIL_MS: u64 = DEFAULT_IOS_CAPTURE_DURATION_SECS * 1_000; const DEFAULT_IOS_LOG_TIMEOUT_SECS: u64 = 60; const ANDROID_BENCH_LOG_MARKER: &str = "BENCH_JSON"; +const ANDROID_BENCHMARK_ANCHORS: &[&str] = &[ + "sample_fns::run_benchmark", + "mobench_sdk::timing::run_closure", + "uniffi.", + "uniffi_", + "runBenchmark", +]; +const IOS_BENCHMARK_ANCHORS: &[&str] = &[ + "runBenchmark(spec:)", + "sample_fns::run_benchmark", + "mobench_sdk::timing::run_closure", + "uniffi_", + "BenchRunnerFFI.run(params:)", +]; #[derive(Debug, Clone, PartialEq, Eq)] struct LocalIosSimulator { @@ -1374,20 +1449,37 @@ fn execute_local_ios_capture(args: &ProfileRunArgs, manifest: &mut ProfileManife .context("ios sample artifact missing parent directory")?; let stdout_path = log_dir.join("app.stdout.log"); let stderr_path = log_dir.join("app.stderr.log"); - let result_hold_ms = (DEFAULT_IOS_CAPTURE_DURATION_SECS + 2) * 1_000; - let env_pairs = [ + let app_args = [ + format!("--mobench-profile-bench-delay-ms={DEFAULT_IOS_BENCH_DELAY_MS}"), + format!( + "--mobench-profile-repeat-until-ms={DEFAULT_IOS_PROFILE_REPEAT_UNTIL_MS}" + ), + format!( + "--mobench-profile-result-hold-ms={}", + DEFAULT_IOS_CAPTURE_DURATION_SECS * 1_000 + ), + ]; + let app_env = [ ( "MOBENCH_BENCH_DELAY_MS", DEFAULT_IOS_BENCH_DELAY_MS.to_string(), ), - ("MOBENCH_PROFILE_RESULT_HOLD_MS", result_hold_ms.to_string()), + ( + "MOBENCH_PROFILE_REPEAT_UNTIL_MS", + DEFAULT_IOS_PROFILE_REPEAT_UNTIL_MS.to_string(), + ), + ( + "MOBENCH_PROFILE_RESULT_HOLD_MS", + (DEFAULT_IOS_CAPTURE_DURATION_SECS * 1_000).to_string(), + ), ]; let pid = launch_local_ios_app( &simulator, &bundle_id, &stdout_path, &stderr_path, - &env_pairs, + &app_args, + &app_env, )?; let sample_result = run_ios_sample_capture(pid, &raw_sample_path); @@ -1429,6 +1521,10 @@ fn execute_local_ios_capture(args: &ProfileRunArgs, manifest: &mut ProfileManife "performed one preparatory warm launch before recording so the measured sample de-emphasizes first-run bridge and UI setup costs".into(), ); } + manifest.capture_metadata.warnings.push(format!( + "iOS profile capture repeated benchmark work for about {} ms so fast functions remain visible in sampled stacks", + DEFAULT_IOS_PROFILE_REPEAT_UNTIL_MS + )); match read_combined_text_files(&[stdout_path, stderr_path]) { Ok(logs) => { @@ -1460,8 +1556,16 @@ fn run_local_ios_warmup_pass( .context("ios sample artifact missing parent directory")?; let stdout_path = log_dir.join("warmup.stdout.log"); let stderr_path = log_dir.join("warmup.stderr.log"); - let env_pairs = [("MOBENCH_PROFILE_WARMUP_ONLY", "1".to_string())]; - let _pid = launch_local_ios_app(simulator, bundle_id, &stdout_path, &stderr_path, &env_pairs)?; + let app_args = [String::from("--mobench-profile-warmup-only=1")]; + let app_env = [("MOBENCH_PROFILE_WARMUP_ONLY", String::from("1"))]; + let _pid = launch_local_ios_app( + simulator, + bundle_id, + &stdout_path, + &stderr_path, + &app_args, + &app_env, + )?; let wait_result = wait_for_ios_log_marker( &[stdout_path, stderr_path], @@ -1709,7 +1813,8 @@ fn launch_local_ios_app( bundle_id: &str, stdout_path: &Path, stderr_path: &Path, - env_pairs: &[(&str, String)], + app_args: &[String], + app_env: &[(&str, String)], ) -> Result { let stdout_path = absolutize_profile_path(stdout_path)?; let stderr_path = absolutize_profile_path(stderr_path)?; @@ -1730,9 +1835,12 @@ fn launch_local_ios_app( .arg("--terminate-running-process") .arg(&simulator.udid) .arg(bundle_id); - for (key, value) in env_pairs { + for (key, value) in app_env { cmd.env(format!("SIMCTL_CHILD_{key}"), value); } + for app_arg in app_args { + cmd.arg(app_arg); + } let output = cmd.output().with_context(|| { format!( @@ -1860,7 +1968,16 @@ fn write_ios_processed_outputs( match collapse_ios_sample_call_graph(sample_output) { Ok((folded_stacks, mut record)) => { std::fs::write(processed_root.join("stacks.folded"), &folded_stacks)?; - write_ios_flamegraph_html(&folded_stacks, &processed_root.join("flamegraph.html"))?; + if let Some(warning) = write_dual_view_flamegraph_bundle( + &folded_stacks, + processed_root, + "iOS Native Profile", + IOS_BENCHMARK_ANCHORS, + "../raw/sample.txt", + "Raw sample.txt", + )? { + record.notes.push(warning); + } if record.tool.is_none() { record.tool = Some("sample".into()); } @@ -1868,7 +1985,14 @@ fn write_ios_processed_outputs( } Err(error) => { std::fs::write(processed_root.join("stacks.folded"), "")?; - write_ios_flamegraph_html("", &processed_root.join("flamegraph.html"))?; + let _ = write_dual_view_flamegraph_bundle( + "", + processed_root, + "iOS Native Profile", + IOS_BENCHMARK_ANCHORS, + "../raw/sample.txt", + "Raw sample.txt", + )?; Ok(SymbolizationRecord { status: CaptureStatus::Failed, tool: Some("sample".into()), @@ -2008,13 +2132,14 @@ struct ParsedIosSampleLine { } fn parse_ios_sample_call_graph_line(line: &str) -> Option { - let indent = line.chars().take_while(|ch| *ch == ' ').count(); - let mut remainder = &line[indent..]; - let is_plus = remainder.starts_with("+ "); - if is_plus { - remainder = &remainder[2..]; - } - let remainder = remainder.trim_start(); + // `sample` encodes stack depth by the column where the sample count appears. + // The tree prefix can include `+`, `|`, `!`, and `:` markers, so leading + // spaces alone are not enough to reconstruct the stack shape. + let digits_start = line.find(|ch: char| ch.is_ascii_digit())?; + let indent = digits_start; + let prefix = &line[..digits_start]; + let is_plus = prefix.trim_end().ends_with('+'); + let remainder = &line[digits_start..]; let digits_end = remainder.find(|ch: char| !ch.is_ascii_digit())?; let count = remainder[..digits_end].parse().ok()?; let frame_part = remainder[digits_end..].trim_start(); @@ -2333,7 +2458,19 @@ fn build_capture_plan( path: processed_root.join("native-report.txt"), }, ArtifactRecord { - label: "flamegraph".into(), + label: "benchmark-focused-stacks".into(), + path: processed_root.join("benchmark.focused.folded"), + }, + ArtifactRecord { + label: "flamegraph-full-svg".into(), + path: processed_root.join("flamegraph.full.svg"), + }, + ArtifactRecord { + label: "flamegraph-focused-svg".into(), + path: processed_root.join("flamegraph.focused.svg"), + }, + ArtifactRecord { + label: "flamegraph-viewer".into(), path: processed_root.join("flamegraph.html"), }, ], @@ -2353,7 +2490,19 @@ fn build_capture_plan( path: processed_root.join("native-report.txt"), }, ArtifactRecord { - label: "flamegraph".into(), + label: "benchmark-focused-stacks".into(), + path: processed_root.join("benchmark.focused.folded"), + }, + ArtifactRecord { + label: "flamegraph-full-svg".into(), + path: processed_root.join("flamegraph.full.svg"), + }, + ArtifactRecord { + label: "flamegraph-focused-svg".into(), + path: processed_root.join("flamegraph.focused.svg"), + }, + ArtifactRecord { + label: "flamegraph-viewer".into(), path: processed_root.join("flamegraph.html"), }, ], @@ -2697,7 +2846,9 @@ fn select_viewer_hint( match backend { ProfileBackend::AndroidNative => { if format != ProfileFormat::Native && !processed_artifacts.is_empty() { - Some("Open artifacts/processed/flamegraph.html in a browser".into()) + Some( + "Open artifacts/processed/flamegraph.html for the interactive dual-view flamegraph explorer".into(), + ) } else if !raw_artifacts.is_empty() { Some( "Inspect artifacts/raw/sample.perf with the Android profiling toolchain".into(), @@ -2708,11 +2859,15 @@ fn select_viewer_hint( } ProfileBackend::IosInstruments => { if format != ProfileFormat::Native && !processed_artifacts.is_empty() { - Some("Open artifacts/processed/flamegraph.html in a browser".into()) + Some( + "Open artifacts/processed/flamegraph.html for the interactive dual-view flamegraph explorer".into(), + ) } else if !raw_artifacts.is_empty() { Some("Inspect artifacts/raw/sample.txt for the raw iOS sample call graph".into()) } else if !processed_artifacts.is_empty() { - Some("Open artifacts/processed/flamegraph.html in a browser".into()) + Some( + "Open artifacts/processed/flamegraph.html for the interactive dual-view flamegraph explorer".into(), + ) } else { None } @@ -3313,6 +3468,30 @@ mod tests { assert_eq!(record.unresolved_frames, 0); } + #[test] + fn flamegraph_html_defaults_to_viewport_width_for_standalone_svg() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let output_path = temp_dir.path().join("flamegraph.svg"); + + let flamegraph = + render_standalone_flamegraph_svg("root;sample_fns::fibonacci 1", "Test Flamegraph") + .expect("render flamegraph"); + std::fs::write(&output_path, flamegraph).expect("write flamegraph"); + + let flamegraph = std::fs::read_to_string(&output_path).expect("read flamegraph"); + + assert!( + flamegraph.contains("var fluiddrawing = false;"), + "expected standalone flamegraph HTML to disable inferno's fluiddrawing script for file:// rendering, got:\n{flamegraph}" + ); + assert!( + flamegraph.contains("width:100vw") + || flamegraph.contains("min-width:100vw") + || flamegraph.contains("max-width:100vw"), + "expected standalone flamegraph HTML to size the SVG to the viewport width, got:\n{flamegraph}" + ); + } + #[test] fn android_ndk_addr2line_discovery_prefers_ndk_toolchain_bin() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -3467,6 +3646,24 @@ mod tests { .iter() .any(|p| p.path.ends_with("flamegraph.html")) ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("benchmark.focused.folded")) + ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("flamegraph.full.svg")) + ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("flamegraph.focused.svg")) + ); assert!( plan.native_capture .processed_artifacts @@ -3553,6 +3750,24 @@ mod tests { .iter() .any(|p| p.path.ends_with("flamegraph.html")) ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("benchmark.focused.folded")) + ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("flamegraph.full.svg")) + ); + assert!( + plan.native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("flamegraph.focused.svg")) + ); } #[test] @@ -3577,6 +3792,40 @@ mod tests { assert!(folded.contains("start;write 2")); } + #[test] + fn ios_sample_call_graph_preserves_rust_branch_with_sample_tree_markers() { + let sample = r#"Call graph: + 7863 Thread_27276912: Main Thread DispatchQueue_ + + ! : 8 runBenchmark(spec:) (in BenchRunner) + 212 [0x104c8fd04] sample_fns.swift:883 + + ! : 8 rustCallWithError(_:_:) (in BenchRunner) + 136 [0x104c88e18] sample_fns.swift:277 + + ! : 8 makeRustCall(_:errorHandler:) (in BenchRunner) + 272 [0x104c889d4] sample_fns.swift:286 + + ! : 8 closure #1 in runBenchmark(spec:) (in BenchRunner) + 196 [0x104c8ff74] sample_fns.swift:884 + + ! : 8 uniffi_sample_fns_fn_func_run_benchmark (in BenchRunner) + 128 [0x104ca0048] + + ! : 8 uniffi_core::ffi::rustcalls::rust_call::hd7f37ba68899eb94 (in BenchRunner) + 60 [0x104c9e050] + + ! : 8 uniffi_core::ffi::rustcalls::rust_call_with_out_status::hb407fdd2dbf3b59b (in BenchRunner) + 60 [0x104c9dbc8] + + ! : 8 std::panic::catch_unwind::h37b9566b8b963094 (in BenchRunner) + 96 [0x104c9aba4] + + ! : 8 __rust_try (in BenchRunner) + 32 [0x104c9ac48] + + ! : 8 std::panicking::catch_unwind::do_call::h426d206e0216d0d8 (in BenchRunner) + 64 [0x104ca1400] + + ! : 8 sample_fns::uniffi_sample_fns_fn_func_run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::h239802906291ec5b (in BenchRunner) + 180 [0x104c96a1c] + + ! : 8 sample_fns::run_benchmark::h9909bea304da6ad4 (in BenchRunner) + 244 [0x104c9ecf8] + + ! : | 6 sample_fns::run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::h93f4e9319d117771 (in BenchRunner) + 40 [0x104c96648] + + ! : | 6 mobench_sdk::timing::profile_phase::hea85f2c7c3e95291 (in BenchRunner) + 116 [0x104ca0d80] + + ! : | 6 sample_fns::run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::_$u7b$$u7b$closure$u7d$$u7d$::h4716261690d4fa31 (in BenchRunner) + 24 [0x104c96800] + + ! : | 6 sample_fns::fibonacci_batch::hc8a1ee7297b9bb66 (in BenchRunner) + 80 [0x104c9f074] + + ! : | 5 sample_fns::fibonacci::ha1ebbae54edac99d (in BenchRunner) + 152 [0x104c9f168] +"#; + + let folded = + collapse_ios_sample_call_graph_to_folded_stacks(sample).expect("collapse sample"); + + assert!( + folded.contains( + "runBenchmark(spec:);rustCallWithError(_:_:);makeRustCall(_:errorHandler:);closure #1 in runBenchmark(spec:);uniffi_sample_fns_fn_func_run_benchmark;uniffi_core::ffi::rustcalls::rust_call::hd7f37ba68899eb94;uniffi_core::ffi::rustcalls::rust_call_with_out_status::hb407fdd2dbf3b59b;std::panic::catch_unwind::h37b9566b8b963094;__rust_try;std::panicking::catch_unwind::do_call::h426d206e0216d0d8;sample_fns::uniffi_sample_fns_fn_func_run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::h239802906291ec5b;sample_fns::run_benchmark::h9909bea304da6ad4;sample_fns::run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::h93f4e9319d117771;mobench_sdk::timing::profile_phase::hea85f2c7c3e95291;sample_fns::run_benchmark::_$u7b$$u7b$closure$u7d$$u7d$::_$u7b$$u7b$closure$u7d$$u7d$::h4716261690d4fa31;sample_fns::fibonacci_batch::hc8a1ee7297b9bb66;sample_fns::fibonacci::ha1ebbae54edac99d 5" + ), + "expected folded stacks to preserve the deep Rust branch emitted by `sample`, got:\n{folded}" + ); + } + #[test] fn browserstack_profile_run_reports_unsupported_native_capture() { let args = sample_run_args( @@ -3826,10 +4075,32 @@ mod tests { label: "simpleperf".into(), path: PathBuf::from("artifacts/raw/sample.perf"), }], - processed_artifacts: vec![ArtifactRecord { - label: "flamegraph".into(), - path: PathBuf::from("artifacts/processed/flamegraph.html"), - }], + processed_artifacts: vec![ + ArtifactRecord { + label: "collapsed-stacks".into(), + path: PathBuf::from("artifacts/processed/stacks.folded"), + }, + ArtifactRecord { + label: "benchmark-focused-stacks".into(), + path: PathBuf::from("artifacts/processed/benchmark.focused.folded"), + }, + ArtifactRecord { + label: "native-report".into(), + path: PathBuf::from("artifacts/processed/native-report.txt"), + }, + ArtifactRecord { + label: "flamegraph-full-svg".into(), + path: PathBuf::from("artifacts/processed/flamegraph.full.svg"), + }, + ArtifactRecord { + label: "flamegraph-focused-svg".into(), + path: PathBuf::from("artifacts/processed/flamegraph.focused.svg"), + }, + ArtifactRecord { + label: "flamegraph-viewer".into(), + path: PathBuf::from("artifacts/processed/flamegraph.html"), + }, + ], symbolization: SymbolizationRecord { status: CaptureStatus::Partial, tool: Some("llvm-addr2line".into()), @@ -3837,7 +4108,10 @@ mod tests { unresolved_frames: 1, notes: vec!["missing symbols".into()], }, - viewer_hint: Some("Open flamegraph.html in a browser".into()), + viewer_hint: Some( + "Open artifacts/processed/flamegraph.html for the interactive dual-view flamegraph explorer" + .into(), + ), }, semantic_profile: SemanticProfileRecord { status: SemanticCaptureStatus::Captured, From 95bfed04e2cdadca002fb543b7d71bc512b2c462 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 21:42:47 -0700 Subject: [PATCH 151/196] docs: refresh profiling docs and sync mobile templates --- .github/actions/mobench/README.md | 3 +- .../mobile-bench-profile-selftest.yml | 95 ++++++ .planning/codebase/ARCHITECTURE.md | 225 -------------- .planning/codebase/CONVENTIONS.md | 244 --------------- .planning/codebase/INTEGRATIONS.md | 140 --------- .planning/codebase/STACK.md | 115 ------- .planning/codebase/STRUCTURE.md | 280 ------------------ .planning/codebase/TESTING.md | 278 ----------------- BUILD.md | 18 +- README.md | 16 +- TESTING.md | 18 +- android/README.md | 2 +- crates/mobench-macros/README.md | 6 +- crates/mobench-sdk/README.md | 30 +- crates/mobench-sdk/src/lib.rs | 13 +- .../mobench-sdk/templates/android/README.md | 27 +- .../src/main/java/MainActivity.kt.template | 7 +- .../BenchRunner/BenchRunnerFFI.swift.template | 3 +- .../BenchRunner/ContentView.swift.template | 3 +- .../templates/ios/BenchRunner/README.md | 29 +- crates/mobench/README.md | 24 +- crates/mobench/src/lib.rs | 24 +- crates/mobench/templates/ci/action.README.md | 3 +- docs/CONCERNS_RESOLUTION_2026-02-16.md | 4 +- docs/codebase/ARCHITECTURE.md | 127 ++++++++ docs/codebase/CONVENTIONS.md | 58 ++++ docs/codebase/INTEGRATIONS.md | 69 +++++ docs/codebase/README.md | 11 + docs/codebase/STACK.md | 80 +++++ docs/codebase/STRUCTURE.md | 92 ++++++ docs/codebase/TESTING.md | 86 ++++++ ...-03-27-flamegraph-tower-collapse-design.md | 3 +- ...lamegraph-tower-collapse-implementation.md | 2 + templates/android/.gitignore | 23 ++ templates/android/README.md | 27 +- templates/android/app/build.gradle | 2 +- templates/android/app/proguard-rules.pro | 22 ++ .../android/app/src/main/AndroidManifest.xml | 1 + .../src/main/java/MainActivity.kt.template | 115 +++++-- .../app/src/main/res/values/themes.xml | 2 +- templates/android/build.gradle | 2 +- templates/android/gradle.properties | 7 + templates/ios/BenchRunner/.gitignore | 27 ++ .../BenchRunner/BenchRunnerFFI.swift.template | 11 +- .../BenchRunner/ContentView.swift.template | 86 +++++- .../BenchRunnerUITests.swift.template | 5 +- .../BenchRunnerUITests/Info.plist.template | 22 ++ templates/ios/BenchRunner/README.md | 29 +- .../{project.yml => project.yml.template} | 10 +- 49 files changed, 1116 insertions(+), 1410 deletions(-) create mode 100644 .github/workflows/mobile-bench-profile-selftest.yml delete mode 100644 .planning/codebase/ARCHITECTURE.md delete mode 100644 .planning/codebase/CONVENTIONS.md delete mode 100644 .planning/codebase/INTEGRATIONS.md delete mode 100644 .planning/codebase/STACK.md delete mode 100644 .planning/codebase/STRUCTURE.md delete mode 100644 .planning/codebase/TESTING.md create mode 100644 docs/codebase/ARCHITECTURE.md create mode 100644 docs/codebase/CONVENTIONS.md create mode 100644 docs/codebase/INTEGRATIONS.md create mode 100644 docs/codebase/README.md create mode 100644 docs/codebase/STACK.md create mode 100644 docs/codebase/STRUCTURE.md create mode 100644 docs/codebase/TESTING.md create mode 100644 templates/android/.gitignore create mode 100644 templates/android/app/proguard-rules.pro create mode 100644 templates/android/gradle.properties create mode 100644 templates/ios/BenchRunner/.gitignore create mode 100644 templates/ios/BenchRunner/BenchRunnerUITests/Info.plist.template rename templates/ios/BenchRunner/{project.yml => project.yml.template} (77%) diff --git a/.github/actions/mobench/README.md b/.github/actions/mobench/README.md index df41f08..07b4625 100644 --- a/.github/actions/mobench/README.md +++ b/.github/actions/mobench/README.md @@ -1,6 +1,6 @@ # mobench GitHub Action -Run `mobench ci run` in GitHub Actions with caching, Android SDK setup, and artifact upload. +Run benchmark-oriented `mobench` commands in GitHub Actions with caching, Android SDK setup, and artifact upload. ## Usage @@ -50,6 +50,7 @@ Run `mobench ci run` in GitHub Actions with caching, Android SDK setup, and arti - Inputs are passed through environment variables in shell steps to reduce script-injection risk from workflow inputs. - `command` is allow-listed in the action implementation; unsupported command values fail the job early. +- This action is for benchmark CI flows. Local native profiling has its own self-test workflow because it does not use the BrowserStack benchmark path. ## Cache keys diff --git a/.github/workflows/mobile-bench-profile-selftest.yml b/.github/workflows/mobile-bench-profile-selftest.yml new file mode 100644 index 0000000..3df92ea --- /dev/null +++ b/.github/workflows/mobile-bench-profile-selftest.yml @@ -0,0 +1,95 @@ +name: Mobile Bench Profile Self-Test + +on: + workflow_dispatch: + inputs: + crate_path: + description: "Benchmark crate to profile" + type: string + default: "crates/sample-fns" + function: + description: "Fully-qualified benchmark function" + type: string + default: "sample_fns::fibonacci" + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + ios-profile: + name: iOS local flamegraph self-test + runs-on: macos-15 + + steps: + - uses: actions/checkout@v6.0.2 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios + + - name: Ensure iOS Rust targets + shell: bash + run: | + rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios + + - name: Install uniffi-bindgen + shell: bash + run: | + set -euo pipefail + uniffi_version=$(awk ' + $0 == "[[package]]" { in_pkg=0 } + $0 == "name = \"uniffi_bindgen\"" { in_pkg=1; next } + in_pkg && /^version = / { + gsub(/^version = "|\"$/, "", $0) + print $0 + exit + } + ' Cargo.lock) + if [ -n "${uniffi_version}" ]; then + cargo install \ + --git https://github.com/mozilla/uniffi-rs \ + --tag "v${uniffi_version}" \ + uniffi-bindgen-cli \ + --bin uniffi-bindgen + else + echo "No uniffi_bindgen entry in Cargo.lock; skipping global uniffi-bindgen install" + fi + + - name: Install XcodeGen + run: brew install xcodegen + + - name: List available simulators + run: xcrun simctl list devices available + + - name: Run iOS local profile capture + shell: bash + run: | + set -euo pipefail + cargo run -p mobench --bin mobench -- profile run \ + --target ios \ + --provider local \ + --backend ios-instruments \ + --crate-path "${{ inputs.crate_path }}" \ + --function "${{ inputs.function }}" \ + --output-dir target/mobench/profile-selftest + + - name: Verify generated flamegraph artifacts + shell: bash + run: | + set -euo pipefail + test -f target/mobench/profile-selftest/profile.json + test -f target/mobench/profile-selftest/summary.md + find target/mobench/profile-selftest -name flamegraph.html -print -quit | grep -q . + find target/mobench/profile-selftest -name native-report.txt -print -quit | grep -q . + find target/mobench/profile-selftest -name sample.txt -print -quit | grep -q . + + - name: Upload profiling artifacts + if: always() + uses: actions/upload-artifact@v7.0.0 + with: + name: mobench-profile-selftest-ios + path: target/mobench/profile-selftest + if-no-files-found: error diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md deleted file mode 100644 index 6188495..0000000 --- a/.planning/codebase/ARCHITECTURE.md +++ /dev/null @@ -1,225 +0,0 @@ -# Architecture - -**Analysis Date:** 2026-01-21 - -## Pattern Overview - -**Overall:** Layered command-line SDK orchestrator with compile-time registration and FFI abstraction. - -The mobench ecosystem follows a **three-crate architecture** with clear separation of concerns: -1. **CLI orchestration layer** (`mobench`) - Entry point for all operations -2. **SDK core** (`mobench-sdk`) - Core timing, building, and registry infrastructure -3. **Compile-time macro registration** (`mobench-macros`) - `#[benchmark]` attribute implementation - -**Key Characteristics:** -- **Macro-based registration** - Functions marked with `#[benchmark]` auto-register via `inventory` crate at compile time -- **Feature-gated modularity** - `runner-only` feature for minimal mobile binaries vs full SDK with build automation -- **Embedded templates** - Android/iOS app templates compiled into the SDK using `include_dir!` macro -- **UniFFI FFI boundary** - Type-safe bindings generated automatically from Rust proc macros (not UDL) -- **Cross-platform building** - Automated native library builds for Android (NDK) and iOS (Xcode) - -## Layers - -**CLI Orchestrator (mobench):** -- Purpose: Entry point driving the full mobile benchmarking workflow -- Location: `crates/mobench/src/` -- Contains: Command handlers (init, build, run, list, fetch, package-ipa, package-xcuitest, compare) -- Depends on: `mobench-sdk` (builders, codegen, registry), BrowserStack REST API client -- Used by: End users via `cargo mobench` or `mobench` binary - -**SDK Core (mobench-sdk):** -- Purpose: Timing harness, function registry, build automation, template generation -- Location: `crates/mobench-sdk/src/` -- Contains: Timing module, registry, runner, builders (Android/iOS), codegen, types -- Depends on: Standard library, serde, uniffi, include_dir, inventory -- Used by: CLI (`mobench`), user benchmarking projects, mobile apps - -**Macro Registry (mobench-macros):** -- Purpose: Compile-time attribute macro for benchmark function registration -- Location: `crates/mobench-macros/src/` -- Contains: `#[benchmark]` attribute implementation -- Depends on: syn, quote, proc-macro2 -- Used by: User projects and example code - -**Timing Harness (mobench-sdk::timing):** -- Purpose: Minimal, portable benchmarking infrastructure for mobile targets -- Location: `crates/mobench-sdk/src/timing.rs` -- Contains: `run_closure()`, `BenchSpec`, `BenchSample`, `BenchReport`, nanosecond-precision timing -- Depends on: Standard library only (no platform-specific dependencies) -- Used by: All benchmark execution, available even with `runner-only` feature - -**Build Automation (mobench-sdk::builders):** -- Purpose: Cross-compile Rust to mobile targets and package into native apps -- Location: `crates/mobench-sdk/src/builders/` -- Contains: `AndroidBuilder`, `IosBuilder`, common utilities -- Depends on: Rust toolchain, Android NDK, Xcode, `cargo-ndk`, `uniffi-bindgen` -- Used by: CLI `build` and `run` commands - -**Template System (mobench-sdk::codegen):** -- Purpose: Generate mobile app projects from embedded templates -- Location: `crates/mobench-sdk/src/codegen.rs`, `crates/mobench-sdk/templates/` -- Contains: Template files (Android Gradle, iOS Xcode), parameterization -- Depends on: `include_dir!` macro for compile-time embedding -- Used by: `init` command to scaffold new projects - -**Registry (mobench-sdk::registry):** -- Purpose: Runtime discovery of benchmark functions -- Location: `crates/mobench-sdk/src/registry.rs` -- Contains: `discover_benchmarks()`, `find_benchmark()`, `list_benchmark_names()` -- Depends on: `inventory` crate for global collection -- Used by: Runner, CLI list command - -**Runner (mobench-sdk::runner):** -- Purpose: Benchmark execution engine linking registry to timing -- Location: `crates/mobench-sdk/src/runner.rs` -- Contains: `run_benchmark()`, `BenchmarkBuilder` -- Depends on: Registry, timing module -- Used by: CLI run command, mobile apps - -## Data Flow - -**User Project Setup:** -1. User adds `mobench-sdk` to `Cargo.toml` with optional build feature -2. User marks functions with `#[benchmark]` attribute -3. At compile time, macro registers functions via `inventory` crate -4. At runtime, registry discovers benchmarks when library loads - -**Mobile Build Pipeline:** -1. User runs `cargo mobench build --target android` (CLI) -2. CLI instantiates `AndroidBuilder` with project path -3. Builder validates workspace (auto-detects crate location) -4. Builder compiles Rust to `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` targets via `cargo-ndk` -5. Builder generates UniFFI Kotlin bindings from Rust proc macro types -6. Builder syncs `.so` files into `android/app/src/main/jniLibs/{abi}/` -7. Builder runs `gradle assemble` to build APK and test APK -8. Builder outputs APK to `target/mobench/android/app/build/outputs/apk/` - -**Benchmark Execution (Local):** -1. User runs `cargo mobench run --function my_benchmark --local-only` -2. CLI loads user project, builds benchmark binary -3. Registry discovers all `#[benchmark]` functions via `inventory` -4. Runner finds function by name, invokes it via registered closure -5. Timing harness measures iterations + warmup, records nanosecond samples -6. Report generated with statistics (mean, std dev, quantiles) - -**Benchmark Execution (BrowserStack):** -1. User runs `cargo mobench run --target android --function my_benchmark --devices "Pixel 7-13.0"` -2. CLI builds APK with `release` profile (smaller upload) -3. CLI uploads APK + test APK to BrowserStack App Automate -4. Mobile app reads `bench_spec.json` from assets, calls `run_benchmark()` via FFI -5. App measures times via SDK's timing harness, returns JSON results -6. BrowserStack returns session artifacts to CLI -7. Results parsed and report generated - -**State Management:** -- **Compile-time:** Benchmark function metadata embedded via `inventory` collect -- **Runtime:** Registry maintains in-memory collection of function pointers -- **Build artifacts:** Generated templates written to `target/mobench/`, source commits optional -- **Results:** JSON reports with samples, metadata (device, SDK version, timestamp) - -## Key Abstractions - -**BenchSpec:** -- Purpose: Declarative benchmark configuration (name, iterations, warmup) -- Examples: `crates/mobench-sdk/src/timing.rs`, `crates/sample-fns/src/lib.rs:8-13` -- Pattern: Serializable struct passed through entire pipeline (user → CLI → mobile app → timing harness) - -**BenchFunction:** -- Purpose: Runtime-discoverable benchmark function pointer -- Examples: `crates/mobench-sdk/src/registry.rs:12-21` -- Pattern: Generated by `#[benchmark]` macro, collected via `inventory`, invoked by runner - -**Builder Pattern:** -- Purpose: Fluent configuration for cross-compilation workflows -- Examples: `crates/mobench-sdk/src/builders/android.rs:66-110`, `crates/mobench-sdk/src/runner.rs:68-100` -- Pattern: Stateful builder accumulating configuration, `.build()` or `.run()` executes full pipeline - -**FFI Boundary (UniFFI Proc Macros):** -- Purpose: Type-safe mobile binding generation from Rust annotations -- Examples: `crates/sample-fns/src/lib.rs:8, 16, 22, 29, 43, 93` -- Pattern: `#[derive(uniffi::Record)]` on types, `#[uniffi::export]` on functions, `uniffi::setup_scaffolding!()` generates bindings - -**Error Propagation:** -- Purpose: Layered error handling from timing harness through CLI -- Examples: `crates/mobench-sdk/src/types.rs:50-100`, wraps `TimingError` as `BenchError` -- Pattern: Result types at all boundaries, detailed error context preserved - -## Entry Points - -**CLI Binary:** -- Location: `crates/mobench/src/main.rs` -- Triggers: User invokes `cargo mobench` or `mobench` binary -- Responsibilities: Parse CLI args, delegate to subcommands (init, build, run, list, fetch, etc.) - -**SDK Init Command:** -- Location: `crates/mobench/src/lib.rs` (Command::InitSdk handler) -- Triggers: `cargo mobench init --target android --project-name my-project` -- Responsibilities: Generate Android/iOS projects, create config files, scaffold example benchmarks - -**SDK Build Command:** -- Location: `crates/mobench/src/lib.rs` (Command::Build handler) -- Triggers: `cargo mobench build --target android` -- Responsibilities: Instantiate builder, validate workspace, cross-compile, package APK/xcframework - -**SDK Run Command:** -- Location: `crates/mobench/src/lib.rs` (Command::Run handler) -- Triggers: `cargo mobench run --target android --function my_benchmark` -- Responsibilities: Build artifacts (if needed), upload to BrowserStack (if --devices), collect results - -**Registry Discovery:** -- Location: `crates/mobench-sdk/src/registry.rs:41-43` -- Triggers: Binary loads, user calls `discover_benchmarks()` -- Responsibilities: Iterate `inventory` collection, return all registered functions - -**Timing Harness (Mobile):** -- Location: `crates/mobench-sdk/src/timing.rs:run_closure()` -- Triggers: Mobile app calls `run_benchmark()` via UniFFI FFI -- Responsibilities: Execute closure with warmup, record nanosecond samples, return report - -## Error Handling - -**Strategy:** Layered result types with context preservation. - -**Patterns:** -- `mobench-sdk` defines `BenchError` enum covering all operation categories (Runner, UnknownFunction, Execution, Io, Serialization, Config, Build) -- CLI uses `anyhow::Result` with `.context()` for actionable error messages -- Builders return `Result` with detailed build failure diagnostics -- Timing harness returns `TimingError` (minimal: NoIterations, Execution) -- Mobile apps receive `BenchError` via UniFFI and propagate to caller - -**Handling patterns:** -```rust -// SDK error wrapping -match run_benchmark(spec) { - Ok(report) => { ... }, - Err(BenchError::UnknownFunction(name)) => eprintln!("not found: {}", name), - Err(BenchError::Runner(e)) => eprintln!("timing error: {}", e), - Err(e) => eprintln!("error: {}", e), -} - -// CLI error context -builder.build(&config) - .context("failed to build Android APK")?; -``` - -## Cross-Cutting Concerns - -**Logging:** Controlled via `--verbose` / `-v` flag in CLI. Builders and commands print progress only when enabled. No structured logging framework; simple stderr output. - -**Validation:** -- Project workspace validation (Cargo.toml, crate location) in builders -- `BenchSpec` validation (iterations > 0) in timing harness -- Config file validation (required fields) in config module - -**Authentication:** BrowserStack credentials resolved from: -1. Config file (supports `${ENV_VAR}` expansion) -2. Environment variables (`BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`) -3. `.env.local` file (loaded via `dotenvy`) - -**Feature Flags:** -- `full` (default): Builders, codegen, registry, runner enabled -- `runner-only`: Timing module only, no build automation or registry - ---- - -*Architecture analysis: 2026-01-21* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md deleted file mode 100644 index 2b8575c..0000000 --- a/.planning/codebase/CONVENTIONS.md +++ /dev/null @@ -1,244 +0,0 @@ -# Coding Conventions - -**Analysis Date:** 2026-01-21 - -## Naming Patterns - -**Files:** -- Rust source files: `lowercase_with_underscores.rs` -- Module structure: `mod.rs` for module files, `filename.rs` for single-item modules -- Examples: `crates/mobench-sdk/src/timing.rs`, `crates/mobench-sdk/src/builders/android.rs` - -**Functions:** -- Public functions and private helpers: `snake_case` -- Builder methods: `snake_case`, returning `Self` for chaining -- Test functions: `test_()` (e.g., `test_rejects_zero_iterations()`) -- Examples: `run_closure()`, `discover_benchmarks()`, `find_benchmark()`, `get_cargo_target_dir()` - -**Variables:** -- Local variables and parameters: `snake_case` -- Constants: `SCREAMING_SNAKE_CASE` -- Field names in structs: `snake_case` -- Example: `CHECKSUM_INPUT` for const, `project_root` for variables - -**Types:** -- Struct and enum names: `PascalCase` -- Type parameters: Single uppercase letters or `PascalCase` (e.g., `T`, `F`) -- Examples: `BenchSpec`, `BenchSample`, `BenchError`, `AndroidBuilder`, `IosBuilder`, `BenchFunction` - -## Code Style - -**Formatting:** -- Edition: Rust 2021 (specified in workspace `Cargo.toml` as `edition = "2024"`) -- Indentation: 4 spaces (Rust standard) -- Line length: No enforced limit observed; examples use 80-100 character average -- Trailing commas: Used in multi-line collections and match expressions - -**Linting:** -- No explicit linter config found (no `.clippy.toml` or `rust-clippy.toml`) -- Implicitly follows Rust conventions through code style -- Error types use `#[error("...")]` from `thiserror` crate for custom messages - -## Import Organization - -**Order:** -1. Crate imports (`use crate::...`) -2. Standard library imports (`use std::...`) -3. External crate imports (`use external_crate::...`) -4. Re-exports and module declarations (`pub use ...`, `mod ...`) - -**Pattern Examples:** - -From `crates/mobench-sdk/src/timing.rs`: -```rust -use serde::{Deserialize, Serialize}; -use std::time::{Duration, Instant}; -use thiserror::Error; -``` - -From `crates/mobench-sdk/src/builders/android.rs`: -```rust -use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; -use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root}; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -``` - -**Path Aliases:** -- No path aliases configured (no `[paths]` in `Cargo.toml`) -- Uses standard module hierarchy - -## Error Handling - -**Patterns:** -- `Result` return types for fallible operations -- Custom error enum `BenchError` with `#[derive(Debug, thiserror::Error)]` -- Error variants use `#[error("message")]` for display formatting -- `#[from]` for automatic conversion from underlying error types -- `Ok(value)?` syntax for error propagation -- `anyhow::Result` and `anyhow::Context` in CLI code (`mobench/src/lib.rs`) - -**Error Handling in Core SDK:** -```rust -#[derive(Debug, thiserror::Error)] -pub enum BenchError { - #[error("benchmark runner error: {0}")] - Runner(#[from] crate::timing::TimingError), - - #[error("unknown benchmark function: {0}...")] - UnknownFunction(String), - - #[error("I/O error: {0}. Check file paths and permissions")] - Io(#[from] std::io::Error), -} -``` - -**Validation Pattern:** -- Early returns for validation failures -- Detailed error messages with fix suggestions -- Example from `crates/mobench-sdk/src/builders/common.rs`: -```rust -if !project_root.exists() { - return Err(BenchError::Build(format!( - "Project root does not exist: {}\n\n\ - Ensure you are running from the correct directory or specify --project-root.", - project_root.display() - ))); -} -``` - -## Logging - -**Framework:** `eprintln!()` macro for error output to stderr - -**Patterns:** -- No centralized logging framework used -- Simple stderr output for errors: `eprintln!("{err:#}")` -- Example from `crates/mobench/src/main.rs`: -```rust -fn main() { - if let Err(err) = mobench::run() { - eprintln!("{err:#}"); - std::process::exit(1); - } -} -``` - -**Verbose Output:** -- Controlled via `--verbose` or `-v` CLI flag -- Verbose mode prints full command execution details - -## Comments - -**When to Comment:** -- Complex algorithms or non-obvious logic -- FFI boundary details and platform-specific behavior -- Workarounds for tooling limitations -- Not required for simple, self-documenting code - -**JSDoc/TSDoc:** -- Uses Rust documentation comments (`//!` for modules, `///` for items) -- Triple-slash `///` comments appear before all public items -- Module-level documentation with `//!` at file start -- Examples provided in doc comments for complex APIs - -**Documentation Comment Pattern:** -```rust -/// Marks a function as a benchmark for mobile execution. -/// -/// This attribute macro registers the function in the global benchmark registry, -/// making it discoverable and executable by the mobench runtime. -/// -/// # Usage -/// -/// ```ignore -/// #[benchmark] -/// fn fibonacci_bench() { ... } -/// ``` -/// -/// # Requirements -/// -/// The annotated function must: -/// - Take no parameters -/// - Return `()` (unit type) -``` - -## Function Design - -**Size:** -- Range from 5-60 lines for typical functions -- Simple builders and utilities: 3-20 lines -- Complex builders with multiple validation steps: 100-200 lines -- Core timing function `run_closure()`: 25 lines - -**Parameters:** -- Builder methods accept `impl Into` for flexibility -- Generic closures with trait bounds -- Result returns for fallible operations -- Example: `pub fn new(name: impl Into, iterations: u32, warmup: u32) -> Result` - -**Return Values:** -- `Result` for fallible operations -- `Option` for optional lookups -- `Self` for builder chaining -- Direct values for infallible operations - -## Module Design - -**Exports:** -- Public types and functions: `pub` keyword explicitly -- Conditional exports with `#[cfg(feature = "...")]` -- Re-exports for convenience at crate root level -- Example from `crates/mobench-sdk/src/lib.rs`: -```rust -#[cfg(feature = "full")] -pub use registry::{BenchFunction, discover_benchmarks, find_benchmark, list_benchmark_names}; -pub use timing::{run_closure, TimingError}; -``` - -**Barrel Files:** -- `mod.rs` files organize module exports -- Example: `crates/mobench-sdk/src/builders/mod.rs` exports all builder types -- Re-export pattern: `pub use self::android::AndroidBuilder; pub use self::ios::IosBuilder;` - -**Feature-Gated Modules:** -- Full feature includes: `builders`, `codegen`, `registry`, `runner`, macros -- Runner-only feature: Minimal `timing` module only -- Conditional compilation: `#[cfg(feature = "full")]` - -## Derive Macros - -**Common Patterns:** -- `#[derive(Debug, Clone)]` for shared types -- `#[derive(Serialize, Deserialize)]` for serializable types -- `#[derive(Error)]` from `thiserror` for error enums -- `#[derive(uniffi::Record)]` for FFI-exposed types (in `sample-fns`) - -**Example:** -```rust -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BenchSpec { - pub name: String, - pub iterations: u32, - pub warmup: u32, -} -``` - -## Builder Pattern - -**Implementation:** -- Builder struct with private fields -- `new()` constructor with required parameters -- Chainable builder methods returning `Self` -- Terminal method (e.g., `build()`) that performs action -- Example: `AndroidBuilder::new(...).verbose(true).output_dir(...).build()` - -**Defaults:** -- Sensible defaults in constructor -- AndroidBuilder: `verbose: false`, `output_dir: "target/mobench"`, `dry_run: false` - ---- - -*Convention analysis: 2026-01-21* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md deleted file mode 100644 index 3e50836..0000000 --- a/.planning/codebase/INTEGRATIONS.md +++ /dev/null @@ -1,140 +0,0 @@ -# External Integrations - -**Analysis Date:** 2026-01-21 - -## APIs & External Services - -**BrowserStack App Automate:** -- Service: Cloud-based mobile device testing and automation platform -- What it's used for: Running benchmarks on real Android and iOS devices; uploading APKs, xcframeworks, and test suites; scheduling test runs; collecting results -- SDK/Client: Custom REST client in `crates/mobench/src/browserstack.rs` -- Auth: HTTP Basic Auth with username and access key -- Base URL: `https://api-cloud.browserstack.com` - -**BrowserStack Espresso (Android):** -- Service: Test automation framework for Android apps -- API: `app-automate/espresso/v2/` endpoints -- Operations: - - Upload app APK: `POST /app-automate/espresso/v2/app` (multipart form) - - Upload test suite APK: `POST /app-automate/espresso/v2/test-suite` (multipart form) - - Schedule run: `POST /app-automate/espresso/v2/build` (JSON request body) - - Get build status: `GET /app-automate/espresso/v2/builds/{build_id}` - - Get device logs: `GET /app-automate/espresso/v2/builds/{build_id}/sessions/{session_id}/devicelogs` -- Implementation: `crates/mobench/src/browserstack.rs` - methods: `upload_espresso_app()`, `upload_espresso_test_suite()`, `schedule_espresso_run()`, `get_espresso_build_status()` - -**BrowserStack XCUITest (iOS):** -- Service: Test automation framework for iOS apps -- API: `app-automate/xcuitest/v2/` endpoints -- Operations: - - Upload app IPA: `POST /app-automate/xcuitest/v2/app` (multipart form) - - Upload test suite: `POST /app-automate/xcuitest/v2/test-suite` (multipart form, zip file) - - Schedule run: `POST /app-automate/xcuitest/v2/build` (JSON request body with `only_testing` field) - - Get build status: `GET /app-automate/xcuitest/v2/builds/{build_id}` - - Get device logs: `GET /app-automate/xcuitest/v2/builds/{build_id}/sessions/{session_id}/devicelogs` -- Implementation: `crates/mobench/src/browserstack.rs` - methods: `upload_xcuitest_app()`, `upload_xcuitest_test_suite()`, `schedule_xcuitest_run()`, `get_xcuitest_build_status()` -- Test specification: Hardcoded XCUITest selector `"BenchRunnerUITests/BenchRunnerUITests/testLaunchAndCaptureBenchmarkReport"` passed in `only_testing` field - -## Data Storage - -**Databases:** -- Not used. All data is ephemeral (benchmark specs and results are files on disk or in memory). - -**File Storage:** -- Local filesystem only (no cloud storage integration) -- Artifact locations: - - Build output: `target/mobench/android/` and `target/mobench/ios/` (customizable with `--output-dir`) - - Benchmark specs: `target/mobile-spec/{android,ios}/bench_spec.json` (written at build time, read by mobile apps) - - Results: `run-summary.json`, `run-summary.csv`, `run-summary.md` (written after benchmark execution) - - BrowserStack artifacts downloaded to: `target/mobench/` (device logs, benchmark results) - -**Caching:** -- None. No persistent caching layer. - -## Authentication & Identity - -**Auth Provider:** -- Custom - No OAuth/OIDC provider. Uses static credentials (username + access key). - -**Implementation:** -- Environment variables: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `BROWSERSTACK_PROJECT` (optional) -- Config file: `bench-config.toml` or `mobench.toml` with `${ENV_VAR}` expansion -- `.env.local` file: Loaded automatically via `dotenvy` crate -- HTTP Basic Auth: Credentials passed to all BrowserStack API requests as base64-encoded Authorization header -- Credential resolution order (in `crates/mobench/src/lib.rs` around line 1444-1478): - 1. Attempt to read from config file (with env var expansion) - 2. Fall back to environment variables - 3. Fall back to `.env.local` file - 4. Error if credentials not found - -## Monitoring & Observability - -**Error Tracking:** -- None. No external error tracking service integrated. - -**Logs:** -- Console output (stdout/stderr) -- Verbose flag (`--verbose` / `-v` in CLI) enables detailed output showing all executed commands -- Dry-run flag (`--dry-run`) previews what would be done without making changes -- BrowserStack device logs: Downloaded and saved locally after benchmark execution - - Location: `target/mobench/` (retrieved via `get_device_logs()` in `browserstack.rs`) - - Format: Raw device logs from BrowserStack, optionally filtered by session ID - -## CI/CD & Deployment - -**Hosting:** -- BrowserStack App Automate - No self-hosted deployment. All mobile execution happens on BrowserStack managed devices. -- GitHub Artifacts - Results and summaries uploaded to GitHub Actions workflow artifacts - - Artifacts: `host-run-summary`, `android-apk-artifact`, `ios-xcframework-artifact` - -**CI Pipeline:** -- GitHub Actions (`.github/workflows/mobile-bench.yml`) -- Trigger: Manual dispatch (`workflow_dispatch`) with platform selection (android, ios, or both) -- Steps: - 1. Host tests: `cargo test --all` - 2. Host benchmark summary: `cargo run -p mobench -- run --local-only` (5 iterations, 1 warmup) - 3. Android build: Compiles Rust, runs `cargo mobench build --target android`, uploads APK artifacts - 4. iOS build: Compiles Rust, runs `cargo mobench build --target ios`, creates xcframework and IPA artifacts -- Upload artifacts to GitHub Actions for download - -## Environment Configuration - -**Required env vars:** -- `BROWSERSTACK_USERNAME` - BrowserStack App Automate username -- `BROWSERSTACK_ACCESS_KEY` - BrowserStack App Automate access key - -**Optional env vars:** -- `BROWSERSTACK_PROJECT` - Project name for builds (defaults to config or empty) -- `ANDROID_NDK_HOME` - Path to Android NDK (required for Android builds on non-standard setups) - -**Secrets location:** -- GitHub Actions secrets (for CI): Not configured in this repo (would be added to `.github/` secrets) -- Local development: `.env.local` file (NOT committed to git) -- Config files: `bench-config.toml` or `mobench.toml` with `${ENV_VAR}` expansion - -## Webhooks & Callbacks - -**Incoming:** -- None. No webhook endpoints exposed. - -**Outgoing:** -- None. BrowserStack results are polled via REST API (`get_build_status()` methods), not pushed via webhook. - -## Result Collection & Aggregation - -**Data Flow:** -1. Benchmark parameters written to `bench_spec.json` during build -2. Mobile app reads `bench_spec.json` at runtime -3. Mobile app calls `run_benchmark()` via UniFFI bindings -4. Results serialized to JSON and returned to app -5. App uploads results or writes to local storage -6. CLI polls BrowserStack API for build status and device logs -7. Results parsed and formatted to JSON/CSV/Markdown files - -**Device Communication:** -- Android: Benchmark spec passed via Intent extras or read from `bench_spec.json` asset -- iOS: Benchmark spec read from bundle resource or environment variables -- Both: Results collected through UniFFI FFI boundary in mobile test automation code - ---- - -*Integration audit: 2026-01-21* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md deleted file mode 100644 index 5c0d8b6..0000000 --- a/.planning/codebase/STACK.md +++ /dev/null @@ -1,115 +0,0 @@ -# Technology Stack - -**Analysis Date:** 2026-01-21 - -## Languages - -**Primary:** -- Rust 2024 edition - Core SDK, CLI tool, and proc macros. Published as `mobench`, `mobench-sdk`, `mobench-macros` on crates.io. - -**Secondary:** -- Rust 2021 edition - Example crates (`sample-fns`, `ffi-benchmark`) required for UniFFI-generated binding compatibility (UniFFI v0.28 targets 2021 edition) -- Kotlin - Auto-generated UniFFI FFI bindings for Android apps (via `uniffi-bindgen`) -- Swift - Auto-generated UniFFI FFI bindings for iOS apps (via `uniffi-bindgen`) - -## Runtime - -**Environment:** -- Rust toolchain (stable) - - Android targets: `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` - - iOS targets: `aarch64-apple-ios` (device), `aarch64-apple-ios-sim` (simulator on M1+ Macs) - -**Package Manager:** -- Cargo (Rust package manager) -- Lockfile: `Cargo.lock` (present) -- Workspace resolver: v2 - -## Frameworks - -**Core Benchmarking:** -- `mobench-sdk` - Core SDK library with timing harness (`timing` module), registry system, and build automation -- `mobench-macros` - Proc macro crate providing `#[benchmark]` attribute for function registration -- `mobench` - CLI tool for orchestrating builds, packaging, and execution - -**Code Generation & FFI:** -- UniFFI v0.28 - Foreign function interface (FFI) generation using proc macros (no UDL files) - - Feature: `build` for build script support - - Feature: `cli` for binding generation CLI - - Generates Kotlin and Swift bindings automatically from Rust code -- `inventory` v0.3 - Runtime function discovery via registration macros (used by `#[benchmark]`) -- `include_dir` v0.7 - Embed mobile app templates at compile time (no runtime file I/O) - -**CLI & Configuration:** -- `clap` v4 - Command-line argument parsing with `derive` feature -- `serde` + `serde_json` v1 - Serialization framework -- `serde_yaml` v0.9 - YAML parsing for device matrices -- `toml` v0.8 - TOML parsing for config files -- `dotenvy` v0.15 - Environment variable loading from `.env.local` files - -**Error Handling:** -- `thiserror` v1 - Derive macro for error types -- `anyhow` v1 - Error context and ergonomic error handling - -**HTTP & Networking:** -- `reqwest` v0.12 (blocking client) - HTTP client for BrowserStack API - - Features: `rustls-tls` (TLS via rustls, no OpenSSL), `blocking`, `json`, `multipart` (form-based uploads) - - Used for: app/test suite uploads, build scheduling, result fetching - -**Timing & Dates:** -- `time` v0.3 - Nanosecond-precision timing and RFC3339 timestamp formatting - -**Proc Macro Dependencies:** -- `syn` v2 - Full featured Rust AST parsing (required for `#[benchmark]` macro implementation) -- `quote` v1 - Rust code generation -- `proc-macro2` v1 - Procedural macro utilities - -## Key Dependencies - -**Critical:** -- `uniffi` v0.28 - FFI binding generation. Without it, mobile apps cannot call Rust code. Breaking changes in UniFFI versions require code updates. -- `inventory` v0.3 - Runtime function registry. The `#[benchmark]` macro uses `inventory::collect!()` to auto-register benchmarks at compile time. -- `reqwest` v0.12 (blocking) - BrowserStack API communication. All device upload, scheduling, and result fetching depends on this. - -**Infrastructure:** -- `dotenvy` v0.15 - Supports `.env.local` for credential management (BrowserStack username/access key) -- `include_dir` v0.7 - Android and iOS app templates embedded in the binary. No runtime file I/O needed. -- `time` v0.3 - Precise timing measurement (nanosecond granularity) and RFC3339 formatting for results - -## Configuration - -**Environment:** -- Credentials resolved in order: - 1. Config file (supports `${ENV_VAR}` expansion) - 2. Environment variables: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `BROWSERSTACK_PROJECT` - 3. `.env.local` file (loaded automatically via `dotenvy`) - 4. Android NDK: `ANDROID_NDK_HOME` environment variable - -**Build:** -- `Cargo.toml` - Workspace manifest at `/Users/dcbuilder/Code/world/mobile-bench-rs/Cargo.toml` -- `bench-config.toml` - User-generated project configuration (via `cargo mobench init`) -- `mobench.toml` - Optional CLI configuration in project root -- Mobile app projects created in `target/mobench/` (default, customizable with `--output-dir`) - -**Features:** -- `mobench-sdk` feature flags: - - `full` (default) - Complete SDK with build automation, templates, and registry - - `runner-only` - Minimal timing harness for mobile binaries (low binary size footprint) - -## Platform Requirements - -**Development:** -- Rust stable toolchain -- `cargo-ndk` (for Android cross-compilation) -- Android SDK (API level 34) -- Android NDK (v26.1.10909125 or compatible) -- For iOS: Xcode toolchain with Swift support - -**Production (BrowserStack):** -- No local platform setup required -- Artifacts uploaded to BrowserStack App Automate for execution on real devices -- Espresso framework for Android test automation -- XCUITest framework for iOS test automation - ---- - -*Stack analysis: 2026-01-21* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md deleted file mode 100644 index 8dde5e7..0000000 --- a/.planning/codebase/STRUCTURE.md +++ /dev/null @@ -1,280 +0,0 @@ -# Codebase Structure - -**Analysis Date:** 2026-01-21 - -## Directory Layout - -``` -mobile-bench-rs/ -├── crates/ # Cargo workspace members -│ ├── mobench/ # CLI tool (published to crates.io) -│ │ ├── src/ -│ │ │ ├── main.rs # CLI entry point -│ │ │ ├── lib.rs # Command handlers and orchestration -│ │ │ ├── config.rs # TOML config parsing -│ │ │ ├── browserstack.rs # BrowserStack REST API client -│ │ │ └── bin/cargo-mobench.rs # cargo-mobench subcommand wrapper -│ │ └── Cargo.toml -│ │ -│ ├── mobench-sdk/ # Core SDK library (published) -│ │ ├── src/ -│ │ │ ├── lib.rs # Public API surface (timing + full feature) -│ │ │ ├── timing.rs # Lightweight timing harness (always available) -│ │ │ ├── types.rs # Error types, BuildConfig, InitConfig -│ │ │ ├── registry.rs # Benchmark function discovery (inventory-based) -│ │ │ ├── runner.rs # Benchmark execution engine -│ │ │ ├── codegen.rs # Template generation from embedded files -│ │ │ └── builders/ -│ │ │ ├── mod.rs # Builders module exports -│ │ │ ├── android.rs # AndroidBuilder (cargo-ndk, Gradle) -│ │ │ ├── ios.rs # IosBuilder (xcodebuild, xcframework) -│ │ │ └── common.rs # Shared builder utilities -│ │ ├── templates/ # Embedded mobile app templates -│ │ │ ├── android/ # Android Gradle project scaffold -│ │ │ └── ios/ # iOS Xcode project scaffold -│ │ └── Cargo.toml -│ │ -│ ├── mobench-macros/ # Proc macro crate (published) -│ │ ├── src/lib.rs # #[benchmark] attribute macro -│ │ └── Cargo.toml -│ │ -│ ├── sample-fns/ # Sample functions for testing (not published) -│ │ ├── src/lib.rs # UniFFI FFI types and run_benchmark() -│ │ └── Cargo.toml -│ │ -├── examples/ -│ ├── basic-benchmark/ # Minimal #[benchmark] usage example -│ │ ├── src/lib.rs # Two bench_* functions, tests -│ │ └── Cargo.toml -│ │ -│ └── ffi-benchmark/ # Full UniFFI example (see sample-fns) -│ ├── src/lib.rs -│ └── Cargo.toml -│ -├── android/ # Development Android app (not auto-generated) -│ ├── app/ # App module -│ │ ├── src/main/ -│ │ │ ├── java/dev/world/bench/MainActivity.kt # Entry point -│ │ │ ├── java/uniffi/sample_fns/sample_fns.kt # Generated Kotlin bindings -│ │ │ ├── assets/bench_spec.json # Benchmark parameters -│ │ │ └── jniLibs/{abi}/ # Native .so libraries -│ │ ├── build.gradle # Gradle configuration -│ │ └── src/androidTest/ # Espresso tests for BrowserStack -│ ├── build.gradle -│ ├── settings.gradle -│ └── gradle/wrapper/ -│ -├── ios/ # Development iOS app (not auto-generated) -│ └── BenchRunner/ -│ ├── BenchRunner/ # Xcode project source -│ │ ├── BenchRunnerFFI.swift # FFI wrapper calling UniFFI bindings -│ │ ├── BenchRunner-Bridging-Header.h # Objective-C bridging header -│ │ ├── Generated/ # Auto-generated UniFFI code -│ │ │ ├── sample_fns.swift # Swift bindings from UniFFI -│ │ │ └── sample_fnsFFI.h # C header from UniFFI -│ │ └── ... -│ ├── BenchRunnerUITests/ # XCUITest runner for BrowserStack -│ ├── BenchRunner.xcodeproj/ # Xcode project -│ └── project.yml # XcodeGen specification -│ -├── templates/ # Source templates (symlinked to SDK) -│ ├── android/ # Android project template source -│ └── ios/ # iOS project template source -│ -├── .github/workflows/ -│ └── mobile-bench.yml # CI/CD workflow -│ -├── Cargo.toml # Workspace root -├── Cargo.lock -├── BUILD.md # Build reference -├── TESTING.md # Testing guide -├── CLAUDE.md # This project's guidelines for Claude -└── README.md -``` - -## Directory Purposes - -**`crates/mobench/`** - CLI orchestrator -- Purpose: Entry point for all user operations (build, run, list, fetch, init) -- Contains: Command handlers, BrowserStack API integration, config parsing -- Key files: `src/lib.rs` (commands), `src/main.rs` (entry), `src/browserstack.rs` (API) - -**`crates/mobench-sdk/`** - Core SDK library -- Purpose: Reusable SDK with timing harness, builders, registry, codegen -- Contains: Timing infrastructure, cross-platform builders, function discovery -- Key files: `src/lib.rs` (API), `src/timing.rs` (core), `src/registry.rs` (discovery) -- Sections: - - `src/timing.rs` - Always available, used by mobile binaries with `runner-only` feature - - `src/builders/` - Android/iOS build automation, requires full feature - - `src/registry.rs` - Runtime function discovery via `inventory` crate - - `src/codegen.rs` - Template parameterization and file generation - - `templates/` - Embedded Android and iOS project templates - -**`crates/mobench-macros/`** - Proc macro crate -- Purpose: Compile-time registration of benchmark functions -- Contains: `#[benchmark]` attribute implementation -- Key files: `src/lib.rs` (single file) - -**`crates/sample-fns/`** - Sample benchmark functions -- Purpose: Reference implementation for UniFFI FFI usage -- Contains: `BenchSpec`, `BenchReport`, `run_benchmark()` with FFI types -- Used by: Repository's Android/iOS test apps -- Key distinction: Shows how to define FFI-compatible types with `#[derive(uniffi::Record)]` - -**`examples/`** - Public examples -- Purpose: Demonstrate SDK usage patterns -- `basic-benchmark/` - Minimal SDK usage with `#[benchmark]` -- `ffi-benchmark/` - (Link to sample-fns implementation) - -**`android/`, `ios/`** - Development test apps -- Purpose: Test mobile app integration (not auto-generated) -- Contains: Full Gradle/Xcode projects with BrowserStack integration -- Android: Espresso tests in `src/androidTest/` -- iOS: XCUITest runner in `BenchRunnerUITests/` -- These apps are what `cargo mobench build` scaffolds for users - -**`templates/`** - Template sources -- Purpose: Source files compiled into SDK via `include_dir!` -- Structure mirrors `android/`, `ios/` directory layout - -## Key File Locations - -**Entry Points:** -- `crates/mobench/src/main.rs` - CLI binary entry point -- `crates/mobench/src/lib.rs` - Command orchestration and handlers -- `crates/mobench-sdk/src/lib.rs` - SDK public API surface -- `examples/basic-benchmark/src/lib.rs` - User project example - -**Configuration:** -- `crates/mobench/src/config.rs` - TOML parsing for `bench-config.toml` -- `crates/mobench-sdk/src/types.rs` - `BuildConfig`, `InitConfig` definitions -- `android/app/build.gradle` - Android build configuration -- `ios/BenchRunner/project.yml` - XcodeGen specification - -**Core Logic:** -- `crates/mobench-sdk/src/timing.rs` - Timing harness (nanosecond measurement) -- `crates/mobench-sdk/src/registry.rs` - Benchmark discovery via `inventory` -- `crates/mobench-sdk/src/runner.rs` - Execution engine linking registry to timing -- `crates/mobench-sdk/src/builders/android.rs` - NDK compilation, Gradle build -- `crates/mobench-sdk/src/builders/ios.rs` - Xcode compilation, xcframework creation -- `crates/mobench/src/browserstack.rs` - BrowserStack REST API client - -**Testing:** -- `crates/mobench-sdk/src/lib.rs` - SDK unit tests -- `examples/basic-benchmark/src/lib.rs:66-99` - Integration tests -- `android/app/src/androidTest/` - Espresso tests (BrowserStack) -- `ios/BenchRunner/BenchRunnerUITests/` - XCUITest runner (BrowserStack) - -**Mobile Integration:** -- `android/app/src/main/java/dev/world/bench/MainActivity.kt` - Android entry point -- `android/app/src/main/java/uniffi/sample_fns/sample_fns.kt` - Generated Kotlin bindings -- `ios/BenchRunner/BenchRunnerFFI.swift` - iOS entry point -- `ios/BenchRunner/Generated/sample_fns.swift` - Generated Swift bindings -- `crates/sample-fns/src/lib.rs` - UniFFI types and `run_benchmark()` export - -## Naming Conventions - -**Files:** -- Rust source files: `snake_case.rs` (e.g., `timing.rs`, `android.rs`, `main.rs`) -- Module files: `mod.rs` for re-exports, file per implementation -- Macro crates: Single file `src/lib.rs` (e.g., `mobench-macros/src/lib.rs`) -- Test files: Co-located with source as `#[cfg(test)]` mod at bottom - -**Directories:** -- Crate directories: kebab-case (e.g., `mobench-sdk`, `mobench-macros`) -- Module directories: snake_case (e.g., `builders/`, `templates/`) -- Platform packages: lowercase (e.g., `android/`, `ios/`) - -**Types:** -- Public types: PascalCase (e.g., `BenchSpec`, `BenchFunction`, `AndroidBuilder`) -- Error types: PascalCase variant names (e.g., `UnknownFunction`, `BuildError`) -- Enum variants: PascalCase (e.g., `Target::Android`, `BuildProfile::Release`) - -**Functions:** -- Public functions: snake_case (e.g., `run_benchmark()`, `discover_benchmarks()`) -- Attribute macros: snake_case (e.g., `#[benchmark]`) -- Builder methods: snake_case (e.g., `.verbose()`, `.output_dir()`) - -**Variables:** -- Local variables: snake_case (e.g., `builder`, `spec`, `output_dir`) -- Constants: SCREAMING_SNAKE_CASE (e.g., `CHECKSUM_INPUT`, `ANDROID_TEMPLATES`) -- Mutable state: Clear naming with `mut` keyword visible - -## Where to Add New Code - -**New Feature (e.g., new benchmarking strategy):** -- Primary code: Add to `crates/mobench-sdk/src/` as a new module -- Example: `crates/mobench-sdk/src/memory_profile.rs` for memory benchmarking -- Tests: `#[cfg(test)] mod tests { }` at bottom of module -- Public API: Re-export in `crates/mobench-sdk/src/lib.rs` - -**New Builder or Platform Support:** -- Implementation: `crates/mobench-sdk/src/builders/{platform}.rs` -- Example: Adding WASM support → `crates/mobench-sdk/src/builders/wasm.rs` -- Module registration: Add to `crates/mobench-sdk/src/builders/mod.rs` with `pub mod wasm;` -- Common utilities: Extend `crates/mobench-sdk/src/builders/common.rs` - -**New CLI Command:** -- Handler: Add variant to `Command` enum in `crates/mobench/src/lib.rs:150+` -- Implementation: Add to match statement in `run()` function -- Example: `Command::Analyze { ... }` → handler function for comparison logic - -**Utilities and Helpers:** -- Shared across SDK modules: `crates/mobench-sdk/src/builders/common.rs` -- Shared across CLI: Add to `crates/mobench/src/lib.rs` or new module -- Shared across crates: Consider extracting to new shared crate - -**Test Examples:** -- User-facing examples: `examples/` directory with `Cargo.toml` and `src/lib.rs` -- Internal integration tests: `crates/*/tests/` (create if needed) or co-located in source -- Unit tests: `#[cfg(test)] mod tests { }` at bottom of source files - -## Special Directories - -**`crates/mobench-sdk/templates/`:** -- Purpose: Source files embedded via `include_dir!` at compile time -- Generated: No, committed to git -- Committed: Yes, both `android/` and `ios/` templates -- Structure: Mirrors actual Android/iOS projects with placeholder variables -- Variables replaced during generation: `${PROJECT_NAME}`, `${PROJECT_SLUG}`, `${BUNDLE_PREFIX}` - -**`target/mobench/`:** -- Purpose: Output directory for all build artifacts -- Generated: Yes, created by builders during `build` and `run` commands -- Committed: No, in `.gitignore` -- Contents: - - `android/` - Generated Android project + APK - - `ios/` - Generated iOS project + xcframework + IPA - -**`.github/workflows/`:** -- Purpose: CI/CD pipeline definition -- File: `mobile-bench.yml` - Build and test automation - -**`android/app/src/androidTest/`:** -- Purpose: Espresso test suite for BrowserStack execution -- Contains: JUnit tests using Espresso framework -- Execution: Runs as test APK on BrowserStack Espresso - -**`ios/BenchRunner/BenchRunnerUITests/`:** -- Purpose: XCUITest runner for BrowserStack iOS -- Contains: Swift XCUITest classes -- Execution: Runs as XCUITest bundle on BrowserStack - -## Import and Module Organization - -**Workspace structure:** -- Root `Cargo.toml` defines members: `crates/mobench`, `crates/mobench-sdk`, `crates/mobench-macros`, `crates/sample-fns`, `examples/basic-benchmark`, `examples/ffi-benchmark` -- Workspace dependencies defined in `[workspace.dependencies]` - -**Public API exports:** -- SDK: `crates/mobench-sdk/src/lib.rs` re-exports key types at crate root -- CLI: `crates/mobench/src/lib.rs` private, main CLI logic in `main.rs` -- Macros: `crates/mobench-macros/src/lib.rs` exports `#[benchmark]` macro - -**Feature gating:** -- `full` (default): Builders, codegen, registry, runner -- `runner-only`: Timing module only, minimal mobile binary - ---- - -*Structure analysis: 2026-01-21* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md deleted file mode 100644 index a9c6bf3..0000000 --- a/.planning/codebase/TESTING.md +++ /dev/null @@ -1,278 +0,0 @@ -# Testing Patterns - -**Analysis Date:** 2026-01-21 - -## Test Framework - -**Runner:** -- Cargo built-in test runner (no external test framework) -- Run all tests: `cargo test --all` -- Run specific crate: `cargo test -p mobench-sdk` -- Watch mode: Not configured (no test-watching framework) -- Coverage: Not configured (no coverage tool integration) - -**Assertion Library:** -- Standard Rust `assert!()`, `assert_eq!()`, `assert_ne!()` -- Pattern matching: `assert!(matches!(result, Err(TimingError::NoIterations)))` - -**Run Commands:** -```bash -# Run all tests in workspace -cargo test --all - -# Run tests for specific crate -cargo test -p mobench-sdk -cargo test -p mobench - -# Run with output captured -cargo test -- --nocapture - -# List all tests without running -cargo test --all -- --list -``` - -## Test File Organization - -**Location:** -- Co-located with source code, not in separate `tests/` directory -- Test modules at end of each source file -- Conditional compilation: `#[cfg(test)]` wrapping test modules - -**Naming:** -- Test function prefix: `test_` or descriptive name -- Crate test modules: Named `tests` consistently -- Test utilities: Defined within test module using helper functions - -**Structure:** - -``` -src/ -├── timing.rs # Source + inline #[cfg(test)] mod tests -├── registry.rs # Source + inline #[cfg(test)] mod tests -├── builders/ -│ ├── android.rs # Source + inline #[cfg(test)] mod tests -│ └── ios.rs # Source + inline #[cfg(test)] mod tests -``` - -## Test Structure - -**Suite Organization:** - -From `crates/mobench-sdk/src/timing.rs`: -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn runs_benchmark() { - let spec = BenchSpec::new("noop", 3, 1).unwrap(); - let report = run_closure(spec, || Ok(())).unwrap(); - - assert_eq!(report.samples.len(), 3); - let non_zero = report.samples.iter().filter(|s| s.duration_ns > 0).count(); - assert!(non_zero >= 1); - } - - #[test] - fn rejects_zero_iterations() { - let result = BenchSpec::new("test", 0, 10); - assert!(matches!(result, Err(TimingError::NoIterations))); - } -} -``` - -**Patterns:** -- `use super::*;` to import all items from parent module -- Setup: Direct instantiation in each test (no shared fixtures) -- Execution: Call the function under test -- Assertion: Use `assert!()`, `assert_eq!()`, or pattern matching -- No teardown required (Rust handles memory cleanup) - -## Mocking - -**Framework:** No external mocking library used - -**Patterns:** -- Manual test doubles and stubs -- Trait-based design for dependency injection in production code -- Example from `crates/mobench-sdk/src/builders/android.rs` - builder pattern with customizable output: -```rust -#[test] -fn test_android_builder_custom_output_dir() { - let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile") - .output_dir("/custom/output"); - assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); -} -``` - -**What to Mock:** -- Not used in this codebase; tests use concrete types with customizable configuration -- Builder pattern preferred over dependency injection for tests - -**What NOT to Mock:** -- Core timing functionality (tested with actual measurements) -- Type conversion logic (uses direct instantiation) -- Registry operations (tested with actual inventory collection) - -## Fixtures and Factories - -**Test Data:** -No factory pattern or fixture framework found. Tests create minimal setup inline: - -```rust -#[test] -fn test_parse_output_metadata_unsigned() { - let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); - let metadata = r#"{"version":3,"artifactType":{"type":"APK",...}}"#; - let result = builder.parse_output_metadata(metadata); - assert_eq!(result, Some("app-release-unsigned.apk".to_string())); -} -``` - -**Location:** -- Test-specific data defined inline or as constants in test module -- Constants: `CHECKSUM_INPUT` in `crates/sample-fns/src/lib.rs` - -**Pattern:** -```rust -const CHECKSUM_INPUT: [u8; 1024] = [1; 1024]; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_checksum() { - // Use CHECKSUM_INPUT directly - } -} -``` - -## Coverage - -**Requirements:** Not enforced - -**View Coverage:** No coverage command configured - -**Observed Coverage:** -- `timing` module: 7 unit tests covering core functionality -- `registry` module: 3 tests covering discovery and lookup -- `builders` (android): 6+ tests covering builder construction and metadata parsing -- `builders` (ios): Tests present but not extensively reviewed - -## Test Types - -**Unit Tests:** -- Scope: Individual functions and methods -- Approach: Direct instantiation, no external dependencies -- Examples: - - `test_rejects_zero_iterations()` - validates error handling - - `test_allows_zero_warmup()` - boundary condition - - `test_serializes_to_json()` - serialization correctness - -**Integration Tests:** -- Scope: Multiple components working together -- Approach: Full builder workflow with real file operations -- Examples: - - Builder type construction and method chaining - - Metadata parsing from JSON - - Benchmark specification validation - -**E2E Tests:** -- Framework: None (not applicable; this is a library) -- Device testing: Handled by mobile app frameworks (XCUITest, Espresso) -- Host testing: No end-to-end test suite observed - -## Common Patterns - -**Async Testing:** -No async tests found (Rust blocking I/O used throughout) - -**Error Testing:** - -```rust -#[test] -fn rejects_zero_iterations() { - let result = BenchSpec::new("test", 0, 10); - assert!(matches!(result, Err(TimingError::NoIterations))); -} -``` - -Pattern: Use `matches!()` for pattern matching on Result/Option types - -**Builder Method Testing:** - -```rust -#[test] -fn test_android_builder_verbose() { - let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile") - .verbose(true); - assert!(builder.verbose); -} -``` - -**Successful Operation Testing:** - -```rust -#[test] -fn runs_benchmark() { - let spec = BenchSpec::new("noop", 3, 1).unwrap(); - let report = run_closure(spec, || Ok(())).unwrap(); - assert_eq!(report.samples.len(), 3); -} -``` - -**JSON Serialization Testing:** - -```rust -#[test] -fn serializes_to_json() { - let spec = BenchSpec::new("test", 10, 2).unwrap(); - let report = run_closure(spec, || Ok(())).unwrap(); - - let json = serde_json::to_string(&report).unwrap(); - let restored: BenchReport = serde_json::from_str(&json).unwrap(); - - assert_eq!(restored.spec.name, "test"); - assert_eq!(restored.samples.len(), 10); -} -``` - -**File Path Testing:** - -```rust -#[test] -fn test_android_builder_creation() { - let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); - assert_eq!( - builder.output_dir, - PathBuf::from("/tmp/test-project/target/mobench") - ); -} -``` - -## Test Characteristics - -**Independence:** -- Each test creates its own test data and configuration -- No shared state between tests -- Tests can run in any order - -**Determinism:** -- All tests are deterministic (no randomness) -- Timing tests accept variance (check `non_zero >= 1`, not exact values) - -**Readability:** -- Clear test names describing what is tested -- Simple, direct assertion patterns -- No test setup boilerplate - -**Performance:** -- Tests run quickly (unit tests < 100ms each) -- No external network calls -- No file I/O except in builder tests (using /tmp paths) - ---- - -*Testing analysis: 2026-01-21* diff --git a/BUILD.md b/BUILD.md index 0525273..1736a4f 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,7 +2,7 @@ Complete build instructions for Android and iOS targets. -In `mobench 0.1.22`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. +Build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. > **For SDK Integrators**: Use the CLI commands: > - `cargo mobench check --target android` (validate prerequisites first) @@ -530,20 +530,20 @@ Run host-side Rust tests: cargo test --all ``` -## Experimental Profiling Prerequisites +## Local Profiling Prerequisites -The experimental `cargo mobench profile ...` commands currently write planned -profile-session artifacts and backend-specific output layouts. Each run is -written under `target/mobench/profile//`, while +`cargo mobench profile ...` is local-first in this release. Each run is written +under `target/mobench/profile//`, while `target/mobench/profile/profile.json` and `summary.md` track the latest session. -The commands do not yet invoke native profiling tools automatically, but the -expected local toolchain is: +Supported local backends attempt native capture and write raw plus processed +artifacts such as `stacks.folded`, `native-report.txt`, and `flamegraph.html`. +The expected local toolchain is: - Android: `adb` plus `simpleperf` -- iOS: `xcrun` plus `xctrace` +- iOS: `xcrun`, `simctl`, and macOS `sample` BrowserStack remains an execution target for benchmark runs, but native -profiling through BrowserStack is not supported by the current MVP command path. +profiling through BrowserStack remains explicitly unsupported. ## Additional Documentation diff --git a/README.md b/README.md index bc1d4b3..3112bf9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # mobench -Mobile benchmarking SDK for Rust. Build and run Rust benchmarks on Android and iOS, locally or on BrowserStack, with a library-first workflow and config-first project resolution for custom repository layouts. +Mobile benchmarking toolkit for Rust. Build and run Rust benchmarks on Android and iOS, locally or on BrowserStack, with a library-first workflow, config-first project resolution, and local native profiling that produces interactive flamegraph artifacts. ## What it is @@ -83,7 +83,7 @@ cargo mobench ci run --target android --function sample_fns::fibonacci --local-o cargo mobench report summarize --summary target/mobench/ci/summary.json --plots auto cargo mobench report github --pr 123 --summary target/mobench/ci/summary.json -# Experimental profiling session capture +# Local native profiling cargo mobench profile run --target android --function sample_fns::fibonacci \ --provider local --backend android-native cargo mobench profile summarize --profile target/mobench/profile/profile.json @@ -97,7 +97,7 @@ CI contract outputs are written to `target/mobench/ci/`: Local summary renderers (`ci run --plots ...` and `report summarize --plots ...`) append a `Device Comparison Plots` section with one Sina-style SVG per benchmark function. Summary resource fields use `cpu_total_ms` and `peak_memory_kb`; Android raw resource stats are preserved and iOS peak memory is enriched from BrowserStack app profiling when available. -Experimental profiling commands are local-first in this release. Each session +Profiling commands are local-first in this release. Each session writes its current manifest and summary under `target/mobench/profile//`, and the CLI also refreshes top-level `target/mobench/profile/profile.json` and `summary.md` as convenience copies of @@ -109,9 +109,9 @@ The manifest is split into three explicit sections: - `semantic_profile`: optional benchmark phase data such as `prove` and `serialize` - `capture_metadata`: device resolution, capture settings, and warnings -The summary renderer keeps native and semantic outputs separate so the flamegraph -view stays focused on native stacks while phase timings remain readable as -benchmark metadata. +The summary renderer keeps native and semantic outputs separate so the +interactive flamegraph viewer stays focused on native stacks while phase +timings remain readable as benchmark metadata. When a benchmark uses `mobench_sdk::timing::profile_phase(...)`, local profile runs also persist a run-scoped semantic sidecar at @@ -176,6 +176,7 @@ CLI flags override config file values when provided. ## Project docs +- `docs/codebase/README.md`: current codebase reference map - `BENCH_SDK_INTEGRATION.md`: SDK integration guide - `BUILD.md`: build prerequisites and troubleshooting - `TESTING.md`: testing guide and device workflows @@ -268,7 +269,8 @@ fn db_query(db: &Database) { - Added device-selection inputs to `profile run` (`--device`, `--os-version`, `--profile`, `--device-matrix`) by reusing the existing deterministic device-resolution flow. - Added real local iOS native capture via simulator-host `sample`, with `sample.txt`, `stacks.folded`, `native-report.txt`, and `flamegraph.html` written into the normalized profile session layout. - Added regression coverage for profile help text, BrowserStack unsupported execution, dry-run planning semantics, and direct device target resolution. -- Added experimental `cargo mobench profile run|summarize` commands for a normalized local profiling session contract across Android and iOS. +- Added `cargo mobench profile run|summarize` commands for a normalized local profiling session contract across Android and iOS. +- Added the interactive dual-view flamegraph viewer plus full/focused SVG artifacts for local native profile runs. - Profile sessions now write run-scoped artifacts under `target/mobench/profile//` and refresh top-level latest-session `profile.json` and `summary.md` convenience files. - Profile manifests now preserve the selected provider and requested output format, and the CLI rejects unsupported format/backend combinations explicitly instead of silently planning the wrong artifacts. - Updated the profiling smoke-test docs to use working `cargo run -p mobench --bin mobench -- ...` invocations from the repo root. diff --git a/TESTING.md b/TESTING.md index 30ddf0f..bc8f958 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ This document provides comprehensive testing instructions for mobile-bench-rs. > - See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide > **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see **[BUILD.md](BUILD.md)**. This document focuses on testing scenarios and troubleshooting. -In `mobench 0.1.22`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. +Build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. ## Table of Contents - [Prerequisites](#prerequisites) @@ -650,18 +650,19 @@ cargo mobench compare \ ### Profiling Command Smoke Checks -The profiling subsystem currently focuses on the normalized session contract and -backend-specific artifact planning. Use these commands as the first validation -layer: +The profiling subsystem is now local-first and attempts real native capture for +`local + android-native` and `local + ios-instruments`. Use these commands as +the first validation layer: ```bash # Profile parser + contract + backend planning tests cargo test -p mobench profile_ -# Android planned session +# Android local native capture cargo run -p mobench --bin mobench -- profile run \ --target android \ --function sample_fns::fibonacci \ + --provider local \ --backend android-native # Render markdown summary from the generated manifest @@ -669,11 +670,12 @@ cargo run -p mobench --bin mobench -- profile summarize \ --profile target/mobench/profile/profile.json ``` -The current MVP should write a run-scoped session under +Each run should write a run-scoped session under `target/mobench/profile//` plus top-level latest-session copies at `target/mobench/profile/profile.json` and `target/mobench/profile/summary.md`. -Backend-specific planned artifact paths should live under the run-scoped -`artifacts/` tree. +For supported local native backends, the run-scoped `artifacts/` tree should +contain real raw and processed artifacts such as `stacks.folded`, +`native-report.txt`, and `flamegraph.html`. ### Adding New Test Functions diff --git a/android/README.md b/android/README.md index b74e311..208f52d 100644 --- a/android/README.md +++ b/android/README.md @@ -1,6 +1,6 @@ # Android demo app -Minimal Android app that loads the Rust `sample-fns` cdylib and calls exported functions. This is a thin wrapper meant for BrowserStack AppAutomate and CI smoke tests. +Minimal Android app that loads the Rust `sample-fns` cdylib and calls exported functions. This is a thin wrapper used for local smoke tests, template parity, and BrowserStack App Automate benchmark runs. ## Build steps diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index 777aa8d..2661bf6 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -4,7 +4,7 @@ Procedural macros for the [mobench](https://crates.io/crates/mobench) mobile ben This crate provides the `#[benchmark]` attribute macro that automatically registers functions for mobile benchmarking. It uses compile-time registration via the `inventory` crate to build a registry of benchmark functions. -In `mobench 0.1.22`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. +Benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. ## Features @@ -19,8 +19,8 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -mobench-macros = "0.1.22" -mobench-sdk = "0.1.22" # For the runtime +mobench-macros = "0.1" +mobench-sdk = "0.1" # For the runtime ``` ### Basic Example diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index 635493a..90175b8 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -1,8 +1,8 @@ # mobench-sdk -Mobile benchmarking SDK for Rust - run benchmarks on real Android and iOS devices. +Mobile benchmarking SDK for Rust. -Transform your Rust project into a mobile benchmarking suite. This SDK provides everything you need to benchmark your Rust code on real mobile devices via BrowserStack or local emulators/simulators. +Transform your Rust project into a mobile benchmarking suite. The SDK provides the timing/runtime layer, registry, builders, and generated mobile runners used by the `mobench` CLI for local execution, BrowserStack benchmark runs, and local native profiling. ## Features @@ -12,10 +12,11 @@ Transform your Rust project into a mobile benchmarking suite. This SDK provides - **Mobile app generation**: Create Android/iOS apps from templates - **Build automation**: Cross-compile and package for mobile platforms - **Statistical analysis**: Mean, median, stddev, percentiles -- **BrowserStack integration**: Test on real devices in the cloud +- **Semantic profiling phases**: Annotate benchmark sub-steps with `profile_phase(...)` +- **BrowserStack benchmark integration**: Run timing benchmarks on real devices in the cloud - **UniFFI bindings**: Automatic FFI generation for mobile platforms - **Configuration file support**: `mobench.toml` for project settings -- **Config-first CLI integration**: `mobench 0.1.22` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root +- **Config-first CLI integration**: the CLI resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root ## Quick Start @@ -23,7 +24,7 @@ Add mobench-sdk to your project: ```toml [dependencies] -mobench-sdk = "0.1.22" +mobench-sdk = "0.1" ``` Mark functions to benchmark: @@ -86,7 +87,7 @@ This creates: - `android/` or `ios/` - Mobile app projects - `bench-config.toml` - Configuration file -The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.22` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. +The generated `bench-mobile/` crate is still the default scaffold, but the CLI can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. ### 2. Add Benchmarks @@ -134,6 +135,21 @@ cargo mobench run --target android --function my_benchmark \ --devices "Google Pixel 7-13.0" --release ``` +Local profiling: + +```bash +cargo mobench profile run \ + --target android \ + --provider local \ + --backend android-native \ + --crate-path ./crates/my-benchmarks \ + --function my_benchmark +``` + +When the benchmark emits semantic phases with `mobench_sdk::timing::profile_phase(...)`, +the CLI merges those phase timings into the profile manifest and summary next to the +native stack artifacts. + ## Examples (Repository) - `examples/basic-benchmark`: minimal SDK usage with `#[benchmark]` @@ -391,7 +407,7 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.22` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### `bench-config.toml` (Run Configuration) diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index f27df07..7d4de1c 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -4,14 +4,15 @@ //! [![Documentation](https://docs.rs/mobench-sdk/badge.svg)](https://docs.rs/mobench-sdk) //! [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/worldcoin/mobile-bench-rs/blob/main/LICENSE) //! -//! A mobile benchmarking SDK for Rust that enables running performance benchmarks -//! on real Android and iOS devices via BrowserStack App Automate. +//! A mobile benchmarking SDK for Rust that provides the runtime, builders, and +//! generated mobile runners used by the `mobench` CLI for local execution, +//! BrowserStack benchmark runs, and local native profiling. //! //! ## Overview //! //! `mobench-sdk` provides a simple, declarative API for defining benchmarks that can -//! run on mobile devices. It handles the complexity of cross-compilation, FFI bindings, -//! and mobile app packaging automatically. +//! run on mobile devices. It handles the timing/runtime layer, cross-compilation, +//! FFI bindings, template generation, and mobile app packaging used by the CLI. //! //! ## Quick Setup Checklist //! @@ -106,6 +107,10 @@ //! # Run on BrowserStack (use --release for smaller APK uploads) //! cargo mobench run --target android --function my_expensive_operation \ //! --iterations 100 --warmup 10 --devices "Google Pixel 7-13.0" --release +//! +//! # Or capture a local native profile +//! cargo mobench profile run --target android --provider local \ +//! --backend android-native --function my_expensive_operation //! ``` //! //! ## Architecture diff --git a/crates/mobench-sdk/templates/android/README.md b/crates/mobench-sdk/templates/android/README.md index fbdf4d4..9556163 100644 --- a/crates/mobench-sdk/templates/android/README.md +++ b/crates/mobench-sdk/templates/android/README.md @@ -1,6 +1,6 @@ # {{PROJECT_NAME}} Android Benchmark App -This is an auto-generated Android app for running Rust benchmarks on real devices. +This is an auto-generated Android app for running Rust benchmarks and local native profiling captures. ## Building @@ -8,21 +8,34 @@ This is an auto-generated Android app for running Rust benchmarks on real device # Build debug APK ./gradlew assembleDebug -# Build release APK (recommended for BrowserStack - ~133MB vs ~544MB debug) +# Build release APK (recommended for BrowserStack uploads) ./gradlew assembleRelease ``` -## BrowserStack Testing +## Benchmark execution ```bash # Build with mobench (use --release for smaller APK uploads) cargo mobench build --target android --release +# Run locally +cargo mobench run --target android --function my_benchmark + # Run on BrowserStack cargo mobench run --target android --function my_benchmark \ --devices "Google Pixel 7-13.0" --release ``` +## Local native profiling + +```bash +cargo mobench profile run \ + --target android \ + --provider local \ + --backend android-native \ + --function my_benchmark +``` + ## Running Benchmarks The app reads benchmark configuration from: @@ -30,11 +43,13 @@ The app reads benchmark configuration from: 2. `assets/bench_spec.json` 3. Default values in code -## Benchmark Report Capture +## Benchmark report capture -The app captures benchmark results for BrowserStack: -- Displays results on screen for 5 seconds (video capture) +The app emits benchmark results for both local automation and BrowserStack: +- Keeps results on screen briefly so humans and automation can inspect them - Outputs JSON with `BENCH_JSON` marker to logcat +- Includes `phases` when the benchmark uses `profile_phase(...)` +- Is marked `profileable` so local native profilers can attach to it ## Generated by diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index 8649df0..08905b6 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -64,8 +64,9 @@ class MainActivity : AppCompatActivity() { findViewById(R.id.result_text)?.text = display - // Keep the report on screen for at least 5 seconds so BrowserStack video captures it - android.util.Log.i("BenchRunner", "Displaying results for 5 seconds for video capture...") + // Keep the report visible briefly so local smoke runs and remote automation + // have a stable window to read the results. + android.util.Log.i("BenchRunner", "Displaying results for 5 seconds for capture output...") Thread.sleep(5000) android.util.Log.i("BenchRunner", "Display hold complete") } @@ -165,7 +166,7 @@ class MainActivity : AppCompatActivity() { DEFAULT_WARMUP ) - // Check for intent extras (used for test automation and BrowserStack) + // Check for intent extras used by local automation, smoke tests, and provider-driven runs. val intentFunction = intent?.getStringExtra(FUNCTION_EXTRA)?.takeUnless { it.isBlank() } val intentIterations = intent?.let { val value = it.getIntExtra(ITERATIONS_EXTRA, -1) diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index db131af..cbd2bed 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -146,7 +146,8 @@ enum {{PROJECT_NAME_PASCAL}}FFI { } } - /// Generates a JSON report matching the Android BENCH_JSON format for consistency + /// Generates a JSON report that matches the Android BENCH_JSON-style payload + /// consumed by mobench so cross-platform parsers can stay aligned. private static func generateJSONReport(_ report: BenchReport) -> String { var json: [String: Any] = [:] diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template index 07fa8e4..cbdb8de 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template @@ -107,7 +107,8 @@ struct ContentView: View { reportJSON = result.jsonReport isCompleted = true - // Log the JSON report with markers for BrowserStack device logs + // Log the JSON report with stable markers so local profiling tools, + // XCUITest, and BrowserStack fetch paths can all recover the payload. NSLog("BENCH_REPORT_JSON_START") NSLog("%@", result.jsonReport) NSLog("BENCH_REPORT_JSON_END") diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/README.md b/crates/mobench-sdk/templates/ios/BenchRunner/README.md index b0cefc1..5fa0193 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/README.md +++ b/crates/mobench-sdk/templates/ios/BenchRunner/README.md @@ -1,6 +1,6 @@ # {{PROJECT_NAME_PASCAL}} iOS Benchmark App -This is an auto-generated iOS app for running Rust benchmarks on real devices. +This is an auto-generated iOS app for running Rust benchmarks and local native profiling captures. ## Building @@ -15,9 +15,9 @@ xcodebuild -scheme BenchRunner -destination 'platform=iOS Simulator,name=iPhone xcodebuild -scheme BenchRunner -destination 'generic/platform=iOS' build ``` -## BrowserStack Testing +## Benchmark execution -For BrowserStack testing, you need both the app IPA and the XCUITest runner: +For BrowserStack benchmarking, you need both the app IPA and the XCUITest runner: ```bash # Package the app as IPA @@ -31,6 +31,22 @@ cargo mobench run --target ios --function my_benchmark \ --devices "iPhone 14-16" --release ``` +For local simulator execution: + +```bash +cargo mobench run --target ios --function my_benchmark +``` + +## Local native profiling + +```bash +cargo mobench profile run \ + --target ios \ + --provider local \ + --backend ios-instruments \ + --function my_benchmark +``` + ## Running Benchmarks The app reads benchmark configuration from: @@ -39,12 +55,13 @@ The app reads benchmark configuration from: 3. `bench_spec.json` in bundle 4. Default values in code -## Benchmark Report Capture +## Benchmark report capture -The app captures benchmark results for BrowserStack: -- Displays results on screen for 5 seconds (video capture) +The app emits benchmark results for both local automation and BrowserStack: +- Displays results long enough for capture/inspection - Outputs JSON with `BENCH_REPORT_JSON_START/END` markers - XCUITest extracts results via accessibility identifiers +- Supports profiling launch options that can delay, warm, or repeat the benchmark to fill a local native capture window ## Generated by diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 056d8e5..7d17a59 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -1,8 +1,8 @@ # mobench -Mobile benchmarking CLI for Rust - Run benchmarks on real Android and iOS devices. +Mobile benchmarking CLI for Rust. -The `mobench` CLI is the easiest way to benchmark your Rust code on mobile devices. It handles everything from project setup to building mobile apps to running tests on real devices via BrowserStack. +The `mobench` CLI handles project setup, mobile artifact builds, benchmark execution, result reporting, and local native profiling for Android and iOS. Benchmark execution can run locally or on BrowserStack. Native profiling is currently local-first. ## Installation @@ -101,24 +101,30 @@ cargo mobench run \ **Note**: Always use the `--release` flag for BrowserStack runs. Debug builds are significantly larger (~544MB vs ~133MB for release) and may cause upload timeouts. -### 5. Plan a Local Profiling Session +### 5. Run a Local Profiling Session ```bash -# Write a run-scoped experimental profile session contract +# Capture a local Android native profile cargo mobench profile run \ --target android \ --function fibonacci_30 \ + --provider local \ --backend android-native -# Render the latest-session manifest as markdown +# Summarize the latest profile session cargo mobench profile summarize \ --profile target/mobench/profile/profile.json ``` -The current profiling MVP writes a normalized manifest plus planned artifact -paths under `target/mobench/profile//` and refreshes top-level latest -copies at `target/mobench/profile/profile.json` and `summary.md`. It does not -yet drive native profiler tools automatically. +Local profile runs now attempt real native capture for: + +- `local + android-native`: `simpleperf`, symbolized folded stacks, `native-report.txt`, full/focused SVGs, and `flamegraph.html` +- `local + ios-instruments`: simulator-host `sample`, folded stacks, `native-report.txt`, full/focused SVGs, and `flamegraph.html` + +Each run writes a normalized manifest under `target/mobench/profile//` +and refreshes `target/mobench/profile/profile.json` plus `summary.md` as +latest-run convenience copies. BrowserStack native profiling remains explicitly +unsupported in this release. ## Commands diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 6ae9ee6..6e4a933 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -4,7 +4,8 @@ //! [![Documentation](https://docs.rs/mobench/badge.svg)](https://docs.rs/mobench) //! [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/worldcoin/mobile-bench-rs/blob/main/LICENSE) //! -//! Command-line tool for building and running Rust benchmarks on mobile devices. +//! Command-line tool for building, running, reporting, and profiling Rust +//! benchmarks on mobile platforms. //! //! ## Overview //! @@ -12,7 +13,8 @@ //! //! - **Building** - Compiles Rust code for Android/iOS and packages mobile apps //! - **Running** - Executes benchmarks locally or on BrowserStack devices -//! - **Reporting** - Collects and formats benchmark results +//! - **Profiling** - Plans and executes supported local native captures +//! - **Reporting** - Collects and formats benchmark and profiling results //! //! ## Installation //! @@ -170,14 +172,14 @@ struct Cli { #[derive(Subcommand, Debug)] enum Command { - /// Run benchmarks on real devices via BrowserStack. + /// Run benchmarks locally or on BrowserStack devices. /// /// This is a single-command flow that: /// 1. Builds Rust libraries for the target platform /// 2. Packages mobile apps (APK/IPA) automatically - /// 3. Uploads to BrowserStack - /// 4. Schedules the benchmark run - /// 5. Fetches results when complete + /// 3. Uploads to BrowserStack when devices are requested + /// 4. Schedules the benchmark run when using BrowserStack + /// 5. Fetches results when the provider returns them /// /// For iOS, IPA and XCUITest packages are created automatically unless /// you provide --ios-app and --ios-test-suite to override. @@ -328,7 +330,7 @@ enum Command { #[arg(long, help = "Optional output path for markdown report")] output: Option, }, - /// Initialize a new benchmark project with SDK (Phase 1 MVP). + /// Initialize a new benchmark project with the SDK templates. InitSdk { #[arg(long, value_enum)] target: SdkTarget, @@ -339,7 +341,7 @@ enum Command { #[arg(long, help = "Generate example benchmarks")] examples: bool, }, - /// Build mobile artifacts (Phase 1 MVP). + /// Build mobile artifacts from the resolved benchmark crate. Build { #[arg(long, value_enum)] target: SdkTarget, @@ -409,7 +411,7 @@ enum Command { )] output_dir: Option, }, - /// List all discovered benchmark functions (Phase 1 MVP). + /// List all discovered benchmark functions. List { #[arg( long, @@ -5498,7 +5500,7 @@ fn write_file(path: &Path, contents: &[u8]) -> Result<()> { fs::write(path, contents).with_context(|| format!("writing file {:?}", path)) } -/// Initialize a new benchmark project using mobench-sdk (Phase 1 MVP) +/// Initialize a new benchmark project using `mobench-sdk`. fn cmd_init_sdk( target: SdkTarget, project_name: String, @@ -5537,7 +5539,7 @@ fn cmd_init_sdk( Ok(()) } -/// Build mobile artifacts using mobench-sdk (Phase 1 MVP) +/// Build mobile artifacts using `mobench-sdk`. fn cmd_build( target: SdkTarget, release: bool, diff --git a/crates/mobench/templates/ci/action.README.md b/crates/mobench/templates/ci/action.README.md index df41f08..07b4625 100644 --- a/crates/mobench/templates/ci/action.README.md +++ b/crates/mobench/templates/ci/action.README.md @@ -1,6 +1,6 @@ # mobench GitHub Action -Run `mobench ci run` in GitHub Actions with caching, Android SDK setup, and artifact upload. +Run benchmark-oriented `mobench` commands in GitHub Actions with caching, Android SDK setup, and artifact upload. ## Usage @@ -50,6 +50,7 @@ Run `mobench ci run` in GitHub Actions with caching, Android SDK setup, and arti - Inputs are passed through environment variables in shell steps to reduce script-injection risk from workflow inputs. - `command` is allow-listed in the action implementation; unsupported command values fail the job early. +- This action is for benchmark CI flows. Local native profiling has its own self-test workflow because it does not use the BrowserStack benchmark path. ## Cache keys diff --git a/docs/CONCERNS_RESOLUTION_2026-02-16.md b/docs/CONCERNS_RESOLUTION_2026-02-16.md index 3c301df..796d56c 100644 --- a/docs/CONCERNS_RESOLUTION_2026-02-16.md +++ b/docs/CONCERNS_RESOLUTION_2026-02-16.md @@ -1,6 +1,6 @@ # Concerns Resolution (2026-02-16) -This document records the disposition of concerns previously tracked in `.planning/codebase/CONCERNS.md`. +This document records the disposition of concerns previously tracked in the old codebase-planning backlog artifact that lived under `.planning/codebase/`. Disposition labels: - `fixed`: implemented in code on `codex/ci-devex` @@ -70,5 +70,5 @@ Disposition labels: ## Notes -- This file replaces the previous `.planning/codebase/CONCERNS.md` backlog artifact. +- This file replaces the previous codebase-planning concerns backlog artifact. - Resolved items in this pass include safer iOS packaging command construction, cargo metadata JSON parsing, and canonicalization fallback visibility. diff --git a/docs/codebase/ARCHITECTURE.md b/docs/codebase/ARCHITECTURE.md new file mode 100644 index 0000000..e9f7b0c --- /dev/null +++ b/docs/codebase/ARCHITECTURE.md @@ -0,0 +1,127 @@ +# Architecture + +Updated: 2026-03-27 + +## System shape + +`mobench` now has two distinct but related product surfaces: + +1. benchmark execution + - build mobile artifacts + - run locally or on BrowserStack + - collect summaries, CSV, plots, and PR comments +2. native profiling + - plan and execute local native captures + - keep a normalized manifest contract + - write flamegraph and semantic-phase artifacts + +The workspace still centers on three published crates: + +- `mobench`: CLI orchestration, BrowserStack client, CI/reporting entry points, profile execution, and flamegraph viewer generation +- `mobench-sdk`: timing harness, registry/runner, project generation, builders, and generated mobile runner templates +- `mobench-macros`: `#[benchmark]` proc macro registration + +## Runtime layers + +### CLI orchestration + +Location: `crates/mobench/src/` + +Responsibilities: +- parse commands and resolve project layout +- build/package Android and iOS artifacts +- dispatch benchmark runs locally or to BrowserStack +- fetch BrowserStack artifacts and enrich results +- manage fixture CI flows and report rendering +- run local native profiling and summarize profile sessions + +Important modules: +- `lib.rs`: command parsing and benchmark/CI orchestration +- `profile.rs`: target resolution, capture planning, capture execution, manifest/summary writing +- `flamegraph_viewer.rs`: focused/full folded-stack derivation and interactive flamegraph HTML generation +- `browserstack.rs`: App Automate upload, schedule, polling, and fetch helpers + +### SDK/runtime layer + +Location: `crates/mobench-sdk/src/` + +Responsibilities: +- benchmark timing and statistics +- function registry and runtime dispatch +- semantic phase capture via `profile_phase(...)` +- Android/iOS code generation and builders +- portable FFI-facing benchmark types + +Important modules: +- `timing.rs`: `BenchSpec`, `BenchReport`, samples, phases, and the timing harness +- `registry.rs` / `runner.rs`: benchmark lookup and execution +- `builders/android.rs`, `builders/ios.rs`: native library build + project packaging +- `codegen.rs`: template expansion and regeneration rules + +### Generated mobile runners + +Generated from `crates/mobench-sdk/templates/` into `target/mobench/`. + +Responsibilities: +- load `bench_spec.json` or launch-time overrides +- call the UniFFI-exposed Rust benchmark entrypoints +- emit benchmark JSON for the CLI fetch/parsing paths +- keep benchmark work alive long enough for local native profile capture when profiling launch options are supplied + +Platform-specific behavior: +- Android runner emits `BENCH_JSON ...` in logcat and is marked `profileable` for local native capture +- iOS runner emits `BENCH_REPORT_JSON_START/END` markers and supports repeat/warmup launch options used by local simulator-host profiling + +### CI/reporting layer + +Responsibilities: +- run fixture benchmarks on BrowserStack from GitHub Actions +- publish normalized CI outputs (`summary.json`, `summary.md`, `results.csv`) +- render shared device-comparison plots +- publish sticky PR comments and check runs + +Current status: +- benchmark CI is first-class +- local native profiling is first-class on developer machines +- a dedicated profiling self-test workflow is separate from BrowserStack benchmark workflows + +## Key flows + +### Benchmark flow + +1. benchmark functions are registered at compile time through `inventory` +2. `cargo mobench build` or `run` resolves the benchmark crate/layout +3. the SDK builders compile Rust libraries and regenerate mobile bindings/templates +4. mobile runners execute `run_benchmark(...)` +5. the CLI collects and normalizes benchmark outputs locally or from BrowserStack +6. reporters render JSON, Markdown, CSV, plots, PR comments, and check-run summaries + +### Profiling flow + +1. `cargo mobench profile run` resolves a target and device context +2. `profile.rs` builds a deterministic manifest and output contract +3. supported local backends attempt native capture + - Android: `simpleperf` + - iOS: simulator-host `sample` +4. post-processing writes: + - `stacks.folded` + - `native-report.txt` + - `flamegraph.full.svg` + - `flamegraph.focused.svg` + - `flamegraph.html` + - `artifacts/semantic/phases.json` when phase data exists +5. `profile summarize` renders the manifest into Markdown or JSON + +### Device resolution flow + +The repo uses one device-resolution model for both benchmark CI and profile planning: + +- `cargo mobench devices resolve` is the canonical entry point +- `profile run` reuses the same profile/matrix/device concepts +- BrowserStack benchmark workflows rely on this resolution path in CI + +## Boundaries and non-goals + +- BrowserStack benchmark execution is supported for timing/memory runs. +- BrowserStack native profiling is explicitly unsupported until retrievable native artifacts exist. +- The flamegraph viewer is a stable dual-view explorer; rolled-back experimental tower-collapse behavior is documented only as historical design work. diff --git a/docs/codebase/CONVENTIONS.md b/docs/codebase/CONVENTIONS.md new file mode 100644 index 0000000..5b0bcc3 --- /dev/null +++ b/docs/codebase/CONVENTIONS.md @@ -0,0 +1,58 @@ +# Conventions + +Updated: 2026-03-27 + +## Naming + +- Rust files, modules, functions, and variables: `snake_case` +- public types and enum variants: `PascalCase` +- constants and environment variable names: `SCREAMING_SNAKE_CASE` +- artifact labels in manifests: kebab-case or short descriptive strings + +## Output conventions + +- default generated output root: `target/mobench/` +- benchmark CI outputs: `summary.json`, `summary.md`, `results.csv` +- profile outputs are run-scoped and also mirrored to latest-run convenience copies +- processed profile artifacts keep stable names: + - `stacks.folded` + - `native-report.txt` + - `flamegraph.full.svg` + - `flamegraph.focused.svg` + - `flamegraph.html` + +## Config conventions + +Resolution order stays consistent across build/run/profile commands: + +1. explicit CLI flags +2. explicit config path +3. discovered `mobench.toml` +4. workspace root +5. git root +6. legacy fallback paths + +Device resolution semantics should reuse the same surface area instead of inventing command-specific flags: +- `--device` +- `--os-version` +- `--profile` +- `--device-matrix` + +## Documentation and comments + +- public Rust items should use `//!` or `///` comments when they define user-facing behavior +- comments should explain why a branch/tooling workaround exists, not restate obvious code +- README and template docs should describe current shipped behavior, not superseded MVP constraints +- design docs in `docs/plans/` should be marked clearly when a design was abandoned or rolled back + +## Template editing + +- edit `templates/` and the mirrored `crates/mobench-sdk/templates/` copy together +- keep Android and iOS runner JSON/log markers aligned so CLI parsers stay cross-platform +- when SDK report structures change, regenerate or refresh template/runtime bindings in the same change + +## Error handling style + +- SDK surfaces typed errors via `thiserror` +- CLI orchestration adds context with `anyhow` +- unsupported provider/backend combinations must fail explicitly and describe the supported alternative diff --git a/docs/codebase/INTEGRATIONS.md b/docs/codebase/INTEGRATIONS.md new file mode 100644 index 0000000..b41a2fe --- /dev/null +++ b/docs/codebase/INTEGRATIONS.md @@ -0,0 +1,69 @@ +# Integrations + +Updated: 2026-03-27 + +## BrowserStack + +Purpose: +- benchmark execution on real Android and iOS devices +- device inventory lookup +- session artifact download +- optional metric enrichment for summaries + +Implementation: +- client code lives in `crates/mobench/src/browserstack.rs` +- auth uses `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` +- benchmark CI workflows resolve device profiles through `cargo-mobench devices resolve` + +Supported BrowserStack flows: +- Android Espresso benchmark runs +- iOS XCUITest benchmark runs +- device listing and resolution +- artifact fetch and post-run summary enrichment + +Explicitly unsupported: +- BrowserStack native profiling for `profile run` + +## Local native profiling + +Android integration: +- build a profileable app +- launch or attach through `adb` +- capture with `simpleperf` +- symbolize native frames with `llvm-addr2line` +- render dual-view flamegraph artifacts + +iOS integration: +- build and install a simulator app +- launch with profiling-specific environment/arguments +- capture with macOS `sample` +- derive focused and full folded stacks +- render dual-view flamegraph artifacts + +## GitHub Actions + +Current workflow families: +- `mobile-bench.yml`: dispatchable BrowserStack benchmark workflow for the fixture crate +- `mobile-bench-plot-fixtures.yml`: fixture plot rendering verification +- `mobile-bench-selftest.yml`: sample benchmark self-test dispatch +- `compile-gate.yml`: compile-only PR gate +- `mobile-bench-pr-auto.yml` and `mobile-bench-pr-command.yml`: PR-triggered benchmark dispatch + +Primary artifact contracts: +- benchmark CI: `summary.json`, `summary.md`, `results.csv`, plots +- profiling smoke/self-test: profile manifest + flamegraph artifacts + +## Local configuration and credentials + +Resolved from: +1. explicit CLI flags +2. `mobench.toml` / config files +3. environment variables +4. `.env.local` where supported + +Common variables: +- `BROWSERSTACK_USERNAME` +- `BROWSERSTACK_ACCESS_KEY` +- `ANDROID_NDK_HOME` + +Profiling-specific launch variables are generated by the CLI and passed into the mobile runners; they are not meant to be hand-authored in normal benchmark runs. diff --git a/docs/codebase/README.md b/docs/codebase/README.md new file mode 100644 index 0000000..9ae9592 --- /dev/null +++ b/docs/codebase/README.md @@ -0,0 +1,11 @@ +# Codebase Reference + +These notes replace the older `.planning/codebase/` scratch docs and track the +repo as it exists on `dev` after the fixture CI and local profiling work. + +- [ARCHITECTURE.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/ARCHITECTURE.md): how the CLI, SDK, generated runners, profiling pipeline, and CI workflows fit together +- [STRUCTURE.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/STRUCTURE.md): where the important crates, templates, docs, and workflows live +- [STACK.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/STACK.md): the main languages, tools, runtime dependencies, and native profiler toolchain +- [INTEGRATIONS.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/INTEGRATIONS.md): BrowserStack, GitHub Actions, Android, iOS, and local profiling integrations +- [CONVENTIONS.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/CONVENTIONS.md): naming, output, configuration, and documentation conventions +- [TESTING.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/TESTING.md): host tests, fixture workflows, smoke tests, and profiling validation diff --git a/docs/codebase/STACK.md b/docs/codebase/STACK.md new file mode 100644 index 0000000..4ceca76 --- /dev/null +++ b/docs/codebase/STACK.md @@ -0,0 +1,80 @@ +# Technology Stack + +Updated: 2026-03-27 + +## Languages + +- Rust 2024: workspace root and primary implementation language +- Rust 2021: some example and fixture crates retained for compatibility +- Kotlin: generated Android bindings and runner code +- Swift: generated iOS bindings and runner code +- YAML/TOML/JSON: workflow, config, matrix, and report contracts + +## Core Rust crates + +- `clap`: CLI surface +- `serde`, `serde_json`, `serde_yaml`, `toml`: config and report serialization +- `anyhow`, `thiserror`: layered error handling +- `inventory`: benchmark registration +- `uniffi`: Kotlin/Swift binding generation +- `include_dir`: embedded template assets +- `reqwest` with `rustls`: BrowserStack REST calls +- `time`: RFC3339 timestamps and report metadata +- `inferno`: flamegraph SVG generation + +## Native toolchain dependencies + +### Android + +- Rust Android targets +- Android SDK + NDK +- `cargo-ndk` +- Gradle / Android build tools +- `adb` +- `simpleperf` +- `llvm-addr2line` for symbolization + +### iOS + +- Rust iOS targets +- Xcode / xcodebuild +- XcodeGen for generated project flows +- `xcrun simctl` +- macOS `sample` for current local native profiling + +## External services + +- BrowserStack App Automate + - benchmark execution on real devices + - device inventory resolution + - session artifact fetching and metric enrichment +- GitHub Actions + - fixture benchmark workflows + - plot fixture verification + - PR auto-dispatch and sticky comments + +## Runtime artifacts + +Benchmark outputs: +- `summary.json` +- `summary.md` +- `results.csv` +- plot SVGs when enabled + +Profile outputs: +- `profile.json` +- `summary.md` +- raw capture artifacts (`sample.perf`, `sample.txt`, etc.) +- processed stacks (`stacks.folded`, `native-report.txt`) +- viewer artifacts (`flamegraph.full.svg`, `flamegraph.focused.svg`, `flamegraph.html`) +- semantic sidecar data (`artifacts/semantic/phases.json`) + +## Supported execution modes + +- Local benchmark execution +- BrowserStack benchmark execution +- Local Android native profiling +- Local iOS native profiling + +Explicitly not supported: +- BrowserStack native profiling with retrievable flamegraph-capable artifacts diff --git a/docs/codebase/STRUCTURE.md b/docs/codebase/STRUCTURE.md new file mode 100644 index 0000000..601d897 --- /dev/null +++ b/docs/codebase/STRUCTURE.md @@ -0,0 +1,92 @@ +# Structure + +Updated: 2026-03-27 + +## Workspace layout + +```text +mobile-bench-rs/ +├── crates/ +│ ├── mobench/ # CLI, BrowserStack client, profile execution, reports +│ ├── mobench-sdk/ # timing/runtime/builders/codegen/templates +│ ├── mobench-macros/ # #[benchmark] proc macro +│ └── sample-fns/ # sample UniFFI benchmark crate used for smoke tests +├── examples/ +│ ├── basic-benchmark/ # minimal benchmark crate +│ └── ffi-benchmark/ # fixture benchmark crate used in CI +├── android/ # checked-in Android runner/demo app +├── ios/ # checked-in iOS runner/demo app +├── templates/ # editable source templates mirrored into SDK templates +├── docs/ +│ ├── adr/ +│ ├── codebase/ # this reference set +│ ├── plans/ # design and implementation notes +│ └── schemas/ +├── .github/ +│ ├── actions/mobench/ # local composite action for benchmark CI +│ └── workflows/ # benchmark, fixture, plot, and PR-dispatch workflows +└── scripts/ # fixture verification and helper scripts +``` + +## Important code locations + +### CLI + +- `crates/mobench/src/lib.rs`: clap surface and benchmark/CI command orchestration +- `crates/mobench/src/profile.rs`: local native profiling flow, manifests, summaries, artifact contracts +- `crates/mobench/src/flamegraph_viewer.rs`: focused/full flamegraph generation and interactive HTML viewer +- `crates/mobench/src/browserstack.rs`: BrowserStack App Automate REST client +- `crates/mobench/src/config.rs`: config + matrix loading + +### SDK + +- `crates/mobench-sdk/src/timing.rs`: `BenchSpec`, `BenchReport`, sample timing, semantic phases +- `crates/mobench-sdk/src/registry.rs`: benchmark discovery +- `crates/mobench-sdk/src/runner.rs`: `run_benchmark` entrypoints +- `crates/mobench-sdk/src/codegen.rs`: template expansion and regeneration +- `crates/mobench-sdk/src/builders/android.rs`: Android library build, packaging, and template sync +- `crates/mobench-sdk/src/builders/ios.rs`: iOS library build, Xcode project generation, IPA/XCUITest packaging + +### Generated templates + +- `crates/mobench-sdk/templates/android/`: embedded Android runner template +- `crates/mobench-sdk/templates/ios/BenchRunner/`: embedded iOS runner template +- `templates/android/` and `templates/ios/`: editable template sources mirrored into the SDK tree + +### Repository fixtures and demos + +- `android/`: checked-in Android app used for local smoke work and template parity +- `ios/BenchRunner/`: checked-in iOS app used for local smoke work and template parity +- `crates/sample-fns/`: sample benchmarks used for flamegraph and device-resolution smoke tests +- `examples/ffi-benchmark/`: benchmark fixture exercised by GitHub Actions + +## Generated output layout + +Default output root: `target/mobench/` + +Common subtrees: +- `android/`: generated Android project and APK/test APK outputs +- `ios/`: generated iOS project, app, IPA, XCUITest bundle, and framework outputs +- `ci/`: standardized benchmark workflow outputs (`summary.json`, `summary.md`, `results.csv`, plots) +- `profile/`: run-scoped local profiling sessions and latest-run convenience copies + +Profile session layout: + +```text +target/mobench/profile// +├── profile.json +├── summary.md +└── artifacts/ + ├── raw/ + ├── processed/ + └── semantic/ +``` + +## Where to add new work + +- new CLI/report/profile behavior: `crates/mobench/src/` +- new SDK/runtime/build/codegen behavior: `crates/mobench-sdk/src/` +- benchmark registration semantics: `crates/mobench-macros/src/lib.rs` +- template/runtime UX changes: `templates/` first, then mirror into `crates/mobench-sdk/templates/` +- CI workflows or PR automation: `.github/workflows/` +- benchmark fixtures or local smoke helpers: `examples/`, `crates/sample-fns/`, `scripts/ci/` diff --git a/docs/codebase/TESTING.md b/docs/codebase/TESTING.md new file mode 100644 index 0000000..ff51cc0 --- /dev/null +++ b/docs/codebase/TESTING.md @@ -0,0 +1,86 @@ +# Testing + +Updated: 2026-03-27 + +## Host-side Rust tests + +Primary commands: + +```bash +cargo test --workspace +cargo test -p mobench profile_ -- --nocapture +cargo test -p mobench flamegraph_viewer -- --nocapture +cargo test -p mobench devices_ -- --nocapture +cargo test -p mobench-sdk -- --nocapture +``` + +Common coverage areas: +- timing/registry behavior +- build/config resolution +- BrowserStack device resolution and fetch parsing +- profile planning/execution semantics +- flamegraph viewer HTML generation +- template regeneration and binding refresh behavior + +## Fixture validation + +The repository keeps a lightweight fixture CI loop around `examples/ffi-benchmark`. + +Key paths: +- `scripts/ci/verify-example-plot-fixture.sh` +- `.github/workflows/mobile-bench.yml` +- `.github/workflows/mobile-bench-plot-fixtures.yml` + +Typical local validation: + +```bash +cargo build -p mobench --bins --locked +export PATH="$PWD/target/debug:$PATH" +scripts/ci/verify-example-plot-fixture.sh basic +scripts/ci/verify-example-plot-fixture.sh ffi +``` + +## Local profiling smoke tests + +These are intentionally separate from BrowserStack benchmark validation. + +Typical smoke commands: + +```bash +cargo run -p mobench --bin mobench -- profile run \ + --target android \ + --provider local \ + --backend android-native \ + --crate-path crates/sample-fns \ + --function sample_fns::fibonacci + +cargo run -p mobench --bin mobench -- profile run \ + --target ios \ + --provider local \ + --backend ios-instruments \ + --crate-path crates/sample-fns \ + --function sample_fns::fibonacci +``` + +Expected outputs: +- run-scoped `profile.json` +- `summary.md` +- raw and processed native artifacts +- `flamegraph.html` + +## Workflow-level testing + +Benchmark workflows: +- BrowserStack fixture benchmark workflow +- plot fixture workflow +- self-test / PR-dispatch workflow chain + +Profiling workflow: +- local native profiling self-test workflow, used to verify the flamegraph path without implying BrowserStack-native profiling support + +## Testing guidance + +- prefer exact, focused tests for CLI/help/error text changes +- do not fake successful profile captures; unsupported paths should fail explicitly +- when template behavior changes, verify both the generator and at least one real generated artifact path +- before merging, rerun the focused profile and flamegraph viewer suites even if the change was “docs only” when comments or doc strings touched command/help output diff --git a/docs/plans/2026-03-27-flamegraph-tower-collapse-design.md b/docs/plans/2026-03-27-flamegraph-tower-collapse-design.md index a26df96..4ea126f 100644 --- a/docs/plans/2026-03-27-flamegraph-tower-collapse-design.md +++ b/docs/plans/2026-03-27-flamegraph-tower-collapse-design.md @@ -1,5 +1,7 @@ # Flamegraph Tower Collapse Design +Status: not shipped. The thin-tower collapse experiment was rolled back after breaking the viewer UX, so this document is historical design context only. + ## Goal Make the flamegraph viewer readable by default when a profile contains tall, thin towers of low-self-time frames. The viewer should hide those towers initially, show compact `+` expand affordances at their x-ranges, and let the user zoom into them on demand. @@ -126,4 +128,3 @@ Add: - do not rewrite folded-stack artifacts on disk - do not remove access to raw full-process detail - do not rely on `inclusive time` as the primary collapse criterion - diff --git a/docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md b/docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md index 1d88e8f..7e91905 100644 --- a/docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md +++ b/docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md @@ -1,5 +1,7 @@ # Flamegraph Tower Collapse Implementation Plan +Status: not shipped. The tower-collapse implementation was rolled back; keep this file only as historical implementation context, not as the current viewer behavior. + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Make the flamegraph viewer hide tall thin low-self-time towers by default, show `+` expand affordances for those ranges, and let users zoom into the hidden range to inspect it. diff --git a/templates/android/.gitignore b/templates/android/.gitignore new file mode 100644 index 0000000..8154a1d --- /dev/null +++ b/templates/android/.gitignore @@ -0,0 +1,23 @@ +# Gradle +.gradle/ +build/ +local.properties + +# IDE +.idea/ +*.iml + +# Android +*.apk +*.aab +*.dex +*.class + +# Kotlin +*.kotlin_module + +# Native libraries (copied by mobench build) +app/src/main/jniLibs/ + +# Generated bindings (regenerated by mobench build) +app/src/main/java/uniffi/ diff --git a/templates/android/README.md b/templates/android/README.md index fbdf4d4..9556163 100644 --- a/templates/android/README.md +++ b/templates/android/README.md @@ -1,6 +1,6 @@ # {{PROJECT_NAME}} Android Benchmark App -This is an auto-generated Android app for running Rust benchmarks on real devices. +This is an auto-generated Android app for running Rust benchmarks and local native profiling captures. ## Building @@ -8,21 +8,34 @@ This is an auto-generated Android app for running Rust benchmarks on real device # Build debug APK ./gradlew assembleDebug -# Build release APK (recommended for BrowserStack - ~133MB vs ~544MB debug) +# Build release APK (recommended for BrowserStack uploads) ./gradlew assembleRelease ``` -## BrowserStack Testing +## Benchmark execution ```bash # Build with mobench (use --release for smaller APK uploads) cargo mobench build --target android --release +# Run locally +cargo mobench run --target android --function my_benchmark + # Run on BrowserStack cargo mobench run --target android --function my_benchmark \ --devices "Google Pixel 7-13.0" --release ``` +## Local native profiling + +```bash +cargo mobench profile run \ + --target android \ + --provider local \ + --backend android-native \ + --function my_benchmark +``` + ## Running Benchmarks The app reads benchmark configuration from: @@ -30,11 +43,13 @@ The app reads benchmark configuration from: 2. `assets/bench_spec.json` 3. Default values in code -## Benchmark Report Capture +## Benchmark report capture -The app captures benchmark results for BrowserStack: -- Displays results on screen for 5 seconds (video capture) +The app emits benchmark results for both local automation and BrowserStack: +- Keeps results on screen briefly so humans and automation can inspect them - Outputs JSON with `BENCH_JSON` marker to logcat +- Includes `phases` when the benchmark uses `profile_phase(...)` +- Is marked `profileable` so local native profilers can attach to it ## Generated by diff --git a/templates/android/app/build.gradle b/templates/android/app/build.gradle index 8296a5f..81b14c4 100644 --- a/templates/android/app/build.gradle +++ b/templates/android/app/build.gradle @@ -16,7 +16,7 @@ android { minSdk 24 targetSdk 34 versionCode 1 - versionName "0.1" + versionName "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) testBuildType "release" diff --git a/templates/android/app/proguard-rules.pro b/templates/android/app/proguard-rules.pro new file mode 100644 index 0000000..f0aea7c --- /dev/null +++ b/templates/android/app/proguard-rules.pro @@ -0,0 +1,22 @@ +# ProGuard rules for mobench Android benchmark app +# These rules ensure UniFFI and JNA work correctly when minification is enabled. + +# Keep JNA classes for UniFFI +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } + +# Keep UniFFI generated bindings +-keep class uniffi.** { *; } + +# Keep application benchmark classes +-keepclassmembers class * { + @uniffi.* ; +} + +# Keep native method names (required for JNI) +-keepclasseswithmembernames class * { + native ; +} + +# Keep Kotlin metadata for reflection +-keep class kotlin.Metadata { *; } diff --git a/templates/android/app/src/main/AndroidManifest.xml b/templates/android/app/src/main/AndroidManifest.xml index eda071a..24aa990 100644 --- a/templates/android/app/src/main/AndroidManifest.xml +++ b/templates/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ android:roundIcon="@android:drawable/ic_dialog_info" android:supportsRtl="true" android:theme="@style/Theme.{{PROJECT_NAME_PASCAL}}"> + diff --git a/templates/android/app/src/main/java/MainActivity.kt.template b/templates/android/app/src/main/java/MainActivity.kt.template index 9893c82..08905b6 100644 --- a/templates/android/app/src/main/java/MainActivity.kt.template +++ b/templates/android/app/src/main/java/MainActivity.kt.template @@ -53,20 +53,20 @@ class MainActivity : AppCompatActivity() { } logBenchReport(report) formatBenchReport(report) - } catch (e: BenchException.InvalidIterations) { - "Error: ${e.message}" - } catch (e: BenchException.UnknownFunction) { - "Error: ${e.message}" - } catch (e: BenchException.ExecutionFailed) { - "Error: ${e.message}" + } catch (e: BenchException) { + // Generic handler for all benchmark errors (InvalidIterations, UnknownFunction, etc.) + android.util.Log.e("BenchRunner", "Benchmark error: ${e.message}", e) + "Benchmark error: ${e.message}" } catch (e: Exception) { + android.util.Log.e("BenchRunner", "Unexpected error during benchmark execution", e) "Unexpected error: ${e.message}" } findViewById(R.id.result_text)?.text = display - // Keep the report on screen for at least 5 seconds so BrowserStack video captures it - android.util.Log.i("BenchRunner", "Displaying results for 5 seconds for video capture...") + // Keep the report visible briefly so local smoke runs and remote automation + // have a stable window to read the results. + android.util.Log.i("BenchRunner", "Displaying results for 5 seconds for capture output...") Thread.sleep(5000) android.util.Log.i("BenchRunner", "Display hold complete") } @@ -123,6 +123,15 @@ class MainActivity : AppCompatActivity() { samples.forEach { sampleArray.put(it) } json.put("samples_ns", sampleArray) + val phases = JSONArray() + report.phases.forEach { phase -> + val phaseJson = JSONObject() + phaseJson.put("name", phase.name) + phaseJson.put("duration_ns", phase.durationNs.toLong()) + phases.put(phaseJson) + } + json.put("phases", phases) + if (samples.isNotEmpty()) { val min = samples.minOrNull() ?: 0L val max = samples.maxOrNull() ?: 0L @@ -150,18 +159,45 @@ class MainActivity : AppCompatActivity() { } private fun resolveBenchParams(): BenchParams { - val defaults = loadBenchParamsFromAssets() ?: BenchParams( + val assetParams = loadBenchParamsFromAssets() + val defaults = assetParams ?: BenchParams( DEFAULT_FUNCTION, DEFAULT_ITERATIONS, DEFAULT_WARMUP ) - val fn = intent?.getStringExtra(FUNCTION_EXTRA) - ?.takeUnless { it.isBlank() } - ?: defaults.function - val iterations = intent?.getIntExtra(ITERATIONS_EXTRA, defaults.iterations.toInt())?.toUInt() - ?: defaults.iterations - val warmup = intent?.getIntExtra(WARMUP_EXTRA, defaults.warmup.toInt())?.toUInt() - ?: defaults.warmup + + // Check for intent extras used by local automation, smoke tests, and provider-driven runs. + val intentFunction = intent?.getStringExtra(FUNCTION_EXTRA)?.takeUnless { it.isBlank() } + val intentIterations = intent?.let { + val value = it.getIntExtra(ITERATIONS_EXTRA, -1) + if (value >= 0) value.toUInt() else null + } + val intentWarmup = intent?.let { + val value = it.getIntExtra(WARMUP_EXTRA, -1) + if (value >= 0) value.toUInt() else null + } + + // Resolve final values with logging + val fn = intentFunction ?: defaults.function + val iterations = intentIterations ?: defaults.iterations + val warmup = intentWarmup ?: defaults.warmup + + // Log the resolution source for debugging + if (assetParams == null && intentFunction == null && intentIterations == null && intentWarmup == null) { + android.util.Log.i("BenchRunner", "Using hardcoded defaults: function=$fn, iterations=$iterations, warmup=$warmup") + } else { + val sources = mutableListOf() + if (intentFunction != null) sources.add("function from intent") + if (intentIterations != null) sources.add("iterations from intent") + if (intentWarmup != null) sources.add("warmup from intent") + if (assetParams != null) { + if (intentFunction == null) sources.add("function from bench_spec.json") + if (intentIterations == null) sources.add("iterations from bench_spec.json") + if (intentWarmup == null) sources.add("warmup from bench_spec.json") + } + android.util.Log.i("BenchRunner", "Resolved params: function=$fn, iterations=$iterations, warmup=$warmup (sources: ${sources.joinToString(", ")})") + } + return BenchParams(fn, iterations, warmup) } @@ -169,16 +205,51 @@ class MainActivity : AppCompatActivity() { return try { val raw = assets.open(SPEC_ASSET).bufferedReader().use { it.readText() } if (raw.isBlank()) { + android.util.Log.w("BenchRunner", "bench_spec.json exists but is empty, using defaults") null } else { val json = JSONObject(raw) - BenchParams( - json.optString("function", DEFAULT_FUNCTION), - json.optInt("iterations", DEFAULT_ITERATIONS.toInt()).toUInt(), - json.optInt("warmup", DEFAULT_WARMUP.toInt()).toUInt(), - ) + + // Log warnings for missing or invalid config values + val function = if (json.has("function")) { + json.getString("function") + } else { + android.util.Log.w("BenchRunner", "Config missing 'function' key, using default: $DEFAULT_FUNCTION") + DEFAULT_FUNCTION + } + + val iterations = if (json.has("iterations")) { + try { + json.getInt("iterations").toUInt() + } catch (e: Exception) { + android.util.Log.w("BenchRunner", "Config 'iterations' is not a valid integer: ${json.opt("iterations")}, using default: $DEFAULT_ITERATIONS") + DEFAULT_ITERATIONS + } + } else { + android.util.Log.w("BenchRunner", "Config missing 'iterations' key, using default: $DEFAULT_ITERATIONS") + DEFAULT_ITERATIONS + } + + val warmup = if (json.has("warmup")) { + try { + json.getInt("warmup").toUInt() + } catch (e: Exception) { + android.util.Log.w("BenchRunner", "Config 'warmup' is not a valid integer: ${json.opt("warmup")}, using default: $DEFAULT_WARMUP") + DEFAULT_WARMUP + } + } else { + android.util.Log.w("BenchRunner", "Config missing 'warmup' key, using default: $DEFAULT_WARMUP") + DEFAULT_WARMUP + } + + android.util.Log.i("BenchRunner", "Loaded config from bench_spec.json: function=$function, iterations=$iterations, warmup=$warmup") + BenchParams(function, iterations, warmup) } - } catch (_: Exception) { + } catch (e: java.io.FileNotFoundException) { + android.util.Log.d("BenchRunner", "No bench_spec.json in assets, will use intent extras or defaults") + null + } catch (e: Exception) { + android.util.Log.e("BenchRunner", "Failed to parse bench_spec.json from assets", e) null } } diff --git a/templates/android/app/src/main/res/values/themes.xml b/templates/android/app/src/main/res/values/themes.xml index fb5b86a..1cf1e00 100644 --- a/templates/android/app/src/main/res/values/themes.xml +++ b/templates/android/app/src/main/res/values/themes.xml @@ -1,5 +1,5 @@ - diff --git a/templates/android/build.gradle b/templates/android/build.gradle index c3e6c75..571ffeb 100644 --- a/templates/android/build.gradle +++ b/templates/android/build.gradle @@ -4,7 +4,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.13.2' + classpath 'com.android.tools.build:gradle:8.2.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22" } } diff --git a/templates/android/gradle.properties b/templates/android/gradle.properties new file mode 100644 index 0000000..b665e1a --- /dev/null +++ b/templates/android/gradle.properties @@ -0,0 +1,7 @@ +android.useAndroidX=true +android.enableJetifier=true +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +kotlin.code.style=official diff --git a/templates/ios/BenchRunner/.gitignore b/templates/ios/BenchRunner/.gitignore new file mode 100644 index 0000000..b4f2cba --- /dev/null +++ b/templates/ios/BenchRunner/.gitignore @@ -0,0 +1,27 @@ +# Xcode +build/ +DerivedData/ +*.xcworkspace +xcuserdata/ +*.xcuserstate +*.xcodeproj/ + +# CocoaPods (if used) +Pods/ + +# Generated +*.ipa +*.dSYM.zip +*.dSYM + +# IDE +.idea/ +*.swp + +# Native libraries and frameworks (copied/built by mobench) +*.xcframework/ +*.framework/ +*.a + +# Generated bindings (regenerated by mobench build) +BenchRunner/Generated/ diff --git a/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index 8ab114d..cbd2bed 100644 --- a/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -146,7 +146,8 @@ enum {{PROJECT_NAME_PASCAL}}FFI { } } - /// Generates a JSON report matching the Android BENCH_JSON format for consistency + /// Generates a JSON report that matches the Android BENCH_JSON-style payload + /// consumed by mobench so cross-platform parsers can stay aligned. private static func generateJSONReport(_ report: BenchReport) -> String { var json: [String: Any] = [:] @@ -169,6 +170,14 @@ enum {{PROJECT_NAME_PASCAL}}FFI { let samplesArray = report.samples.map { ["duration_ns": $0.durationNs] } json["samples"] = samplesArray + let phases = report.phases.map { phase in + [ + "name": phase.name, + "duration_ns": phase.durationNs + ] as [String: Any] + } + json["phases"] = phases + // Statistics if !report.samples.isEmpty { let durations = report.samples.map { $0.durationNs } diff --git a/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template b/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template index d288952..cbdb8de 100644 --- a/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template +++ b/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template @@ -1,7 +1,61 @@ import SwiftUI +private struct ProfileLaunchOptions { + let benchDelayMs: UInt64 + let resultHoldMs: UInt64 + let repeatUntilMs: UInt64 + let warmupOnly: Bool + + static func resolved() -> ProfileLaunchOptions { + let info = ProcessInfo.processInfo + + var benchDelayMs = UInt64(info.environment["MOBENCH_BENCH_DELAY_MS"] ?? "0") ?? 0 + var resultHoldMs = UInt64( + info.environment["MOBENCH_PROFILE_RESULT_HOLD_MS"] ?? "5000" + ) ?? 5000 + var repeatUntilMs = UInt64( + info.environment["MOBENCH_PROFILE_REPEAT_UNTIL_MS"] ?? "0" + ) ?? 0 + var warmupOnly = info.environment["MOBENCH_PROFILE_WARMUP_ONLY"] == "1" + + for arg in info.arguments { + if arg.hasPrefix("--mobench-profile-bench-delay-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + benchDelayMs = parsed + } else if arg.hasPrefix("--mobench-profile-result-hold-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + resultHoldMs = parsed + } else if arg.hasPrefix("--mobench-profile-repeat-until-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + repeatUntilMs = parsed + } else if arg == "--mobench-profile-warmup-only" + || arg == "--mobench-profile-warmup-only=1" { + warmupOnly = true + } + } + + NSLog( + "[BenchRunner] Profile launch options: delayMs=%llu, repeatUntilMs=%llu, resultHoldMs=%llu, warmupOnly=%@", + benchDelayMs, + repeatUntilMs, + resultHoldMs, + warmupOnly ? "true" : "false" + ) + + return ProfileLaunchOptions( + benchDelayMs: benchDelayMs, + resultHoldMs: resultHoldMs, + repeatUntilMs: repeatUntilMs, + warmupOnly: warmupOnly + ) + } +} + struct ContentView: View { - @State private var report: String = "Running benchmark..." + @State private var report: String = "Running benchmarks..." @State private var reportJSON: String = "" @State private var isCompleted: Bool = false @@ -36,19 +90,39 @@ struct ContentView: View { } .onAppear { Task { - let result = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + let options = ProfileLaunchOptions.resolved() + if options.benchDelayMs > 0 { + try? await Task.sleep(nanoseconds: options.benchDelayMs * 1_000_000) + } + let repeatDeadline = Date().addingTimeInterval( + Double(options.repeatUntilMs) / 1_000.0 + ) + var repeatedRuns = 1 + var result = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + while !options.warmupOnly && options.repeatUntilMs > 0 && Date() < repeatDeadline { + result = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + repeatedRuns += 1 + } report = result.displayText reportJSON = result.jsonReport isCompleted = true - // Log the JSON report with markers for BrowserStack device logs + // Log the JSON report with stable markers so local profiling tools, + // XCUITest, and BrowserStack fetch paths can all recover the payload. NSLog("BENCH_REPORT_JSON_START") NSLog("%@", result.jsonReport) NSLog("BENCH_REPORT_JSON_END") + if repeatedRuns > 1 { + NSLog("Repeated benchmark %d time(s) during profile capture", repeatedRuns) + } + + if options.warmupOnly { + NSLog("Warmup-only profile run complete") + return + } - // Keep the report on screen for at least 5 seconds so BrowserStack video captures it - NSLog("Displaying results for 5 seconds for video capture...") - try? await Task.sleep(nanoseconds: 5_000_000_000) + NSLog("Displaying results for \(options.resultHoldMs) ms for capture output...") + try? await Task.sleep(nanoseconds: options.resultHoldMs * 1_000_000) NSLog("Display hold complete") } } diff --git a/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template b/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template index 70f7cb7..fc0e8df 100644 --- a/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template +++ b/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template @@ -15,8 +15,9 @@ final class {{PROJECT_NAME_PASCAL}}UITests: XCTestCase { let completed = completedIndicator.waitForExistence(timeout: benchmarkTimeout) XCTAssertTrue(completed, "Benchmark should complete within \(benchmarkTimeout) seconds") - // Give UI a moment to update with final results - Thread.sleep(forTimeInterval: 0.5) + // Wait 5 seconds so BrowserStack video captures the results + // This delay is critical for video evidence of benchmark completion + Thread.sleep(forTimeInterval: 5.0) // Extract the benchmark report JSON from the hidden element let reportElement = app.staticTexts["benchmarkReportJSON"] diff --git a/templates/ios/BenchRunner/BenchRunnerUITests/Info.plist.template b/templates/ios/BenchRunner/BenchRunnerUITests/Info.plist.template new file mode 100644 index 0000000..fb402aa --- /dev/null +++ b/templates/ios/BenchRunner/BenchRunnerUITests/Info.plist.template @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/templates/ios/BenchRunner/README.md b/templates/ios/BenchRunner/README.md index b0cefc1..5fa0193 100644 --- a/templates/ios/BenchRunner/README.md +++ b/templates/ios/BenchRunner/README.md @@ -1,6 +1,6 @@ # {{PROJECT_NAME_PASCAL}} iOS Benchmark App -This is an auto-generated iOS app for running Rust benchmarks on real devices. +This is an auto-generated iOS app for running Rust benchmarks and local native profiling captures. ## Building @@ -15,9 +15,9 @@ xcodebuild -scheme BenchRunner -destination 'platform=iOS Simulator,name=iPhone xcodebuild -scheme BenchRunner -destination 'generic/platform=iOS' build ``` -## BrowserStack Testing +## Benchmark execution -For BrowserStack testing, you need both the app IPA and the XCUITest runner: +For BrowserStack benchmarking, you need both the app IPA and the XCUITest runner: ```bash # Package the app as IPA @@ -31,6 +31,22 @@ cargo mobench run --target ios --function my_benchmark \ --devices "iPhone 14-16" --release ``` +For local simulator execution: + +```bash +cargo mobench run --target ios --function my_benchmark +``` + +## Local native profiling + +```bash +cargo mobench profile run \ + --target ios \ + --provider local \ + --backend ios-instruments \ + --function my_benchmark +``` + ## Running Benchmarks The app reads benchmark configuration from: @@ -39,12 +55,13 @@ The app reads benchmark configuration from: 3. `bench_spec.json` in bundle 4. Default values in code -## Benchmark Report Capture +## Benchmark report capture -The app captures benchmark results for BrowserStack: -- Displays results on screen for 5 seconds (video capture) +The app emits benchmark results for both local automation and BrowserStack: +- Displays results long enough for capture/inspection - Outputs JSON with `BENCH_REPORT_JSON_START/END` markers - XCUITest extracts results via accessibility identifiers +- Supports profiling launch options that can delay, warm, or repeat the benchmark to fill a local native capture window ## Generated by diff --git a/templates/ios/BenchRunner/project.yml b/templates/ios/BenchRunner/project.yml.template similarity index 77% rename from templates/ios/BenchRunner/project.yml rename to templates/ios/BenchRunner/project.yml.template index 11f416b..cd8278b 100644 --- a/templates/ios/BenchRunner/project.yml +++ b/templates/ios/BenchRunner/project.yml.template @@ -18,13 +18,16 @@ targets: optional: true info: path: {{PROJECT_NAME_PASCAL}}/Info.plist + properties: + CFBundleShortVersionString: "1.0.0" + CFBundleVersion: "1" settings: base: PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID}} SWIFT_OBJC_BRIDGING_HEADER: {{PROJECT_NAME_PASCAL}}/{{PROJECT_NAME_PASCAL}}-Bridging-Header.h HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/{{PROJECT_NAME_PASCAL}}/Generated" dependencies: - - framework: ../../target/ios/{{LIBRARY_NAME}}.xcframework + - framework: ../{{LIBRARY_NAME}}.xcframework embed: true link: true {{PROJECT_NAME_PASCAL}}UITests: @@ -33,5 +36,10 @@ targets: deploymentTarget: "15.0" sources: - path: {{PROJECT_NAME_PASCAL}}UITests + info: + path: {{PROJECT_NAME_PASCAL}}UITests/Info.plist + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID}}.uitests dependencies: - target: {{PROJECT_NAME_PASCAL}} From cd33b1fe224dfaa2162730233e1137920166c69d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 21:49:33 -0700 Subject: [PATCH 152/196] docs: remove tracked planning artifacts --- docs/codebase/CONVENTIONS.md | 1 - .../2026-03-25-sina-device-plots-design.md | 250 --------- .../2026-03-26-eng-25-profiling-design.md | 272 --------- ...6-03-26-eng-25-profiling-implementation.md | 524 ------------------ .../2026-03-26-flamegraph-viewer-design.md | 175 ------ .../2026-03-26-profiling-upgrades-design.md | 294 ---------- ...-03-27-flamegraph-tower-collapse-design.md | 130 ----- ...lamegraph-tower-collapse-implementation.md | 282 ---------- 8 files changed, 1928 deletions(-) delete mode 100644 docs/plans/2026-03-25-sina-device-plots-design.md delete mode 100644 docs/plans/2026-03-26-eng-25-profiling-design.md delete mode 100644 docs/plans/2026-03-26-eng-25-profiling-implementation.md delete mode 100644 docs/plans/2026-03-26-flamegraph-viewer-design.md delete mode 100644 docs/plans/2026-03-26-profiling-upgrades-design.md delete mode 100644 docs/plans/2026-03-27-flamegraph-tower-collapse-design.md delete mode 100644 docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md diff --git a/docs/codebase/CONVENTIONS.md b/docs/codebase/CONVENTIONS.md index 5b0bcc3..840131e 100644 --- a/docs/codebase/CONVENTIONS.md +++ b/docs/codebase/CONVENTIONS.md @@ -43,7 +43,6 @@ Device resolution semantics should reuse the same surface area instead of invent - public Rust items should use `//!` or `///` comments when they define user-facing behavior - comments should explain why a branch/tooling workaround exists, not restate obvious code - README and template docs should describe current shipped behavior, not superseded MVP constraints -- design docs in `docs/plans/` should be marked clearly when a design was abandoned or rolled back ## Template editing diff --git a/docs/plans/2026-03-25-sina-device-plots-design.md b/docs/plans/2026-03-25-sina-device-plots-design.md deleted file mode 100644 index 7c40035..0000000 --- a/docs/plans/2026-03-25-sina-device-plots-design.md +++ /dev/null @@ -1,250 +0,0 @@ -# Sina-Style Device Comparison Plots - -## Summary - -Add per-function device comparison plots to the final Mobench summary flow. -The Rust CLI remains the user-facing entry point, but delegates plot rendering -to a separate Python tool. Each plot compares devices for a single benchmark -function by showing one dot per iteration, with runtime on the y-axis and -device on the x-axis. - -The result should increase information density without weakening the existing -CI output contract. `summary.json` remains stable. Plots are additive report -artifacts embedded into `summary.md`. - -## Goals - -- Keep the primary workflow inside the existing CLI/report path. -- Render one plot per benchmark function in the final summary section. -- Compare devices on a single chart with one point per raw iteration. -- Use deterministic point placement instead of random jitter or KDE sampling. -- Produce sleek, publication-friendly SVG output. -- Preserve the current `summary.json` contract. - -## Non-Goals - -- Replacing the current table and markdown summaries. -- Introducing a hard dependency on marimo notebooks. -- Changing the CI v1 required output contract. -- Requiring plots to exist when only aggregate statistics are available. - -## User Experience - -The common flow stays the same: - -```bash -cargo mobench ci run ... -cargo mobench report summarize --summary target/mobench/ci/summary.json -``` - -During the final summarize/report step, Mobench attempts to generate plots for -each benchmark function that has raw per-iteration data across devices. The -report embeds those plots in the same section as the summary table. - -Plot generation mode should be configurable: - -- `auto`: generate plots when the Python renderer and raw samples are available -- `off`: skip plotting entirely -- `require`: fail the summarize/report step if plots cannot be generated - -`auto` should be the default. - -## Architecture - -### Rust CLI - -Rust remains responsible for: - -- locating and loading result artifacts -- understanding Mobench summary/result layouts -- normalizing plot input per function and device -- invoking the Python renderer -- embedding generated plot paths into the markdown report - -Rust should not implement the visual rendering logic directly. - -### Python Renderer - -The Python renderer is a standalone script checked into the repository and -called by the CLI. It should: - -- read a normalized plot payload from JSON -- render one SVG figure for one benchmark function -- implement deterministic horizontal point packing -- apply a custom Matplotlib style tuned for clean static output - -This keeps the Rust side focused on artifact orchestration and keeps the -plotting side easy to iterate on visually. - -## Artifacts - -Required contract outputs remain unchanged: - -- `summary.json` -- `summary.md` -- `results.csv` - -Additive plot artifacts should live under: - -```text -target/mobench/ci/plots/ -``` - -Example outputs: - -```text -target/mobench/ci/plots/nullifier-proof-generation.svg -target/mobench/ci/plots/query-proof-generation.svg -target/mobench/ci/plots/manifest.json -``` - -`manifest.json` is optional but recommended as an implementation detail. Rust -can emit a slim plotting payload to avoid duplicating Mobench artifact parsing -logic in Python. - -## Data Flow - -1. `ci run` produces the usual result artifacts. -2. The final summarize/report step loads the summary and richer raw sample - sources already produced by earlier steps. -3. Rust groups raw samples by function and device. -4. Rust writes one normalized plot payload per function, or one shared manifest - with one entry per function. -5. Rust invokes the Python renderer once per function. -6. The renderer writes SVGs into `plots/`. -7. The markdown summary embeds the SVGs in the corresponding function section. - -The plotting feature depends on richer `benchmark_results` data when available. -If only aggregate stats exist, the function remains table-only. - -## Plot Layout - -Each figure represents a single benchmark function. - -- x-axis: device -- y-axis: runtime in milliseconds -- one dot: one iteration sample - -Each device gets a vertical strip of samples centered on the device position. -To avoid overlap, dots are packed horizontally around the device centerline. - -The report should render separate plots for multiple functions, all inside the -same summary section. - -## Point Placement Algorithm - -Use a deterministic local packing algorithm rather than KDE-based Sina jitter. - -For each device: - -1. Sort samples by y value. -2. Convert each sample to plot coordinates. -3. For the next dot, search horizontal offsets in symmetric order around zero. -4. Choose the smallest `delta_x` that keeps the new dot center at least - `epsilon` away from all previously placed dots in the same device strip. -5. Clamp the maximum allowed width so one dense device does not dominate the - figure layout. - -Where: - -- `epsilon` = dot diameter + visual gap -- search order is symmetric, for example `0, +d, -d, +2d, -2d, ...` - -This approach is: - -- deterministic -- simpler than KDE-based placement -- faithful to the raw samples -- visually centered and compact - -The implementation is best described as a Sina/beeswarm-style plot with -deterministic collision packing. - -## Visual Style - -The styling should aim for a clean, polished static look without requiring -marimo itself. - -Important note: marimo does not appear to use a special Matplotlib beautifier. -Its Matplotlib integration mainly provides a rendering backend and applies -Matplotlib's built-in `dark_background` style in dark mode. We should not add a -marimo dependency for this feature. - -Instead, ship a custom Matplotlib style layer for the renderer: - -- SVG output by default -- light theme by default -- restrained typography and spacing -- thin horizontal gridlines -- compact margins -- muted per-device colors or a single neutral dot color -- subtle median marker per device -- humanized benchmark titles -- consistent figure sizing across plots - -The style should live in a small dedicated module or `.mplstyle` file so it is -usable outside a notebook environment. - -## Markdown Embedding - -The markdown summary should keep the existing table, then add the plot directly -below it when present. - -Preferred embedding uses relative links so artifacts survive CI upload and -download together: - -```md -![nullifier-proof-generation](plots/nullifier-proof-generation.svg) -``` - -If a function has no plot, no placeholder is needed. - -## Failure Handling - -- If raw samples are missing for a function, skip that plot. -- If some devices for a function have raw samples and others do not, render the - plot with the complete devices only and emit a warning. -- In `auto` mode, missing Python or renderer failures should not fail the - overall report. -- In `require` mode, plot generation failures should fail the step. -- Output should be deterministic for stable CI diffs. - -## Testing Strategy - -### Rust - -- unit tests for extracting raw per-device per-function samples from current - result layouts -- tests for manifest generation -- tests for markdown embedding and relative path handling -- fixture-based tests covering mixed availability of raw and aggregate data - -### Python - -- unit tests for the collision-packing algorithm -- invariant tests that verify minimum distance constraints -- determinism tests using fixed input data -- smoke tests that generate SVG output for representative inputs - -### End-to-End - -- one fixture-driven summarize/report test that confirms plots are emitted and - linked into markdown when raw sample data is available - -## Rollout Notes - -- Make plots additive and optional first. -- Keep the current text/table report fully useful without plots. -- Prefer a minimal renderer dependency surface, ideally `matplotlib` and - standard library only unless a narrow extra package materially improves the - result. - -## Accepted Decisions - -- Rust CLI entry point, Python renderer underneath -- one plot per benchmark function -- separate plots embedded in the final summary section -- compare devices on one graph -- point-per-iteration visualization -- deterministic collision-based horizontal packing -- custom Matplotlib styling rather than marimo notebook dependency diff --git a/docs/plans/2026-03-26-eng-25-profiling-design.md b/docs/plans/2026-03-26-eng-25-profiling-design.md deleted file mode 100644 index 3e88e83..0000000 --- a/docs/plans/2026-03-26-eng-25-profiling-design.md +++ /dev/null @@ -1,272 +0,0 @@ -# Mobench Profiling Feature Set Design - -## Summary - -Add a new profiling subsystem to Mobench that complements timing benchmarks -with native profile artifacts and flamegraph-capable visualizations. The CLI -should remain the user-facing orchestrator and should reuse the same benchmark -selection, build, packaging, device resolution, and output conventions that -existing `run` and `ci run` commands already use. - -The design standardizes orchestration and artifact layout, not a fake common -raw profile format. Android and iOS should keep their native capture formats, -while Mobench provides a shared command surface, normalized metadata, and -reporting helpers. BrowserStack stays in scope as an execution environment, but -remote native sampling capture is not an MVP dependency. - -## Goals - -- Keep the primary workflow inside the existing `mobench` CLI. -- Add a dedicated profiling command family that feels parallel to `run` and - `report summarize`. -- Produce native raw profile artifacts plus normalized metadata for downstream - viewing and automation. -- Support local-first native profiling on Android and iOS with release-like - builds and explicit symbol handling. -- Make flamegraph-capable output first-class on at least the Android path. -- Preserve compatibility with the current benchmark/report contract. - -## Non-Goals - -- Replacing the existing timing benchmark flow. -- Inventing a single cross-platform raw profile format. -- Depending on undocumented BrowserStack native-profiler APIs for MVP success. -- Treating app-level instrumentation as a substitute for native stack sampling. -- Changing the required v1 CI outputs in `summary.json`, `summary.md`, or - `results.csv`. - -## User Experience - -The new workflow should add a dedicated profile command family: - -```bash -cargo mobench profile run --target android --function sample_fns::fibonacci -cargo mobench profile summarize --profile target/mobench/profile/profile.json -cargo mobench profile summarize --profile target/mobench/profile//profile.json -``` - -`profile run` should mirror existing benchmark selectors and project-resolution -flags: - -- `--target` -- `--function` -- `--iterations` -- `--warmup` -- `--config` -- `--crate-path` -- `--project-root` -- `--output-dir` -- device/provider flags already used by benchmark runs - -Profiling-specific controls should be additive: - -- `--backend auto|android-native|ios-instruments|rust-tracing` -- `--format native|processed|both` -- `--time-limit` -- `--symbolize auto|off|require` - -The command must be local-first. Developers should be able to run local Android -and local iOS profiling through Mobench, then continue in Android Studio, -Firefox Profiler, Xcode, or Instruments using artifacts that Mobench wrote. - -BrowserStack should remain a supported execution target conceptually, but the -MVP must not assume that remote native sampling capture is available. If that -path is unsupported, Mobench should fail explicitly and explain why. - -## Architecture - -### Orchestration Layer - -Mobench should own: - -- target and benchmark resolution -- build/profile mode selection -- device/provider selection -- output directory and artifact naming -- symbol discovery and policy handling -- backend selection and subprocess invocation - -This keeps profiling aligned with the existing CLI instead of becoming a set of -ad hoc shell recipes. - -### Platform Backend Layer - -Each platform backend should expose a shared interface to the CLI: - -- `prepare()` -- `capture()` -- `process()` -- `summarize()` - -Android and iOS should share orchestration semantics while keeping native tool -choices: - -- Android: native sampling capture, then optional import/conversion into a - flamegraph-capable viewer format. -- iOS: `xctrace` / Instruments capture with `.trace` bundles and exported - summaries. - -### Artifact And Report Layer - -Raw artifacts should stay native. Mobench should normalize only the metadata -around them: - -- what benchmark ran -- on which target/device/provider -- which backend executed -- where raw and processed artifacts live -- symbol status -- viewer hints -- partial-failure state - -This lets Mobench summarize captures without pretending `.trace` bundles and -Android sample captures are interchangeable. - -## Command Surface - -The MVP command family should be: - -- `cargo mobench profile run` -- `cargo mobench profile summarize` - -`profile run` creates a profile session and writes artifacts. `profile summarize` -reads the normalized metadata file and renders terminal or markdown output -without re-running the benchmark. - -Future commands can remain additive: - -- `cargo mobench profile open` -- `cargo mobench profile export` -- `cargo mobench profile import` - -## Artifacts - -Profiling should not modify the existing CI v1 contract. Instead it should add a -parallel additive artifact tree: - -```text -target/mobench/profile/ - profile.json # latest session convenience copy - summary.md # latest session convenience copy - / - profile.json - summary.md - artifacts/ - raw/ - processed/ -``` - -`profile.json` is the normalized metadata file. It should record: - -- benchmark identity and run parameters -- target/platform/device/provider -- backend, capture mode, and requested output format -- raw artifact paths -- processed artifact paths -- symbolization state -- capture timestamps and durations -- warnings and partial failures -- viewer hints and recommended next actions - -The raw artifacts remain platform-specific: - -- Android: native sampling capture plus processed flamegraph-capable output when - available -- iOS: `.trace` bundles and optional exported XML summaries -- optional Rust tracing output if a future fallback backend is added - -## Backend Behavior - -### Android - -Android should be the first flamegraph-capable path in the MVP. Mobench should -handle: - -- building release-like artifacts with symbol retention -- launching the benchmarked app on a local Android target -- coordinating native sampling capture -- emitting raw and processed artifacts - -The preferred viewer can differ from iOS as long as the processed output is -clearly documented and actionable. - -### iOS - -iOS should use Instruments via `xctrace`. Mobench should: - -- build release-like artifacts with dSYMs -- launch or attach to the benchmarked app on a local iOS target -- record a `Time Profiler` trace -- emit `.trace` artifacts plus exported metadata - -The first-class viewer is Instruments/Xcode, not Firefox Profiler. - -### BrowserStack - -BrowserStack remains in scope as a run environment, but the MVP should treat -native remote profile capture as optional. The benchmark may run on a real phone -there, but the profiling backend must fail explicitly if the provider cannot -support native capture. That keeps BrowserStack integration honest instead of -relying on undocumented capabilities. - -## Symbolization - -Profile runs are only useful if Mobench can report symbol quality clearly. - -Rules: - -- Android must preserve or locate unstripped native symbols. -- iOS must preserve or locate dSYMs. -- `--symbolize require` should fail the command when symbolization prerequisites - are missing. -- `--symbolize auto` may continue but must record warnings and degraded status - in `profile.json`. - -Unsymbolized captures are operationally failures even when raw files exist. - -## Error Handling - -The profiling subsystem should distinguish: - -- benchmark execution failure -- capture startup failure -- symbol discovery or symbolization failure -- artifact conversion/export failure -- unsupported backend or provider capability -- viewer/tool availability failure - -Partial success must be expressible in `profile.json`. For example, a benchmark -may run and produce a raw trace while processed exports fail. - -## Testing Strategy - -### Unit Tests - -- CLI parser coverage for `profile` commands and options -- artifact-path and manifest-serialization tests -- backend selection and capability gating tests -- command-construction tests for external tools - -### Fixture Tests - -- `profile.json` rendering and summary formatting -- partial-failure serialization -- symbol-warning propagation - -### Smoke Tests - -- opt-in local Android smoke tests -- opt-in local iOS smoke tests -- no assumption that CI can run full native profilers on both platforms - -## References - -- [docs/CONTRACT_CI_V1.md](/Users/dcbuilder/.config/superpowers/worktrees/mobile-bench-rs/codex-eng-25-profiling/docs/CONTRACT_CI_V1.md) -- [samply README](https://github.com/mstange/samply) -- [Firefox profiling with simpleperf](https://firefox-source-docs.mozilla.org/performance/profiling_with_simpleperf.html) -- [Android Studio profiling docs](https://developer.android.com/studio/profile) -- [Xcode performance and metrics](https://developer.apple.com/documentation/xcode/performance-and-metrics) - -Local verification on this machine also confirmed that `xcrun xctrace` exposes -`record`, `export`, and `import` commands, including the `Time Profiler` -template, which makes a CLI-orchestrated iOS path viable on Xcode hosts. diff --git a/docs/plans/2026-03-26-eng-25-profiling-implementation.md b/docs/plans/2026-03-26-eng-25-profiling-implementation.md deleted file mode 100644 index df1abb7..0000000 --- a/docs/plans/2026-03-26-eng-25-profiling-implementation.md +++ /dev/null @@ -1,524 +0,0 @@ -# ENG-25 Profiling Feature Set Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add a new `mobench profile` subsystem that standardizes profiling orchestration and artifacts across platforms, provides native-tool backends for Android and iOS local runs, and reports explicit capability limits for BrowserStack-backed execution. - -**Architecture:** Keep the `mobench` CLI as the orchestrator. Introduce a dedicated `profile` Rust module tree for command parsing, artifact contracts, backend dispatch, and summary rendering. Android and iOS backends build native command lines for external tools, write run-scoped `target/mobench/profile//profile.json` manifests plus top-level latest-session convenience files, and preserve raw platform-specific artifacts without changing the existing CI v1 benchmark contract. - -**Tech Stack:** Rust (`clap`, `serde`, `serde_json`, `anyhow`, `std::process`, `std::fs`), existing `mobench-sdk` builders, external platform tools (`adb`, `simpleperf`, `xcrun`, `xctrace`), existing summary/report patterns under `crates/mobench/src`. - ---- - -### Task 1: Add Profile CLI Parsing And Contract Types - -**Files:** -- Create: `crates/mobench/src/profile.rs` -- Modify: `crates/mobench/src/lib.rs` -- Test: `crates/mobench/src/profile.rs` - -**Step 1: Write the failing test** - -Add parser-focused tests in `crates/mobench/src/profile.rs` that assert the new -subcommand shape parses cleanly: - -```rust -#[test] -fn profile_run_parses_with_android_backend() { - let cli = Cli::try_parse_from([ - "mobench", - "profile", - "run", - "--target", - "android", - "--function", - "sample_fns::fibonacci", - "--backend", - "android-native", - ]) - .expect("parse profile run"); - - assert!(matches!(cli.command, Command::Profile { .. })); -} - -#[test] -fn profile_summarize_parses_with_default_profile_path() { - let cli = Cli::try_parse_from(["mobench", "profile", "summarize"]) - .expect("parse profile summarize"); - - assert!(matches!(cli.command, Command::Profile { .. })); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cargo test -p mobench profile_run_parses_with_android_backend -- --exact` - -Expected: FAIL because `Command::Profile` and the profile argument types do not -exist yet. - -**Step 3: Write minimal implementation** - -Create `crates/mobench/src/profile.rs` with the shared enums and manifest types: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] -pub enum ProfileBackend { - Auto, - AndroidNative, - IosInstruments, - RustTracing, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] -pub enum ProfileFormat { - Native, - Processed, - Both, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProfileManifest { - pub run_id: String, - pub target: String, - pub function: String, - pub backend: ProfileBackend, - pub raw_artifacts: Vec, - pub processed_artifacts: Vec, - pub warnings: Vec, -} -``` - -Wire the module into `crates/mobench/src/lib.rs`: - -```rust -mod profile; -``` - -Then add `Command::Profile` plus `ProfileCommand::{Run,Summarize}` with a first -pass argument structure. - -**Step 4: Run test to verify it passes** - -Run: `cargo test -p mobench profile_run_parses_with_android_backend -- --exact` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/mobench/src/lib.rs crates/mobench/src/profile.rs -git commit -m "feat: add profile command scaffolding" -``` - -### Task 2: Add Normalized Profile Manifest Rendering - -**Files:** -- Modify: `crates/mobench/src/profile.rs` -- Test: `crates/mobench/src/profile.rs` - -**Step 1: Write the failing test** - -Add a manifest round-trip test and summary-render test: - -```rust -#[test] -fn profile_manifest_serializes_partial_failure_state() { - let manifest = ProfileManifest { - run_id: "run-123".into(), - target: "android".into(), - function: "sample_fns::fibonacci".into(), - backend: ProfileBackend::AndroidNative, - raw_artifacts: vec![PathBuf::from("artifacts/raw/sample.perf")], - processed_artifacts: vec![], - warnings: vec!["missing symbols".into()], - }; - - let json = serde_json::to_value(&manifest).expect("serialize manifest"); - assert_eq!(json["warnings"][0], "missing symbols"); -} - -#[test] -fn render_profile_summary_mentions_backend_and_artifacts() { - let manifest = sample_manifest(); - let markdown = render_profile_markdown(&manifest); - assert!(markdown.contains("android-native")); - assert!(markdown.contains("artifacts/raw")); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cargo test -p mobench profile_manifest_serializes_partial_failure_state -- --exact` - -Expected: FAIL because `render_profile_markdown` and the finalized manifest -shape do not exist yet. - -**Step 3: Write minimal implementation** - -Extend `ProfileManifest` with: - -- symbolization status -- viewer hints -- capture status enum -- explicit raw/processed artifact records - -Add: - -```rust -pub fn render_profile_markdown(manifest: &ProfileManifest) -> String { - // render backend, target, function, capture status, warnings, and artifact paths -} - -pub fn write_profile_manifest(path: &Path, manifest: &ProfileManifest) -> Result<()> { - // pretty JSON writer -} -``` - -Keep the output stable and deterministic so it is easy to diff in tests. - -**Step 4: Run test to verify it passes** - -Run: `cargo test -p mobench profile_manifest_serializes_partial_failure_state -- --exact` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/mobench/src/profile.rs -git commit -m "feat: add normalized profile manifests" -``` - -### Task 3: Add Command Dispatch For `profile run` And `profile summarize` - -**Files:** -- Modify: `crates/mobench/src/lib.rs` -- Modify: `crates/mobench/src/profile.rs` -- Test: `crates/mobench/src/lib.rs` - -**Step 1: Write the failing test** - -Add command-dispatch tests that verify the new commands produce expected files -in dry-run or fixture mode: - -```rust -#[test] -fn profile_summarize_reads_manifest_and_prints_markdown() { - let dir = tempfile::tempdir().unwrap(); - let manifest_path = dir.path().join("profile.json"); - write_file( - &manifest_path, - serde_json::to_string_pretty(&sample_manifest()).unwrap().as_bytes(), - ) - .unwrap(); - - let output = cmd_profile_summarize_for_test(&manifest_path).expect("summarize profile"); - assert!(output.contains("sample_fns::fibonacci")); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cargo test -p mobench profile_summarize_reads_manifest_and_prints_markdown -- --exact` - -Expected: FAIL because the profile commands are not wired into execution yet. - -**Step 3: Write minimal implementation** - -In `crates/mobench/src/lib.rs`: - -- add `Command::Profile { command: ProfileCommand }` -- dispatch to `cmd_profile_run` and `cmd_profile_summarize` -- reuse existing `write_file` and CLI output helpers - -In `crates/mobench/src/profile.rs`: - -- add helpers for default output paths -- add manifest loading -- add `cmd_profile_summarize_for_test` - -**Step 4: Run test to verify it passes** - -Run: `cargo test -p mobench profile_summarize_reads_manifest_and_prints_markdown -- --exact` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/mobench/src/lib.rs crates/mobench/src/profile.rs -git commit -m "feat: wire profile command dispatch" -``` - -### Task 4: Add Android Backend Command Construction With Capability Checks - -**Files:** -- Modify: `crates/mobench/src/profile.rs` -- Test: `crates/mobench/src/profile.rs` - -**Step 1: Write the failing test** - -Add a backend-unit test that verifies Android profiling builds the expected tool -commands and errors clearly when prerequisites are missing: - -```rust -#[test] -fn android_backend_requires_adb_and_simpleperf() { - let ctx = sample_profile_context().with_backend(ProfileBackend::AndroidNative); - let error = build_android_capture_plan(&ctx, None, None).unwrap_err(); - assert!(error.to_string().contains("simpleperf")); -} - -#[test] -fn android_backend_builds_capture_plan_with_processed_artifacts() { - let ctx = sample_profile_context().with_backend(ProfileBackend::AndroidNative); - let plan = build_android_capture_plan( - &ctx, - Some(Path::new("/usr/bin/adb")), - Some(Path::new("/opt/android/simpleperf")), - ) - .expect("android capture plan"); - - assert!(plan.raw_artifacts.iter().any(|p| p.ends_with("sample.perf"))); - assert!(plan.processed_artifacts.iter().any(|p| p.ends_with("flamegraph.html"))); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cargo test -p mobench android_backend_requires_adb_and_simpleperf -- --exact` - -Expected: FAIL because the Android profiling backend does not exist yet. - -**Step 3: Write minimal implementation** - -Add an Android backend that: - -- detects `adb` and `simpleperf` -- allocates raw/processed artifact paths under `artifacts/raw` and - `artifacts/processed` -- records a capture plan structure without yet attempting to optimize - -Start with command construction and manifest emission only. Keep tool invocation -behind small helper functions so tests can cover planning without requiring a -real device. - -**Step 4: Run test to verify it passes** - -Run: `cargo test -p mobench android_backend_requires_adb_and_simpleperf -- --exact` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/mobench/src/profile.rs -git commit -m "feat: add android profiling backend planning" -``` - -### Task 5: Add iOS Backend Command Construction With `xctrace` - -**Files:** -- Modify: `crates/mobench/src/profile.rs` -- Test: `crates/mobench/src/profile.rs` - -**Step 1: Write the failing test** - -Add tests for iOS capability detection and artifact planning: - -```rust -#[test] -fn ios_backend_requires_xctrace() { - let ctx = sample_profile_context().with_backend(ProfileBackend::IosInstruments); - let error = build_ios_capture_plan(&ctx, None).unwrap_err(); - assert!(error.to_string().contains("xctrace")); -} - -#[test] -fn ios_backend_allocates_trace_bundle_and_export_paths() { - let ctx = sample_profile_context().with_backend(ProfileBackend::IosInstruments); - let plan = build_ios_capture_plan(&ctx, Some(Path::new("/usr/bin/xcrun"))) - .expect("ios capture plan"); - - assert!(plan.raw_artifacts.iter().any(|p| p.ends_with("time-profiler.trace"))); - assert!(plan.processed_artifacts.iter().any(|p| p.ends_with("time-profiler.xml"))); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cargo test -p mobench ios_backend_requires_xctrace -- --exact` - -Expected: FAIL because the iOS backend is not implemented yet. - -**Step 3: Write minimal implementation** - -Add an iOS backend that: - -- detects `xcrun` / `xctrace` -- builds an `xctrace record` command plan with the `Time Profiler` template -- allocates `.trace` and exported XML artifact paths -- records viewer hints that point users to Instruments - -Again, keep command planning testable without requiring a live device. - -**Step 4: Run test to verify it passes** - -Run: `cargo test -p mobench ios_backend_requires_xctrace -- --exact` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/mobench/src/profile.rs -git commit -m "feat: add ios profiling backend planning" -``` - -### Task 6: Add BrowserStack Capability Gating And Provider Errors - -**Files:** -- Modify: `crates/mobench/src/profile.rs` -- Modify: `crates/mobench/src/lib.rs` -- Test: `crates/mobench/src/profile.rs` - -**Step 1: Write the failing test** - -Add a test that asserts BrowserStack-backed profile runs fail explicitly when a -native backend is requested: - -```rust -#[test] -fn browserstack_profile_run_reports_unsupported_native_capture() { - let ctx = sample_profile_context() - .with_backend(ProfileBackend::AndroidNative) - .with_provider("browserstack"); - - let error = validate_profile_capabilities(&ctx).unwrap_err(); - assert!(error.to_string().contains("BrowserStack")); - assert!(error.to_string().contains("unsupported")); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cargo test -p mobench browserstack_profile_run_reports_unsupported_native_capture -- --exact` - -Expected: FAIL because capability gating is not implemented. - -**Step 3: Write minimal implementation** - -Add capability validation that: - -- allows local Android and local iOS native backends -- rejects BrowserStack native profiling backends with a precise error -- records provider/capability warnings in dry-run and manifest output - -Do not silently downgrade to another backend. - -**Step 4: Run test to verify it passes** - -Run: `cargo test -p mobench browserstack_profile_run_reports_unsupported_native_capture -- --exact` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/mobench/src/lib.rs crates/mobench/src/profile.rs -git commit -m "feat: gate unsupported browserstack profiling backends" -``` - -### Task 7: Add README And Build Documentation - -**Files:** -- Modify: `README.md` -- Modify: `BUILD.md` -- Modify: `TESTING.md` - -**Step 1: Write the failing doc check** - -Add a focused grep-based check to confirm the docs mention the new command -surface and prerequisites: - -```bash -rg -n "mobench profile run|simpleperf|xctrace|profile summarize" README.md BUILD.md TESTING.md -``` - -Expected: no matches before the doc update. - -**Step 2: Run check to verify it fails** - -Run: `rg -n "mobench profile run|simpleperf|xctrace|profile summarize" README.md BUILD.md TESTING.md` - -Expected: exit 1 or missing coverage for the new profiling workflow. - -**Step 3: Write minimal documentation** - -Document: - -- the new `mobench profile` commands -- Android prerequisites (`adb`, `simpleperf`, symbols) -- iOS prerequisites (`xcrun`, `xctrace`, dSYMs) -- BrowserStack profiling limitations in the MVP - -Keep the current CI v1 contract language unchanged. - -**Step 4: Run check to verify it passes** - -Run: `rg -n "mobench profile run|simpleperf|xctrace|profile summarize" README.md BUILD.md TESTING.md` - -Expected: matching lines in all relevant docs. - -**Step 5: Commit** - -```bash -git add README.md BUILD.md TESTING.md -git commit -m "docs: add profiling workflow guidance" -``` - -### Task 8: Run Final Verification - -**Files:** -- Modify: any files needed to fix verification failures - -**Step 1: Run targeted tests for the new profile subsystem** - -Run: `cargo test -p mobench profile_ -- --nocapture` - -Expected: PASS - -**Step 2: Run the full workspace test suite** - -Run: `cargo test` - -Expected: PASS - -**Step 3: Run formatting** - -Run: `cargo fmt --all --check` - -Expected: PASS - -**Step 4: Run a dry-run CLI smoke test** - -Run: - -```bash -cargo run -p mobench --bin mobench -- profile run \ - --target android \ - --function sample_fns::fibonacci \ - --backend android-native -``` - -Expected: PASS with a run-scoped profile session under -`target/mobench/profile//` and refreshed latest-session files at -`target/mobench/profile/profile.json` and `target/mobench/profile/summary.md`. - -**Step 5: Commit** - -```bash -git add . -git commit -m "feat: add mobench profiling subsystem" -``` diff --git a/docs/plans/2026-03-26-flamegraph-viewer-design.md b/docs/plans/2026-03-26-flamegraph-viewer-design.md deleted file mode 100644 index b5f40f9..0000000 --- a/docs/plans/2026-03-26-flamegraph-viewer-design.md +++ /dev/null @@ -1,175 +0,0 @@ -# Flamegraph Viewer Design - -## Context - -The current `mobench` flamegraph artifact is now technically correct enough to show the benchmark stack, but it is still a poor primary UX. The page opens as a raw `inferno` SVG with minimal controls, full-process noise dominates the default view, and common analysis tasks such as switching between benchmark-only and full-process stacks, brushing a range, and navigating zoom history are cumbersome. - -This design changes the flamegraph artifact from "a standalone SVG file" into "a standalone flamegraph viewer page" while preserving the existing raw profiling outputs. - -## Goals - -- Make the default profiling artifact easy to navigate without external tooling. -- Default the user to a benchmark-focused view while preserving access to full-process data. -- Support toggling between benchmark-only and full-process flamegraphs in one artifact. -- Add explicit interaction affordances for click zoom, drag-to-select range zoom, reset, and zoom history. -- Keep the output standalone and local-file friendly. - -## Non-Goals - -- Rebuild flamegraph layout in the browser from raw stack data. -- Replace `inferno` with a custom canvas renderer. -- Remove or hide the existing raw artifacts such as `sample.txt`, `stacks.folded`, or `native-report.txt`. - -## Recommended Approach - -Generate a custom standalone HTML shell around two pre-rendered flamegraph SVGs: - -- `Benchmark Only` -- `Full Process` - -The HTML shell becomes the primary artifact (`flamegraph.html`) and provides the controls, summaries, and navigation state. The SVGs remain pre-rendered by `inferno`, which keeps generation deterministic and avoids moving layout logic into client-side JavaScript. - -## Alternatives Considered - -### Extend `inferno`'s emitted SVG in place - -This is the lightest possible patch, but the embedded script is not structured for dual datasets, brush-zoom, or a real control surface. It would become brittle quickly. - -### Replace the output with a custom canvas app - -This has the highest interaction ceiling, but it is unnecessary for the current scope and would create a much larger maintenance burden. - -## Product Shape - -The generated viewer page should contain: - -- A sticky toolbar with: - - `Benchmark Only` - - `Full Process` - - `Back` - - `Forward` - - `Reset` - - `Search` -- A summary strip showing: - - current mode - - current root frame - - current selection width - - visible sample count -- A details pane or summary section listing the hottest frames in the current mode and zoom root -- The current flamegraph visualization -- Links to supporting artifacts: - - `native-report.txt` - - `stacks.folded` - - `benchmark.focused.folded` - - `flamegraph.full.svg` - - `flamegraph.focused.svg` - -The default mode should be `Benchmark Only`. - -## Architecture - -The implementation has three layers. - -### 1. Dataset generation - -Keep generating the existing full folded stack dataset. Add a second derived folded dataset, `benchmark.focused.folded`, by trimming each stack down to the first benchmark anchor and everything below it. - -### 2. Rendering - -Render two SVG flamegraphs with `inferno`: - -- full process -- benchmark focused - -These become `flamegraph.full.svg` and `flamegraph.focused.svg`. - -### 3. Viewer shell - -Generate a standalone HTML page that embeds both SVGs, shows only one at a time, and layers our own navigation and summary UI on top. - -## Benchmark-Focused Derivation - -The benchmark-only dataset is produced by matching the first benchmark anchor in each stack and dropping all frames above that anchor. - -Candidate anchors include: - -- iOS: - - `runBenchmark(spec:)` - - `uniffi_*` - - `sample_fns::run_benchmark` - - `mobench_sdk::timing::run_closure` -- Android: - - UniFFI/JNA entrypoints - - Rust `run_benchmark` - - `mobench_sdk::timing::run_closure` - -The anchor list should be data-driven so new benchmark surfaces can be added without changing the renderer. - -If a stack has no benchmark anchor, it is excluded from the focused dataset. If no stacks match at all, the viewer should surface that fact and degrade to the full-process view cleanly. - -## Interaction Model - -The viewer should support: - -### Click zoom - -Clicking a frame zooms into that frame. - -### Brush zoom - -Dragging horizontally across the graph selects an x-axis sample range. On mouse-up, that range expands to the full graph width. - -### Navigation history - -Every click zoom or brush zoom creates a history entry. The toolbar exposes: - -- `Back` -- `Forward` -- `Reset` - -Each mode should preserve its own history stack. - -## Output Contract - -Processed artifacts should expand to include: - -- `stacks.folded` -- `benchmark.focused.folded` -- `native-report.txt` -- `flamegraph.full.svg` -- `flamegraph.focused.svg` -- `flamegraph.html` - -The profile manifest should record both flamegraph datasets and the viewer artifact explicitly so summaries and downstream tooling can refer to them unambiguously. - -## Failure Handling - -- If the focused dataset is empty, the `Benchmark Only` tab should remain available but show a warning and allow a one-click switch to `Full Process`. -- If only one SVG renders successfully, the viewer should still load and surface the failure inline. -- If a brush selection is too small or resolves to zero samples, ignore it and keep the current view. - -## Performance Constraints - -- Keep flamegraph rendering static and generation-time only. -- Do not recompute flamegraph layout in browser JavaScript. -- The browser should manage mode switching, zoom state, history, and local summaries only. - -## Testing Strategy - -- Unit tests for benchmark-anchor trimming on representative Android and iOS folded stacks -- HTML generation tests for toolbar controls, dual-view shell, and artifact links -- Snapshot-style tests for `flamegraph.html` structure -- Smoke tests confirming: - - `flamegraph.full.svg` exists - - `flamegraph.focused.svg` exists - - `flamegraph.html` references both - - focused stacks contain benchmark frames such as `run_benchmark` and `fibonacci` - -## Recommendation - -Ship the viewer in two steps: - -1. Benchmark-focused dataset derivation plus dual-view standalone HTML shell -2. Brush zoom, history controls, and hot-frame summaries - -This keeps the first version grounded in the current pipeline while directly fixing the biggest UX problem: the default artifact should lead with the benchmark, not the entire process. diff --git a/docs/plans/2026-03-26-profiling-upgrades-design.md b/docs/plans/2026-03-26-profiling-upgrades-design.md deleted file mode 100644 index 230d294..0000000 --- a/docs/plans/2026-03-26-profiling-upgrades-design.md +++ /dev/null @@ -1,294 +0,0 @@ -# Mobench Profiling Upgrades Design - -## Summary - -Upgrade Mobench profiling from "real local capture with basic artifacts" to a -two-layer profiling system that answers both of the questions users actually -have when investigating mobile benchmark performance: - -- Which native and Rust functions were hot? -- Which benchmark phase was expensive? - -The design keeps native profilers as the source of truth for stack-level CPU -behavior while adding an explicit semantic profiling layer for benchmark phases -such as `load`, `prove`, `serialize`, and `verify`. - -## Goals - -- Improve the quality and interpretability of native profiling artifacts on - Android and iOS. -- Make Android flamegraphs show demangled Rust frames below the UniFFI/JNA - bridge. -- Preserve the current iOS ability to show Rust frames below the FFI boundary. -- Add an opt-in semantic profiling layer in `mobench-sdk` for benchmark phases. -- Keep the normalized `profile.json` contract, but separate native capture from - semantic instrumentation clearly. -- Keep BrowserStack native profiling explicitly unsupported unless real - artifact retrieval exists. - -## Non-Goals - -- Replacing native profilers with benchmark instrumentation. -- Promising exact per-iteration call traces from sampled profiling. -- Migrating the Android bridge from JNA/UniFFI to Java FFM in this upgrade. -- Treating BrowserStack timing or memory metrics as native stack profiling. - -## Current State - -The branch already supports real local profiling: - -- Android local profiling uses `simpleperf`, writes `sample.perf`, - `stacks.folded`, and `flamegraph.html`. -- iOS local profiling samples the simulator-host process and writes - `sample.txt`, `stacks.folded`, and `flamegraph.html`. -- BrowserStack native profiling is explicitly unsupported. - -Observed limitations: - -- iOS preserves the FFI and Rust call chain well in current outputs. -- Android captures native samples successfully, but the current folded/flamegraph - outputs do not fully symbolize internal Rust frames and still show unresolved - native offsets in practice. -- Current native profiling is good at identifying hot call stacks, but it does - not answer semantic questions such as "how much time was spent proving vs - serializing?" - -## Recommended Approach - -Use a two-layer profiling model: - -1. Native capture layer - - Android: improve `simpleperf` symbol fidelity and capture hygiene. - - iOS: keep the current working local flow, but hide it behind a capture - interface that can later support richer native tools such as `xctrace`. -2. Semantic profiling layer - - Add an opt-in phase API in `mobench-sdk`. - - Record flat benchmark phases such as `prove`, `serialize`, and `verify`. - - Merge these phase timings into `profile.json` and `summary.md` without - pretending they came from native profilers. - -This approach preserves native profiling accuracy while making benchmark-level -performance reports actionable. - -## Product Shape - -The upgraded profiling experience should answer two questions explicitly: - -### Stack-level - -Which Rust or native functions were hot during the benchmark? - -This is answered by: - -- raw native capture artifacts -- symbolized folded stacks -- rendered flamegraphs -- optional native plain-text reports - -### Phase-level - -How much time was spent in major benchmark stages such as proving, -serialization, or verification? - -This is answered by: - -- benchmark-side semantic phase instrumentation -- merged phase timing output in `profile.json` -- rendered phase summaries in `summary.md` - -## Architecture - -### 1. Capture Executor - -Platform-specific native profilers should remain responsible for producing raw -capture data. - -- Android executor: run `simpleperf` and persist all intermediate artifacts - needed for later symbolization. -- iOS executor: keep the current working local capture path, but return a raw - capture bundle through a stable interface instead of assuming the raw text - file is the final product. - -### 2. Symbolizer - -A platform-specific post-processing layer should resolve native frames into -stable, readable function names. - -- Android: - - consume unresolved native offsets from `simpleperf` output - - resolve them with `llvm-addr2line -Cfpe` against unstripped Rust shared - libraries - - rewrite folded stacks before flamegraph generation -- iOS: - - continue using already-symbolized frames from the working local path - - keep the symbolizer interface generic enough to support `xctrace` later - -### 3. Semantic Profiler - -An opt-in semantic profiling API should be added to `mobench-sdk`. - -Start with flat phases only: - -- `profile_phase("load", || ...)` -- `profile_phase("prove", || ...)` -- `profile_phase("serialize", || ...)` -- `profile_phase("verify", || ...)` - -This layer records benchmark meaning, not stack structure. - -### 4. Renderers - -Mobench should render: - -- native folded stacks into `flamegraph.html` -- a native plain-text call tree report for inspection -- semantic phase summaries into Markdown and JSON outputs - -## Output Contract - -The manifest should distinguish native capture from semantic instrumentation. - -Recommended top-level sections: - -- `native_capture` - - `status` - - `raw_artifacts` - - `processed_artifacts` - - `symbolization` -- `semantic_profile` - - `status` - - `phases` - - optional `spans_path` -- `capture_metadata` - - target device/runtime - - sample duration / sample frequency - - warmup mode - - capture method details - -The on-disk run directory can remain compatible with the current layout: - -```text -target/mobench/profile// - profile.json - summary.md - artifacts/ - raw/ - processed/ - semantic/ -``` - -Suggested additions: - -- `artifacts/processed/native-report.txt` -- `artifacts/semantic/phases.json` - -## Native Profiling Improvements - -### Android - -Android needs better symbol fidelity and less setup noise. - -Required improvements: - -- warm the app and bridge before recording so JNA/UniFFI startup does not - dominate the flamegraph -- preserve and use unstripped Rust `.so` files for symbolization -- symbolize `lib*.so[+offset]` frames with `llvm-addr2line -Cfpe` -- record unresolved-frame counts and expose them in the manifest and summary -- prefer release builds with debuginfo for profiling runs - -Desired outputs: - -- symbolized `stacks.folded` -- symbolized `flamegraph.html` -- `native-report.txt` for text inspection - -### iOS - -iOS should keep the current working capture path while improving capture -quality and metadata. - -Required improvements: - -- warm the benchmark before recording to reduce launch/setup noise -- record the exact capture method in metadata -- preserve current symbol visibility in folded stacks and flamegraphs - -Future-compatible improvements: - -- abstract the working capture path behind an executor interface -- allow a richer `xctrace` backend later without changing the manifest model - -## Semantic Profiling Layer - -The semantic profiling layer exists to answer benchmark-specific questions that -sampled native stacks cannot answer reliably. - -The initial semantic profiling API should be: - -- opt-in -- flat rather than nested -- cheap enough to use in hot benchmarks without distorting results - -Output example: - -- `prove = 92%` -- `serialize = 5%` -- `verify = 3%` - -The summary must label this clearly as benchmark instrumentation rather than -native profiling. - -## Android Interop Direction - -Do not include an Android FFM migration in this upgrade. - -Rationale: - -- JNA still relies on JNI internally, but avoids handwritten JNI glue. -- The Java Foreign Function and Memory API is a standard JDK feature in Java SE - 22, but it is not an Android-supported surface that this project can rely on - today. -- The immediate profiling problems are symbolization and startup noise, not the - existence of the bridge itself. - -Short-term plan: - -- keep JNA/UniFFI -- warm the bridge before native capture -- make the Rust frames below the bridge visible in Android outputs - -## Testing Strategy - -### Unit Tests - -- manifest serialization for native plus semantic sections -- Android symbolization rewrite logic -- iOS/native renderer behavior -- semantic phase merge and summary rendering - -### Smoke Tests - -- local Android smoke test confirming symbolized Rust frames appear in rendered - outputs -- local iOS smoke test confirming the FFI-to-Rust chain is preserved -- semantic phase smoke test confirming phase timings land in `profile.json` and - `summary.md` - -### Documentation Checks - -- README capability matrix remains honest -- docs distinguish sampled native stacks from semantic phase profiling - -## Rollout - -Roll out in this order: - -1. Android native symbolization and warm profiling -2. native plain-text report output -3. semantic profiling API in `mobench-sdk` -4. manifest and summary extensions for semantic phases -5. iOS capture metadata cleanup and warm profiling polish - -This ordering improves the current flamegraphs first, then adds the semantic -layer users need for proving benchmarks. diff --git a/docs/plans/2026-03-27-flamegraph-tower-collapse-design.md b/docs/plans/2026-03-27-flamegraph-tower-collapse-design.md deleted file mode 100644 index 4ea126f..0000000 --- a/docs/plans/2026-03-27-flamegraph-tower-collapse-design.md +++ /dev/null @@ -1,130 +0,0 @@ -# Flamegraph Tower Collapse Design - -Status: not shipped. The thin-tower collapse experiment was rolled back after breaking the viewer UX, so this document is historical design context only. - -## Goal - -Make the flamegraph viewer readable by default when a profile contains tall, thin towers of low-self-time frames. The viewer should hide those towers initially, show compact `+` expand affordances at their x-ranges, and let the user zoom into them on demand. - -## Problem - -The current flamegraph viewer renders every frame at full depth. For profiles with narrow, mostly vertical branches, that produces a graph that is too tall, forces long scrolling, and makes the durable branches hard to compare. Even when the benchmark-only capture is good, the default presentation still overemphasizes thin call towers that contribute little self time. - -## Chosen Approach - -Use a viewer-layer collapse pass driven by `self time`, not a preprocessing pass that mutates the stored folded-stack artifacts. - -The viewer already has: - -- dual modes: `Benchmark Only` and `Full Process` -- zoom history -- brush range selection -- SVG helper functions inside the embedded flamegraph document - -The new behavior should extend that existing interaction model: - -1. Analyze the rendered SVG frames in the current zoomed view. -2. Detect contiguous vertical towers that are both visually thin and low in `self time`. -3. Hide those towers from the default graph layout. -4. Render a `+` chip at each hidden tower’s x-range. -5. Clicking the chip zooms that tower range to full width and reveals the hidden frames for that region. - -## Why This Approach - -This preserves the raw outputs: - -- `stacks.folded` -- `benchmark.focused.folded` -- `flamegraph.full.svg` -- `flamegraph.focused.svg` - -It also makes the behavior dynamic per zoom level. A tower that is too thin in the default view may become readable after zooming; the collapse logic should therefore rerun after every zoom instead of baking the collapse into the artifact generation pipeline. - -## Collapse Heuristic - -Base the heuristic on `self time`. - -Recommended defaults: - -- collapse a tower when frame width in the current view is below about `18px` -- and frame self time is below about `0.5%` of visible samples -- and the tower depth is at least `6` frames - -The detector should: - -- reconstruct parent/child relationships from the flamegraph SVG coordinates (`fg:x`, `fg:w`, `y`) -- estimate each frame’s `self_samples` from inclusive width minus direct child coverage -- identify mostly vertical, low-self-time chains -- group each chain into one collapsed tower rooted at the first durable ancestor - -## Interaction Model - -Default behavior: - -- `Hide Thin Towers` is enabled by default -- hidden towers do not contribute to graph height -- the graph height is cropped to the highest remaining visible frame - -Expansion behavior: - -- each hidden tower renders as a compact `+ N` chip -- clicking the chip zooms into that tower’s x-range -- after zoom, the collapse pass reruns for the new viewport -- `Back` returns to the prior durable-only view -- `Reset` returns to the default durable-only view for the current mode - -Mode behavior: - -- `Benchmark Only` and `Full Process` keep independent zoom/collapse history -- the collapse toggle state is shared unless later evidence shows mode-specific defaults are better - -## UI Additions - -Add to the toolbar: - -- `Hide Thin Towers` toggle, default `on` - -Add to the sidebar or toolbar metadata: - -- the active thresholds: - - `self < 0.5%` - - `width < 18px` - -The graph itself should show: - -- `+` chips positioned at hidden tower x-ranges -- hidden tower depth or hidden frame count in the chip label if space allows - -## Architecture - -Keep this feature inside the existing flamegraph viewer layer in: - -- `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` - -Do not introduce a new persisted artifact format. - -Implementation should likely touch: - -- the generated viewer HTML shell -- the embedded helper script inside the standalone SVG document -- viewer tests for HTML generation and tower-detection behavior - -## Testing - -Add: - -- unit tests for tower detection on a synthetic flamegraph tree -- HTML regression tests for: - - `Hide Thin Towers` toggle - - `+` expand chip support - - per-view collapse metadata -- smoke verification on the iOS sample artifact to confirm: - - initial graph height is shorter - - collapsed towers render with `+` affordances - - expanding a tower zooms to a legible range - -## Non-Goals - -- do not rewrite folded-stack artifacts on disk -- do not remove access to raw full-process detail -- do not rely on `inclusive time` as the primary collapse criterion diff --git a/docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md b/docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md deleted file mode 100644 index 7e91905..0000000 --- a/docs/plans/2026-03-27-flamegraph-tower-collapse-implementation.md +++ /dev/null @@ -1,282 +0,0 @@ -# Flamegraph Tower Collapse Implementation Plan - -Status: not shipped. The tower-collapse implementation was rolled back; keep this file only as historical implementation context, not as the current viewer behavior. - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Make the flamegraph viewer hide tall thin low-self-time towers by default, show `+` expand affordances for those ranges, and let users zoom into the hidden range to inspect it. - -**Architecture:** Keep the collapse logic in the viewer layer. Analyze rendered flamegraph frames from SVG coordinates at the current zoom level, compute low-self-time tower candidates, hide them from the default graph height, and inject expand chips that zoom into the hidden range. Preserve raw folded-stack and SVG artifacts exactly as they are today. - -**Tech Stack:** Rust, inferno-generated SVG flamegraphs, embedded viewer HTML/JS in `crates/mobench/src/flamegraph_viewer.rs`, Rust unit tests, local CLI smoke runs - ---- - -### Task 1: Freeze The New Viewer Behavior With Tests - -**Files:** -- Modify: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` - -**Step 1: Add failing viewer-shell tests** - -Add tests that assert the generated viewer HTML includes: - -- a `Hide Thin Towers` control -- expand-chip support -- tower-collapse metadata/hooks for the embedded graph - -**Step 2: Add a failing tower-detector test** - -Add a small synthetic flamegraph tree test that proves: - -- a deep thin low-self-time chain is detected as collapsible -- a durable wide branch is not collapsed - -**Step 3: Run the focused tests** - -Run: - -```bash -cargo test -p mobench flamegraph_viewer -- --nocapture -``` - -Expected: - -- new tests fail before implementation - -**Step 4: Commit** - -```bash -git add crates/mobench/src/flamegraph_viewer.rs -git commit -m "test: freeze flamegraph tower collapse behavior" -``` - -### Task 2: Add Tower Analysis And Collapse Metadata - -**Files:** -- Modify: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` - -**Step 1: Add SVG-frame analysis structures** - -Implement internal structs/functions to represent: - -- frame bounds -- depth -- parent/child coverage -- computed `self_samples` -- collapsible tower groups - -**Step 2: Implement the collapse heuristic** - -Use these default thresholds: - -- width `< 18px` -- self time `< 0.5%` -- tower depth `>= 6` - -The detector should return grouped tower ranges, not just individual frames. - -**Step 3: Add unit tests for heuristic edge cases** - -Cover: - -- tower just below threshold collapses -- wide branch does not collapse -- shallow tower does not collapse -- zoomed wider range no longer collapses - -**Step 4: Run tests** - -Run: - -```bash -cargo test -p mobench flamegraph_viewer -- --nocapture -``` - -Expected: - -- tower-analysis tests pass - -**Step 5: Commit** - -```bash -git add crates/mobench/src/flamegraph_viewer.rs -git commit -m "feat: detect low-self-time flamegraph towers" -``` - -### Task 3: Hide Towers And Add Expand Chips In The Embedded SVG - -**Files:** -- Modify: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` - -**Step 1: Extend the standalone SVG helper script** - -Add helper functions that: - -- inspect current visible frames -- mark collapsible towers -- hide their `` groups -- inject `+` chip overlays at the tower x-ranges -- recompute the visible SVG height after collapse - -**Step 2: Wire chip clicks to range zoom** - -Clicking a chip should: - -- call the existing zoom helper with that tower’s x-range -- rerun collapse analysis after zoom - -**Step 3: Preserve existing interactions** - -Ensure these still work: - -- click zoom -- brush zoom -- back -- forward -- reset -- search - -**Step 4: Run tests** - -Run: - -```bash -cargo test -p mobench flamegraph_viewer -- --nocapture -``` - -Expected: - -- SVG/viewer tests pass - -**Step 5: Commit** - -```bash -git add crates/mobench/src/flamegraph_viewer.rs -git commit -m "feat: collapse thin flamegraph towers by default" -``` - -### Task 4: Add Viewer Controls And State For Tower Hiding - -**Files:** -- Modify: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` - -**Step 1: Add the toolbar toggle** - -Add `Hide Thin Towers` to the viewer toolbar, default `on`. - -**Step 2: Add threshold/status display** - -Surface: - -- active threshold values -- number of collapsed towers or hidden frames when available - -**Step 3: Maintain per-mode state** - -Ensure `Benchmark Only` and `Full Process` retain independent zoom/collapse history. - -**Step 4: Add HTML regression tests** - -Assert the viewer HTML contains: - -- the toggle -- threshold display -- collapse-status placeholders - -**Step 5: Run tests** - -Run: - -```bash -cargo test -p mobench flamegraph_viewer -- --nocapture -``` - -Expected: - -- viewer-shell tests pass - -**Step 6: Commit** - -```bash -git add crates/mobench/src/flamegraph_viewer.rs -git commit -m "feat: add flamegraph tower collapse controls" -``` - -### Task 5: Verify With A Real iOS Profile Artifact - -**Files:** -- Modify if needed: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/crates/mobench/src/flamegraph_viewer.rs` - -**Step 1: Regenerate the iOS smoke artifact** - -Run: - -```bash -cargo run -p mobench --bin mobench -- profile run --target ios --provider local --backend ios-instruments --crate-path crates/sample-fns --function sample_fns::fibonacci --output-dir target/mobench/profile-smoke-ios -``` - -Expected: - -- fresh artifacts under `target/mobench/profile-smoke-ios/ios-sample_fns--fibonacci/artifacts/processed` - -**Step 2: Verify collapse behavior in the generated viewer** - -Check: - -- the initial graph height is materially shorter -- `+` expand chips appear -- clicking a chip zooms to a readable tower range -- `Back` and `Reset` restore the prior/default view - -**Step 3: Re-run relevant tests** - -Run: - -```bash -cargo test -p mobench flamegraph_viewer -- --nocapture -cargo test -p mobench profile_ -- --nocapture -``` - -Expected: - -- all tests pass - -**Step 4: Commit** - -```bash -git add crates/mobench/src/flamegraph_viewer.rs -git commit -m "feat: verify flamegraph tower collapse on iOS artifact" -``` - -### Task 6: Update Docs If Viewer Semantics Changed - -**Files:** -- Modify if needed: `/Users/dcbuilder/Code/world/mobile-bench-rs/.worktrees/profile-browserstack-honesty/README.md` - -**Step 1: Document the new viewer affordance** - -Add a short note that: - -- the interactive viewer hides tall thin low-self-time towers by default -- `+` chips expand hidden tower ranges -- `Reset` returns to the durable-only view - -**Step 2: Verify docs** - -Run: - -```bash -cargo run -p mobench --bin mobench -- profile run --help -``` - -Expected: - -- help remains accurate - -**Step 3: Commit** - -```bash -git add README.md -git commit -m "docs: describe flamegraph tower collapse viewer" -``` From f154c90704b6921192475825541902e0908b2d30 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 21:58:17 -0700 Subject: [PATCH 153/196] fix: package BrowserStack iOS IPAs with ditto --- crates/mobench-sdk/src/builders/ios.rs | 199 ++++++++++++++++++++----- 1 file changed, 165 insertions(+), 34 deletions(-) diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 8301e11..5cc6f72 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -76,6 +76,7 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; /// iOS builder that handles the complete build pipeline. /// @@ -1645,7 +1646,10 @@ impl IosBuilder { println!("Creating IPA from app bundle..."); - // Step 3: Create IPA (which is just a zip of Payload/{app}) + // Step 3: Stage the app bundle inside Payload/ and archive it with + // `ditto`, which preserves the bundle structure the way Xcode-generated + // IPAs do. The earlier recursive copy + `zip` path produced invalid + // BrowserStack uploads in CI. let payload_dir = export_path.join("Payload"); if payload_dir.exists() { fs::remove_dir_all(&payload_dir).map_err(|e| { @@ -1664,11 +1668,11 @@ impl IosBuilder { )) })?; - // Copy app bundle into Payload/ + // Copy app bundle into Payload/ using the standard macOS bundle copier. let dest_app = payload_dir.join(format!("{}.app", scheme)); - self.copy_dir_recursive(&app_path, &dest_app)?; + self.copy_bundle_with_ditto(&app_path, &dest_app)?; - // Create zip archive + // Create IPA archive if ipa_path.exists() { fs::remove_file(&ipa_path).map_err(|e| { BenchError::Build(format!( @@ -1679,17 +1683,21 @@ impl IosBuilder { })?; } - let mut cmd = Command::new("zip"); - cmd.arg("-qr") - .arg(&ipa_path) + let mut cmd = Command::new("ditto"); + cmd.arg("-c") + .arg("-k") + .arg("--sequesterRsrc") + .arg("--keepParent") .arg("Payload") + .arg(&ipa_path) .current_dir(&export_path); if self.verbose { println!(" Running: {:?}", cmd); } - run_command(cmd, "zip IPA")?; + run_command(cmd, "create IPA archive with ditto")?; + self.validate_ipa_archive(&ipa_path, scheme)?; // Clean up Payload directory fs::remove_dir_all(&payload_dir).map_err(|e| { @@ -1873,42 +1881,85 @@ impl IosBuilder { Ok(zip_path) } - /// Recursively copies a directory - fn copy_dir_recursive(&self, src: &Path, dest: &Path) -> Result<(), BenchError> { - fs::create_dir_all(dest).map_err(|e| { - BenchError::Build(format!("Failed to create directory {:?}: {}", dest, e)) + fn copy_bundle_with_ditto(&self, src: &Path, dest: &Path) -> Result<(), BenchError> { + let mut cmd = Command::new("ditto"); + cmd.arg(src).arg(dest); + + if self.verbose { + println!(" Running: {:?}", cmd); + } + + run_command(cmd, "copy app bundle with ditto") + } + + fn validate_ipa_archive(&self, ipa_path: &Path, scheme: &str) -> Result<(), BenchError> { + let extract_root = env::temp_dir().join(format!( + "mobench-ipa-validate-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + + if extract_root.exists() { + fs::remove_dir_all(&extract_root).map_err(|e| { + BenchError::Build(format!( + "Failed to clear IPA validation dir at {}: {}", + extract_root.display(), + e + )) + })?; + } + fs::create_dir_all(&extract_root).map_err(|e| { + BenchError::Build(format!( + "Failed to create IPA validation dir at {}: {}", + extract_root.display(), + e + )) })?; - for entry in fs::read_dir(src) - .map_err(|e| BenchError::Build(format!("Failed to read directory {:?}: {}", src, e)))? - { - let entry = - entry.map_err(|e| BenchError::Build(format!("Failed to read entry: {}", e)))?; - let path = entry.path(); - let file_name = path - .file_name() - .ok_or_else(|| BenchError::Build(format!("Invalid file name in {:?}", path)))?; - let dest_path = dest.join(file_name); - - if path.is_dir() { - self.copy_dir_recursive(&path, &dest_path)?; - } else { - fs::copy(&path, &dest_path).map_err(|e| { - BenchError::Build(format!( - "Failed to copy {:?} to {:?}: {}", - path, dest_path, e - )) - })?; - } + let mut extract = Command::new("ditto"); + extract + .arg("-x") + .arg("-k") + .arg(ipa_path) + .arg(&extract_root); + + let extract_result = run_command(extract, "extract IPA for validation"); + if let Err(err) = extract_result { + let _ = fs::remove_dir_all(&extract_root); + return Err(err); } - Ok(()) + let info_plist = extract_root + .join("Payload") + .join(format!("{}.app", scheme)) + .join("Info.plist"); + let validation_result = if info_plist.is_file() { + Ok(()) + } else { + Err(BenchError::Build(format!( + "IPA validation failed: {} is missing from {}.\n\n\ + The packaged IPA does not contain a valid iOS app bundle. \ + BrowserStack will reject this upload.", + info_plist + .strip_prefix(&extract_root) + .unwrap_or(&info_plist) + .display(), + ipa_path.display() + ))) + }; + + let _ = fs::remove_dir_all(&extract_root); + validation_result } } #[cfg(test)] mod tests { use super::*; + use std::io::Write; #[test] fn test_ios_builder_creation() { @@ -1933,6 +1984,86 @@ mod tests { assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); } + #[cfg(target_os = "macos")] + #[test] + fn test_validate_ipa_archive_rejects_missing_info_plist() { + let temp_dir = env::temp_dir().join(format!( + "mobench-ios-test-bad-ipa-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let payload = temp_dir.join("Payload/BenchRunner.app"); + fs::create_dir_all(&payload).expect("create payload"); + let ipa = temp_dir.join("broken.ipa"); + + let status = Command::new("ditto") + .arg("-c") + .arg("-k") + .arg("--sequesterRsrc") + .arg("--keepParent") + .arg("Payload") + .arg(&ipa) + .current_dir(&temp_dir) + .status() + .expect("run ditto"); + assert!(status.success(), "ditto should create the broken test ipa"); + + let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile"); + let err = builder + .validate_ipa_archive(&ipa, "BenchRunner") + .expect_err("IPA missing Info.plist should be rejected"); + assert!( + err.to_string().contains("Info.plist"), + "expected validation error mentioning Info.plist, got: {err}" + ); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[cfg(target_os = "macos")] + #[test] + fn test_validate_ipa_archive_accepts_payload_with_info_plist() { + let temp_dir = env::temp_dir().join(format!( + "mobench-ios-test-good-ipa-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let payload = temp_dir.join("Payload/BenchRunner.app"); + fs::create_dir_all(&payload).expect("create payload"); + let mut info = fs::File::create(payload.join("Info.plist")).expect("create plist"); + writeln!( + info, + "" + ) + .expect("write plist"); + let ipa = temp_dir.join("valid.ipa"); + + let status = Command::new("ditto") + .arg("-c") + .arg("-k") + .arg("--sequesterRsrc") + .arg("--keepParent") + .arg("Payload") + .arg(&ipa) + .current_dir(&temp_dir) + .status() + .expect("run ditto"); + assert!(status.success(), "ditto should create the valid test ipa"); + + let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile"); + builder + .validate_ipa_archive(&ipa, "BenchRunner") + .expect("IPA with Info.plist should validate"); + + let _ = fs::remove_dir_all(&temp_dir); + } + #[test] fn test_find_crate_dir_current_directory_is_crate() { // Test case 1: Current directory IS the crate with matching package name From 23d42cb96a0fd773a62035d09b339920124a2414 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 22:10:42 -0700 Subject: [PATCH 154/196] fix: harden iOS IPA bundle packaging --- crates/mobench-sdk/src/builders/ios.rs | 144 ++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 5cc6f72..2a451d8 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -1525,7 +1525,10 @@ impl IosBuilder { // Step 1: Build the app for device (simpler than archiving) let build_dir = self.output_dir.join("ios/build"); - let build_configuration = "Debug"; + // Package the same optimized device binary we ship to BrowserStack. + // `Release + iphoneos` has proven more stable in CI than the previous + // implicit Debug destination build. + let build_configuration = "Release"; let mut cmd = Command::new("xcodebuild"); cmd.arg("-project") .arg(&project_path) @@ -1533,6 +1536,8 @@ impl IosBuilder { .arg(scheme) .arg("-destination") .arg("generic/platform=iOS") + .arg("-sdk") + .arg("iphoneos") .arg("-configuration") .arg(build_configuration) .arg("-derivedDataPath") @@ -1607,6 +1612,19 @@ impl IosBuilder { println!(" App bundle created successfully at {:?}", app_path); } + if let Ok(output) = &build_result + && !output.status.success() + { + println!( + "Warning: xcodebuild exited with {} but produced {}. Validating the bundle before continuing.", + output.status, + app_path.display() + ); + } + + let source_info_plist = ios_dir.join(scheme).join("Info.plist"); + self.ensure_device_app_bundle_metadata(&app_path, &source_info_plist, scheme)?; + if matches!(method, SigningMethod::AdHoc) { let profile = find_provisioning_profile(); let identity = find_codesign_identity(); @@ -1892,6 +1910,50 @@ impl IosBuilder { run_command(cmd, "copy app bundle with ditto") } + fn ensure_device_app_bundle_metadata( + &self, + app_path: &Path, + source_info_plist: &Path, + scheme: &str, + ) -> Result<(), BenchError> { + let bundled_info_plist = app_path.join("Info.plist"); + if !bundled_info_plist.is_file() { + if !source_info_plist.is_file() { + return Err(BenchError::Build(format!( + "Built app bundle at {} is missing Info.plist, and the generated source plist was not found at {}.\n\n\ + The device build produced an incomplete .app bundle, so packaging cannot continue.", + app_path.display(), + source_info_plist.display() + ))); + } + + fs::copy(source_info_plist, &bundled_info_plist).map_err(|e| { + BenchError::Build(format!( + "Built app bundle at {} is missing Info.plist, and restoring it from {} failed: {}.", + app_path.display(), + source_info_plist.display(), + e + )) + })?; + println!( + "Warning: Restored missing Info.plist into built app bundle from {}.", + source_info_plist.display() + ); + } + + let executable = app_path.join(scheme); + if !executable.is_file() { + return Err(BenchError::Build(format!( + "Built app bundle at {} is missing the expected executable {}.\n\n\ + The device build produced an incomplete .app bundle, so packaging cannot continue.", + app_path.display(), + executable.display() + ))); + } + + Ok(()) + } + fn validate_ipa_archive(&self, ipa_path: &Path, scheme: &str) -> Result<(), BenchError> { let extract_root = env::temp_dir().join(format!( "mobench-ipa-validate-{}-{}", @@ -2064,6 +2126,86 @@ mod tests { let _ = fs::remove_dir_all(&temp_dir); } + #[test] + fn test_ensure_device_app_bundle_metadata_restores_missing_info_plist() { + let temp_dir = env::temp_dir().join(format!( + "mobench-ios-test-repair-plist-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let app_dir = temp_dir.join("Build/Products/Release-iphoneos/BenchRunner.app"); + fs::create_dir_all(&app_dir).expect("create app dir"); + fs::write(app_dir.join("BenchRunner"), "bin").expect("create executable"); + + let source_dir = temp_dir.join("BenchRunner"); + fs::create_dir_all(&source_dir).expect("create source dir"); + fs::write( + source_dir.join("Info.plist"), + "", + ) + .expect("create source plist"); + + let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile"); + builder + .ensure_device_app_bundle_metadata( + &app_dir, + &source_dir.join("Info.plist"), + "BenchRunner", + ) + .expect("missing plist should be restored"); + + assert!( + app_dir.join("Info.plist").is_file(), + "restored app bundle should contain Info.plist" + ); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[test] + fn test_ensure_device_app_bundle_metadata_rejects_missing_executable() { + let temp_dir = env::temp_dir().join(format!( + "mobench-ios-test-missing-exec-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let app_dir = temp_dir.join("Build/Products/Release-iphoneos/BenchRunner.app"); + fs::create_dir_all(&app_dir).expect("create app dir"); + fs::write( + app_dir.join("Info.plist"), + "", + ) + .expect("create bundled plist"); + let source_dir = temp_dir.join("BenchRunner"); + fs::create_dir_all(&source_dir).expect("create source dir"); + fs::write( + source_dir.join("Info.plist"), + "", + ) + .expect("create source plist"); + + let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile"); + let err = builder + .ensure_device_app_bundle_metadata( + &app_dir, + &source_dir.join("Info.plist"), + "BenchRunner", + ) + .expect_err("missing executable should fail validation"); + assert!( + err.to_string().contains("missing the expected executable"), + "expected executable validation error, got: {err}" + ); + + let _ = fs::remove_dir_all(&temp_dir); + } + #[test] fn test_find_crate_dir_current_directory_is_crate() { // Test case 1: Current directory IS the crate with matching package name From 4af4d072c42a91d40cc3a732dd0ca9c47b8a5eaa Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 22:17:35 -0700 Subject: [PATCH 155/196] fix: stop embedding static iOS xcframeworks --- crates/mobench-sdk/src/codegen.rs | 5 +++++ .../templates/ios/BenchRunner/project.yml.template | 2 +- templates/ios/BenchRunner/project.yml.template | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 68620dc..e34b54c 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -1493,6 +1493,11 @@ pub fn public_bench() { "Bundle ID should NOT be duplicated as 'dev.world.benchmobile.benchmobile', got:\n{}", project_yml ); + assert!( + project_yml.contains("embed: false"), + "Static xcframework dependency should be link-only, got:\n{}", + project_yml + ); // Cleanup fs::remove_dir_all(&temp_dir).ok(); diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template index cd8278b..7ccad1a 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template @@ -28,7 +28,7 @@ targets: HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/{{PROJECT_NAME_PASCAL}}/Generated" dependencies: - framework: ../{{LIBRARY_NAME}}.xcframework - embed: true + embed: false link: true {{PROJECT_NAME_PASCAL}}UITests: type: bundle.ui-testing diff --git a/templates/ios/BenchRunner/project.yml.template b/templates/ios/BenchRunner/project.yml.template index cd8278b..7ccad1a 100644 --- a/templates/ios/BenchRunner/project.yml.template +++ b/templates/ios/BenchRunner/project.yml.template @@ -28,7 +28,7 @@ targets: HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/{{PROJECT_NAME_PASCAL}}/Generated" dependencies: - framework: ../{{LIBRARY_NAME}}.xcframework - embed: true + embed: false link: true {{PROJECT_NAME_PASCAL}}UITests: type: bundle.ui-testing From efb72975438eeb8f249017ea8baa7b1d2f755595 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 22:39:13 -0700 Subject: [PATCH 156/196] fix: expose iOS device build failures --- crates/mobench-sdk/src/builders/ios.rs | 60 +++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 2a451d8..af0c34d 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -1547,9 +1547,18 @@ impl IosBuilder { // Add signing parameters based on method match method { SigningMethod::AdHoc => { - // Ad-hoc signing (works for BrowserStack, no Apple ID needed) - // For ad-hoc, we disable signing during build and sign manually after - cmd.args(["CODE_SIGNING_REQUIRED=NO", "CODE_SIGNING_ALLOWED=NO"]); + // Ad-hoc packaging on CI needs the app target to skip both signing + // and product validation; otherwise Xcode exits 65 after emitting a + // partial .app bundle with no executable. + cmd.args([ + "VALIDATE_PRODUCT=NO", + "CODE_SIGN_STYLE=Manual", + "CODE_SIGN_IDENTITY=", + "CODE_SIGNING_ALLOWED=NO", + "CODE_SIGNING_REQUIRED=NO", + "DEVELOPMENT_TEAM=", + "PROVISIONING_PROFILE_SPECIFIER=", + ]); } SigningMethod::Development => { // Development signing (requires Apple Developer account) @@ -1612,18 +1621,57 @@ impl IosBuilder { println!(" App bundle created successfully at {:?}", app_path); } + let build_log_path = export_path.join("ipa-build.log"); if let Ok(output) = &build_result && !output.status.success() { + let mut log = String::new(); + log.push_str("STDOUT:\n"); + log.push_str(&String::from_utf8_lossy(&output.stdout)); + log.push_str("\n\nSTDERR:\n"); + log.push_str(&String::from_utf8_lossy(&output.stderr)); + let _ = fs::write(&build_log_path, log); println!( - "Warning: xcodebuild exited with {} but produced {}. Validating the bundle before continuing.", + "Warning: xcodebuild exited with {} but produced {}. Validating the bundle before continuing. Log: {}", output.status, - app_path.display() + app_path.display(), + build_log_path.display() ); } let source_info_plist = ios_dir.join(scheme).join("Info.plist"); - self.ensure_device_app_bundle_metadata(&app_path, &source_info_plist, scheme)?; + if let Err(bundle_err) = + self.ensure_device_app_bundle_metadata(&app_path, &source_info_plist, scheme) + { + if let Ok(output) = &build_result + && !output.status.success() + { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BenchError::Build(format!( + "xcodebuild build produced an incomplete app bundle.\n\n\ + Project: {}\n\ + Scheme: {}\n\ + Configuration: {}\n\ + Derived data: {}\n\ + Exit status: {}\n\ + Log: {}\n\n\ + Bundle validation: {}\n\n\ + Stdout:\n{}\n\n\ + Stderr:\n{}", + project_path.display(), + scheme, + build_configuration, + build_dir.display(), + output.status, + build_log_path.display(), + bundle_err, + stdout, + stderr + ))); + } + return Err(bundle_err); + } if matches!(method, SigningMethod::AdHoc) { let profile = find_provisioning_profile(); From 7e0075ea520fbe2b9d04b71eb1a2087b757cfec5 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 22:46:19 -0700 Subject: [PATCH 157/196] fix: add semantic phases to ffi fixture report --- examples/ffi-benchmark/src/lib.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/examples/ffi-benchmark/src/lib.rs b/examples/ffi-benchmark/src/lib.rs index 2703164..3842dec 100644 --- a/examples/ffi-benchmark/src/lib.rs +++ b/examples/ffi-benchmark/src/lib.rs @@ -48,11 +48,19 @@ pub struct BenchSample { pub duration_ns: u64, } +/// A semantic phase emitted by the benchmark runner. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct SemanticPhase { + pub name: String, + pub duration_ns: u64, +} + /// Complete benchmark report with spec and timing samples. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct BenchReport { pub spec: BenchSpec, pub samples: Vec, + pub phases: Vec, } /// Error types for benchmark operations. @@ -101,11 +109,21 @@ impl From for BenchSample { } } +impl From for SemanticPhase { + fn from(phase: mobench_sdk::SemanticPhase) -> Self { + Self { + name: phase.name, + duration_ns: phase.duration_ns, + } + } +} + impl From for BenchReport { fn from(report: mobench_sdk::RunnerReport) -> Self { Self { spec: report.spec.into(), samples: report.samples.into_iter().map(Into::into).collect(), + phases: report.phases.into_iter().map(Into::into).collect(), } } } From 2cb22a7ffdd8be1853d41ddccfb754b9b3ecf6ca Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 23:03:48 -0700 Subject: [PATCH 158/196] docs: move release history into release notes --- BUILD.md | 3 +- CLAUDE.md | 3 +- PROJECT_PLAN.md | 82 ------ README.md | 114 +-------- RELEASE_NOTES.md | 323 ++++++++++++++++++++++++ TESTING.md | 3 +- crates/mobench/README.md | 9 +- docs/CONTRACT_CI_V1.md | 121 --------- docs/MIGRATION_GUIDE.md | 9 +- docs/adr/0001-mobench-ci-contract-v1.md | 76 ------ 10 files changed, 345 insertions(+), 398 deletions(-) delete mode 100644 PROJECT_PLAN.md create mode 100644 RELEASE_NOTES.md delete mode 100644 docs/CONTRACT_CI_V1.md delete mode 100644 docs/adr/0001-mobench-ci-contract-v1.md diff --git a/BUILD.md b/BUILD.md index 1736a4f..ac1c955 100644 --- a/BUILD.md +++ b/BUILD.md @@ -550,4 +550,5 @@ profiling through BrowserStack remains explicitly unsupported. - **`TESTING.md`**: Comprehensive testing guide with troubleshooting - **`README.md`**: Project overview and quick start - **`CLAUDE.md`**: Developer guide for this codebase -- **`PROJECT_PLAN.md`**: Architecture and roadmap +- **`docs/codebase/ARCHITECTURE.md`**: Current architecture reference +- **`RELEASE_NOTES.md`**: Published release history and support status diff --git a/CLAUDE.md b/CLAUDE.md index c60107d..cfd0580 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -799,7 +799,8 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) - **`BUILD.md`**: Complete build reference with prerequisites and troubleshooting - **`TESTING.md`**: Comprehensive testing guide with detailed troubleshooting - **`BENCH_SDK_INTEGRATION.md`**: Integration guide for SDK users -- **`PROJECT_PLAN.md`**: Goals, architecture, task backlog +- **`docs/codebase/ARCHITECTURE.md`**: Current architecture reference +- **`RELEASE_NOTES.md`**: Published release history and support status - **`CLAUDE.md`**: This file - developer guide for the codebase ### Build Tooling diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md deleted file mode 100644 index 025a4dd..0000000 --- a/PROJECT_PLAN.md +++ /dev/null @@ -1,82 +0,0 @@ -# Mobile Bench RS – Plan - -## Goals - -- Package arbitrary Rust functions into Android (Kotlin) and iOS (Swift) binaries. -- Drive builds and benchmark runs via a Rust CLI that works locally and in GitHub Actions. -- Execute binaries on real devices through BrowserStack AppAutomate, collecting timing/telemetry and artifacts. -- Produce repeatable, configurable runs (device matrix, iterations, warmups) with exportable reports. - -## Non-Goals (for now) - -- Desktop or web benchmarks. -- Perf profiling beyond timing/throughput (e.g., flamegraphs, memory sampling). -- Real-time dashboards; focus on generated reports and CI annotations first. - -## Architecture Outline - -- `mobench`: CLI tool that orchestrates builds, packaging, upload, AppAutomate sessions, and result collation. -- `mobench-sdk`: Core SDK library with timing harness (consolidated from the former `mobench-runner`), builders, registry, and codegen. Compiled into mobile libs; exposes FFI entrypoints for target functions and collects timings. -- `mobench-macros`: Proc macro crate providing the `#[benchmark]` attribute for marking functions. -- Mobile bindings: - - Android: Kotlin wrapper + APK test harness embedding Rust lib (cargo-ndk); uses Espresso/Appium-style entrypoints for AppAutomate. - - iOS: Swift wrapper + test host app/xcframework; invokes Rust via C-ABI bindings. -- CI: GitHub Actions workflows for build (per target), upload to BrowserStack, run matrix, fetch reports, and publish summary. - -## MVP Scope - -- Benchmark a single exported Rust function with configurable iterations. -- Build Android APK + iOS app/xcframework locally and in CI. -- Trigger one Android device run on BrowserStack and capture timing JSON. -- CLI command: `mobench run --target android --function path::to::fn --devices "Google Pixel 7-13.0"` producing a report. - -## Task Backlog - -- [x] Repo bootstrap: Cargo workspace, `mobench` CLI crate, `mobench-sdk` library crate (timing module consolidated from former `mobench-runner`), `mobench-macros` proc macro crate, example `sample-fns` crate. -- [x] Define FFI boundary: macro/attribute to mark benchmarkable Rust functions; export through C ABI; basic timing harness. -- [x] Android packaging: cargo-ndk config, Kotlin wrapper module, minimal test/activity to trigger Rust bench entrypoint. -- [x] iOS packaging: xcframework build script (cargo lipo or cargo-apple), C header generation (cbindgen), Swift wrapper, test host. -- [x] CLI scaffolding: parse config (function path, iterations, warmups, device matrix), invoke build scripts, prepare artifacts. -- [x] BrowserStack integration: AppAutomate REST client (upload builds, start sessions, poll status, download logs/artifacts). -- [x] Result handling: normalize timing output to JSON, aggregate across iterations/devices, emit markdown/CSV summary. -- [x] CI: GitHub Actions workflow covering build, artifact upload, BrowserStack-triggered run (behind secrets), and report upload. -- [x] Developer UX: local smoke test runners, sample bench functions, docs with step-by-step usage. -- [x] Add markdown + CSV summary output for `mobench run` results. -- [x] Wire device matrix config into `mobench run` (load devices by tag). -- [x] Replace BrowserStack stub run in CI with real AppAutomate run and fetch. -- [x] Add GH Actions summary/annotations for benchmark results. -- [x] Add regression comparison command (compare two JSON summaries). - -## DX Improvements (v2) - Completed - -### SDK Improvements -- [x] `#[benchmark]` macro validates function signature at compile time (no params, returns `()`) -- [x] Helpful compile errors with suggestions for fixing signature issues -- [x] `debug_benchmarks!()` macro for debugging registration issues -- [x] Better error messages: `UnknownFunction` shows available benchmarks -- [x] Better error messages: `TimingError::NoIterations` shows actual value provided -- [x] Quick Setup Checklist in SDK lib.rs documentation - -### New CLI Commands -- [x] `cargo mobench check` - Validates prerequisites (NDK, Xcode, Rust targets, etc.) -- [x] `cargo mobench verify` - Validates registry, spec, and artifacts with optional smoke test -- [x] `cargo mobench summary` - Displays result statistics (text/json/csv formats) -- [x] `cargo mobench devices` - Lists and validates BrowserStack devices - -### BrowserStack Improvements -- [x] Better credential error messages with 3 setup methods (env vars, config file, .env.local) -- [x] Artifact validation before upload (checks file exists and size) -- [x] Device fuzzy matching with suggestions for typos -- [x] Device validation via `--validate` flag - -## Suggested Next Tasks - -- [ ] Stretch: parallel device runs, retries, percentile stats, optional energy/thermal readings where available. -- [ ] Rich reporting dashboard (P2 from DX spec) -- [ ] Spec snapshots and result comparisons across builds (P2 from DX spec) - -## In-Repo Placeholders (current) - -- CLI: `cargo mobench build --target ` for manual/CI builds (requires Android NDK/Xcode as appropriate). -- Android demo app: `android/` Gradle project that loads the Rust demo cdylib (`sample-fns`) and displays results. -- Workflow: `.github/workflows/mobile-bench.yml` manual build for Android; extend with BrowserStack upload/run and iOS job. diff --git a/README.md b/README.md index 3112bf9..cd8a0b1 100644 --- a/README.md +++ b/README.md @@ -181,12 +181,10 @@ CLI flags override config file values when provided. - `BUILD.md`: build prerequisites and troubleshooting - `TESTING.md`: testing guide and device workflows - `BROWSERSTACK_CI_INTEGRATION.md`: BrowserStack CI setup -- `docs/CONTRACT_CI_V1.md`: frozen v1 CI input/output/error contract -- `docs/adr/0001-mobench-ci-contract-v1.md`: CI contract ADR and compatibility policy +- `RELEASE_NOTES.md`: published release history and support status - `docs/schemas/`: machine-readable CI/summary schema artifacts -- `docs/MIGRATION_GUIDE.md`: migration guide (placeholder, linked from ADR) +- `docs/MIGRATION_GUIDE.md`: migration notes for CI and reporting changes - `FETCH_RESULTS_GUIDE.md`: fetching and summarizing results -- `PROJECT_PLAN.md`: goals and backlog - `CLAUDE.md`: developer guide ## Setup and Teardown @@ -262,109 +260,9 @@ fn db_query(db: &Database) { ## Release Notes -### v0.1.25 - -- Clarified that profiling remains local-first in this release; BrowserStack native profiling is explicitly unsupported with actionable error text and a visible capability matrix. -- Split `profile run` into target resolution, capture planning, and capture execution seams so planned manifests no longer imply that native capture actually ran. -- Added device-selection inputs to `profile run` (`--device`, `--os-version`, `--profile`, `--device-matrix`) by reusing the existing deterministic device-resolution flow. -- Added real local iOS native capture via simulator-host `sample`, with `sample.txt`, `stacks.folded`, `native-report.txt`, and `flamegraph.html` written into the normalized profile session layout. -- Added regression coverage for profile help text, BrowserStack unsupported execution, dry-run planning semantics, and direct device target resolution. -- Added `cargo mobench profile run|summarize` commands for a normalized local profiling session contract across Android and iOS. -- Added the interactive dual-view flamegraph viewer plus full/focused SVG artifacts for local native profile runs. -- Profile sessions now write run-scoped artifacts under `target/mobench/profile//` and refresh top-level latest-session `profile.json` and `summary.md` convenience files. -- Profile manifests now preserve the selected provider and requested output format, and the CLI rejects unsupported format/backend combinations explicitly instead of silently planning the wrong artifacts. -- Updated the profiling smoke-test docs to use working `cargo run -p mobench --bin mobench -- ...` invocations from the repo root. -- Stabilized the SDK timing test suite by removing a timer-resolution assumption from the noop benchmark test. - -### v0.1.24 - -- Switched BrowserStack device discovery to the unified `app-automate/devices.json` inventory for Android, iOS, and combined device listing. -- Filtered unified BrowserStack inventory results locally by OS so Espresso resolution stays Android-only and XCUITest resolution stays iOS-only. -- Added regression coverage for mixed Android+iOS BrowserStack inventories used by device-resolution commands. - -### v0.1.23 - -- Added Sina-style per-function device comparison plots to local summaries: - - `cargo mobench ci run --plots ` - - `cargo mobench report summarize --plots ` -- Rendered one SVG plot per benchmark function in the `Device Comparison Plots` section of local markdown summaries. -- Switched summary resource reporting to `cpu_total_ms` and `peak_memory_kb`, and preserved BrowserStack-derived peak memory while backfilling CPU from raw benchmark results. -- Enabled BrowserStack app profiling on Android and iOS runs, including App Profiling v2 parsing for iOS peak-memory enrichment. -- Added baseline artifact download in the reusable CI workflow so `ci check-run` can compare PR results against the latest successful default-branch run. - -### v0.1.22 - -- Fixed BrowserStack result fetching so `cargo mobench ci run --fetch` falls back to downloaded session artifacts when live device logs do not expose benchmark JSON. -- Unified benchmark extraction across live logs, `bench-report.json`, iOS marker logs, and Android `BENCH_JSON` logs so per-function CI summaries are written with populated benchmark data. -- Fixed merged CI output generation to preserve every function under each target and emit a top-level `summary` for single-target runs. -- Fixed `cargo-mobench ci summarize` to read merged `{targets, ci}` outputs, recurse through nested target/function result directories, and fall back to raw `bench-report.json` when needed. - -### v0.1.21 - -- Added a shared config-first project resolver across `build`, `run`, packaging, `list`, and `verify`. -- Added `--project-root` and `--crate-path` parity across the main CLI commands for custom repository layouts. -- `build --progress` now respects `mobench.toml` instead of assuming `bench-mobile`. -- Dotenv loading now follows the resolved project root and config path. -- `list` now discovers benchmarks from configured external crates instead of only legacy sample layouts. -- `verify --smoke-test` now reports external-crate smoke tests as unsupported instead of failing with an empty benchmark list. - -### v0.1.14 - -- Added CI contract-oriented commands and workflows: - - `cargo mobench ci run` - - `cargo mobench config validate` - - `cargo mobench devices resolve` - - `cargo mobench fixture init|build|verify|cache-key` - - `cargo mobench report summarize|github` -- Standardized CI outputs under `target/mobench/ci/` with schema-backed metadata. -- Added baseline comparison source support (`path|url|artifact:`) and regression labels. -- Improved local action safety for workflow input handling and sticky PR comment publishing. -- Fixed iOS CI target setup (`x86_64-apple-ios`) and preserved CI outputs on regression exit. - -### v0.1.13 - -- **Setup and teardown support**: `#[benchmark]` macro now supports `setup`, `teardown`, and `per_iteration` attributes for excluding expensive initialization from timing measurements - ```rust - fn setup_data() -> Vec { vec![0u8; 10_000_000] } - - #[benchmark(setup = setup_data)] - fn process_data(data: &Vec) { - // Only this is measured, not the setup - } - ``` -- **New `check` command**: Validates prerequisites (NDK, Xcode, Rust targets, etc.) before building - ```bash - cargo mobench check --target android - cargo mobench check --target ios - ``` -- **New `verify` command**: Validates registry, spec, and artifacts -- **New `summary` command**: Displays benchmark result statistics (avg/min/max/median) -- **New `devices` command**: Lists available BrowserStack devices with validation -- **`--progress` flag**: Simplified step-by-step output for `build` and `run` commands -- **Consolidated `mobench-runner` into `mobench-sdk`**: The timing harness is now part of `mobench-sdk` as the `timing` module, simplifying the dependency graph -- **SDK improvements**: - - `#[benchmark]` macro now validates function signature at compile time (no params, returns `()`) - - New `debug_benchmarks!()` macro for verifying benchmark registration - - Better error messages with available benchmarks list -- **BrowserStack improvements**: - - Better credential error messages with setup instructions - - Artifact pre-flight validation before uploads - - Upload progress indication with file sizes - - Dashboard link printed immediately when build starts - - Improved device fuzzy matching with suggestions -- **Fix iOS XCUITest test name mismatch**: Changed BrowserStack `only-testing` filter to use `testLaunchAndCaptureBenchmarkReport` - -### v0.1.12 - -- **Fix iOS XCUITest BrowserStack detection**: Added Info.plist to the UITests target template, resolving issues where BrowserStack could not properly detect and run XCUITest bundles -- **Improved video capture for BrowserStack**: Increased post-benchmark delay from 0.5s to 5.0s to ensure benchmark results are captured in BrowserStack video recordings -- **Better UX during benchmark runs**: iOS app now shows "Running benchmarks..." text before results appear, providing visual feedback during execution -- **Template sync**: Synchronized top-level iOS/Android templates with SDK-embedded templates for consistency - -### v0.1.11 - -- Initial public release with `--release` flag support -- `package-xcuitest` command for iOS BrowserStack testing -- Updated mobile timing display and documentation +Published release history and support status live in +[`RELEASE_NOTES.md`](RELEASE_NOTES.md). Only `v0.1.25` is currently treated as +supported; earlier crates.io publishes are retained there as historical test +builds and should not be used. MIT licensed — World Foundation 2026. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..e1226c9 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,323 @@ +# Release Notes + +`mobench` and `mobench-sdk` were published rapidly during bring-up. Only the +current release line should be treated as supported. Every earlier crates.io +publish is retained here for auditability, but unless noted otherwise it should +be treated as a test build and should not be used for new integrations. + +Crates.io release history: +- [mobench](https://crates.io/crates/mobench) +- [mobench-sdk](https://crates.io/crates/mobench-sdk) + +## Support Policy + +- `v0.1.25` is the current supported release. +- Every earlier published version is a historical test build and should not be + used. +- Yanked versions are explicitly called out below. + +## Published Version History + +| Version | Published | Published crates | Status | +|---------|-----------|------------------|--------| +| `v0.1.25` | 2026-03-26 | `mobench 0.1.25`, `mobench-sdk 0.1.25` | Current supported release | +| `v0.1.24` | 2026-03-26 | `mobench 0.1.24`, `mobench-sdk 0.1.24` | Test build. Do not use. | +| `v0.1.23` | 2026-03-26 | `mobench 0.1.23`, `mobench-sdk 0.1.23` | Test build. Do not use. | +| `v0.1.22` | 2026-03-25 | `mobench 0.1.22`, `mobench-sdk 0.1.22` | Test build. Do not use. | +| `v0.1.21` | 2026-03-24 | `mobench 0.1.21`, `mobench-sdk 0.1.21` | Test build. Do not use. | +| `v0.1.20` | 2026-03-24 | `mobench 0.1.20`, `mobench-sdk 0.1.20` | Test build. Do not use. | +| `v0.1.19` | 2026-03-24 | `mobench 0.1.19`, `mobench-sdk 0.1.19` | Test build. Do not use. | +| `v0.1.18` | 2026-03-24 | `mobench 0.1.18`, `mobench-sdk 0.1.18` | Test build. Do not use. | +| `v0.1.17` | 2026-03-23 | `mobench 0.1.17`, `mobench-sdk 0.1.17` | Test build. Do not use. | +| `v0.1.16` | 2026-03-18 | `mobench 0.1.16`, `mobench-sdk 0.1.16` | Test build. Do not use. | +| `v0.1.15-patch-1` | 2026-03-12 | `mobench 0.1.15-patch-1` | Test build. Do not use. | +| `v0.1.15` | 2026-03-06 | `mobench 0.1.15`, `mobench-sdk 0.1.15` | Test build. Do not use. | +| `v0.1.14` | 2026-02-16 | `mobench 0.1.14`, `mobench-sdk 0.1.14` | Test build. Do not use. | +| `v0.1.13` | 2026-01-21 | `mobench 0.1.13`, `mobench-sdk 0.1.13` | Test build. Do not use. | +| `v0.1.12` | 2026-01-20 | `mobench 0.1.12`, `mobench-sdk 0.1.12` | Test build. Do not use. | +| `v0.1.11` | 2026-01-19 | `mobench 0.1.11`, `mobench-sdk 0.1.11` | Test build. Do not use. | +| `v0.1.10` | 2026-01-19 | `mobench 0.1.10`, `mobench-sdk 0.1.10` | Test build. Do not use. | +| `v0.1.9` | 2026-01-19 | `mobench 0.1.9`, `mobench-sdk 0.1.9` | Test build. Do not use. | +| `v0.1.8` | 2026-01-19 | `mobench 0.1.8`, `mobench-sdk 0.1.8` | Test build. Do not use. | +| `v0.1.7` | 2026-01-19 | `mobench 0.1.7`, `mobench-sdk 0.1.7` | Test build. Do not use. | +| `v0.1.6` | 2026-01-16 | `mobench 0.1.6`, `mobench-sdk 0.1.6` | Test build. Do not use. | +| `v0.1.5` | 2026-01-15 | `mobench 0.1.5`, `mobench-sdk 0.1.5` | Test build. Do not use. | +| `v0.1.4` | 2026-01-14 | `mobench 0.1.4`, `mobench-sdk 0.1.4` | Test build. Do not use. | +| `v0.1.3` | 2026-01-14 | `mobench 0.1.3`, `mobench-sdk 0.1.3` | Yanked test build. Do not use. | +| `v0.1.2` | 2026-01-14 | `mobench 0.1.2`, `mobench-sdk 0.1.2` | Yanked test build. Do not use. | +| `v0.1.1` | 2026-01-13 | `mobench 0.1.1`, `mobench-sdk 0.1.1` | Yanked test build. Do not use. | +| `v0.1.0` | 2026-01-13 | `mobench 0.1.0`, `mobench-sdk 0.1.0` | Yanked test build. Do not use. | + +## v0.1.25 + +Status: current supported release. + +- Clarified that profiling remains local-first in this release; BrowserStack + native profiling is explicitly unsupported with actionable error text and a + visible capability matrix. +- Split `profile run` into target resolution, capture planning, and capture + execution seams so planned manifests no longer imply that native capture + actually ran. +- Added device-selection inputs to `profile run` (`--device`, `--os-version`, + `--profile`, `--device-matrix`) by reusing the existing deterministic + device-resolution flow. +- Added real local iOS native capture via simulator-host `sample`, with + `sample.txt`, `stacks.folded`, `native-report.txt`, and `flamegraph.html` + written into the normalized profile session layout. +- Added regression coverage for profile help text, BrowserStack unsupported + execution, dry-run planning semantics, and direct device target resolution. +- Added `cargo mobench profile run|summarize` commands for a normalized local + profiling session contract across Android and iOS. +- Added the interactive dual-view flamegraph viewer plus full and focused SVG + artifacts for local native profile runs. +- Profile sessions now write run-scoped artifacts under + `target/mobench/profile//` and refresh top-level latest-session + `profile.json` and `summary.md` convenience files. +- Profile manifests now preserve the selected provider and requested output + format, and the CLI rejects unsupported format/backend combinations + explicitly instead of silently planning the wrong artifacts. +- Updated the profiling smoke-test docs to use working + `cargo run -p mobench --bin mobench -- ...` invocations from the repo root. +- Stabilized the SDK timing test suite by removing a timer-resolution + assumption from the noop benchmark test. + +## v0.1.24 + +Status: test build. Do not use. + +- Switched BrowserStack device discovery to the unified + `app-automate/devices.json` inventory for Android, iOS, and combined device + listing. +- Filtered unified BrowserStack inventory results locally by OS so Espresso + resolution stays Android-only and XCUITest resolution stays iOS-only. +- Added regression coverage for mixed Android+iOS BrowserStack inventories used + by device-resolution commands. + +## v0.1.23 + +Status: test build. Do not use. + +- Added Sina-style per-function device comparison plots to local summaries: + `cargo mobench ci run --plots ` and + `cargo mobench report summarize --plots `. +- Rendered one SVG plot per benchmark function in the `Device Comparison Plots` + section of local markdown summaries. +- Switched summary resource reporting to `cpu_total_ms` and `peak_memory_kb`, + and preserved BrowserStack-derived peak memory while backfilling CPU from raw + benchmark results. +- Enabled BrowserStack app profiling on Android and iOS runs, including App + Profiling v2 parsing for iOS peak-memory enrichment. +- Added baseline artifact download in the reusable CI workflow so + `ci check-run` can compare PR results against the latest successful + default-branch run. + +## v0.1.22 + +Status: test build. Do not use. + +- Fixed BrowserStack result fetching so `cargo mobench ci run --fetch` falls + back to downloaded session artifacts when live device logs do not expose + benchmark JSON. +- Unified benchmark extraction across live logs, `bench-report.json`, iOS + marker logs, and Android `BENCH_JSON` logs so per-function CI summaries are + written with populated benchmark data. +- Fixed merged CI output generation to preserve every function under each + target and emit a top-level `summary` for single-target runs. +- Fixed `cargo-mobench ci summarize` to read merged `{targets, ci}` outputs, + recurse through nested target and function result directories, and fall back + to raw `bench-report.json` when needed. + +## v0.1.21 + +Status: test build. Do not use. + +- Added a shared config-first project resolver across `build`, `run`, + packaging, `list`, and `verify`. +- Added `--project-root` and `--crate-path` parity across the main CLI commands + for custom repository layouts. +- `build --progress` now respects `mobench.toml` instead of assuming + `bench-mobile`. +- Dotenv loading now follows the resolved project root and config path. +- `list` now discovers benchmarks from configured external crates instead of + only legacy sample layouts. +- `verify --smoke-test` now reports external-crate smoke tests as unsupported + instead of failing with an empty benchmark list. + +## v0.1.20 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.19 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.18 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.17 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.16 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.15-patch-1 + +Status: test build. Do not use. + +- Published only for `mobench`; there was no matching `mobench-sdk` release for + this version tag. +- No supported release notes were maintained for this build. + +## v0.1.15 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.14 + +Status: test build. Do not use. + +- Added CI contract-oriented commands and workflows: + `cargo mobench ci run`, `cargo mobench config validate`, + `cargo mobench devices resolve`, `cargo mobench fixture init|build|verify|cache-key`, + and `cargo mobench report summarize|github`. +- Standardized CI outputs under `target/mobench/ci/` with schema-backed + metadata. +- Added baseline comparison source support (`path|url|artifact:`) and + regression labels. +- Improved local action safety for workflow input handling and sticky PR comment + publishing. +- Fixed iOS CI target setup (`x86_64-apple-ios`) and preserved CI outputs on + regression exit. + +## v0.1.13 + +Status: test build. Do not use. + +- Added setup and teardown support to `#[benchmark]` via `setup`, `teardown`, + and `per_iteration` attributes. +- Added `cargo mobench check`, `cargo mobench verify`, `cargo mobench summary`, + and `cargo mobench devices`. +- Added `--progress` output for `build` and `run`. +- Consolidated `mobench-runner` into `mobench-sdk`. +- Improved SDK compile-time validation and benchmark debug helpers. +- Improved BrowserStack credential, upload, and device-matching UX. +- Fixed the iOS XCUITest BrowserStack `only-testing` filter to use + `testLaunchAndCaptureBenchmarkReport`. + +## v0.1.12 + +Status: test build. Do not use. + +- Fixed iOS XCUITest BrowserStack detection by adding `Info.plist` to the + UITests target template. +- Increased BrowserStack post-benchmark delay to improve video capture of + benchmark results. +- Added visible “Running benchmarks...” feedback during iOS benchmark runs. +- Synchronized top-level iOS and Android templates with the SDK-embedded + templates. + +## v0.1.11 + +Status: test build. Do not use. + +- Initial public release with `--release` flag support. +- Added `package-xcuitest` for iOS BrowserStack testing. +- Updated mobile timing display and documentation. + +## v0.1.10 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.9 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.8 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.7 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.6 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.5 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.4 + +Status: test build. Do not use. + +- Published to crates.io as part of the pre-release validation cycle. +- No supported release notes were maintained for this build. + +## v0.1.3 + +Status: yanked test build. Do not use. + +- Yanked from crates.io. +- No supported release notes were maintained for this build. + +## v0.1.2 + +Status: yanked test build. Do not use. + +- Yanked from crates.io. +- No supported release notes were maintained for this build. + +## v0.1.1 + +Status: yanked test build. Do not use. + +- Yanked from crates.io. +- No supported release notes were maintained for this build. + +## v0.1.0 + +Status: yanked test build. Do not use. + +- Yanked from crates.io. +- No supported release notes were maintained for this build. diff --git a/TESTING.md b/TESTING.md index bc8f958..bc1d1f0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -733,5 +733,6 @@ To trigger manually: - [UniFFI Documentation](https://mozilla.github.io/uniffi-rs/) - [Android NDK Documentation](https://developer.android.com/ndk) - [Rust Cross-Compilation Guide](https://rust-lang.github.io/rustup/cross-compilation.html) -- [PROJECT_PLAN.md](PROJECT_PLAN.md) - Roadmap and architecture +- [docs/codebase/ARCHITECTURE.md](docs/codebase/ARCHITECTURE.md) - Current architecture reference +- [RELEASE_NOTES.md](RELEASE_NOTES.md) - Published release history and support status - [CLAUDE.md](CLAUDE.md) - Developer guide for this codebase diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 7d17a59..1a78d23 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -270,10 +270,11 @@ cargo mobench ci run --target --function [OPTIONS] - `mobench_ref` - `mobench_version` -Contract references: -- `docs/CONTRACT_CI_V1.md` -- `docs/schemas/summary-v1.schema.json` -- `docs/schemas/ci-contract-v1.schema.json` +Stable output references: +- `https://github.com/worldcoin/mobile-bench-rs/blob/dev/README.md` CI section +- `https://github.com/worldcoin/mobile-bench-rs/blob/dev/docs/schemas/summary-v1.schema.json` +- `https://github.com/worldcoin/mobile-bench-rs/blob/dev/docs/schemas/ci-contract-v1.schema.json` +- `https://github.com/worldcoin/mobile-bench-rs/blob/dev/RELEASE_NOTES.md` **Example:** ```bash diff --git a/docs/CONTRACT_CI_V1.md b/docs/CONTRACT_CI_V1.md deleted file mode 100644 index 4cc9fc5..0000000 --- a/docs/CONTRACT_CI_V1.md +++ /dev/null @@ -1,121 +0,0 @@ -# Mobench CI Contract v1 - -## Status - -- Version: `v1` -- Stability: Frozen for v1 consumers -- Effective date: 2026-02-16 - -## Scope - -This document defines the stable contract for `cargo mobench ci run`. - -It covers: -- Input contract (CLI/environment inputs accepted by `ci run`) -- Output contract (`summary.json`, `summary.md`, `results.csv`) -- Error taxonomy categories used by CI-focused validation commands - -## Input Contract - -### Required CLI inputs - -- `--target ` -- `--function ` - -### Optional execution inputs - -- Iteration controls: `--iterations`, `--warmup` -- Device selection: `--devices`, `--device-matrix`, `--device-tags` -- Runtime mode: `--local-only`, `--release`, `--fetch` -- Local summary rendering: `--plots ` -- iOS artifacts: `--ios-app`, `--ios-test-suite` -- Regression mode: `--baseline`, `--regression-threshold-pct` -- Output path: `--output-dir` - -Behavior notes: -- In config-driven runs (`--config`), `--device-matrix` overrides the matrix path from config when both are provided. -- If `--baseline` resolves to the same file as the candidate output, mobench snapshots the previous baseline file before writing the candidate summary so regression comparison remains valid. - -### Optional metadata inputs - -Metadata can be provided via flags or CI environment discovery: - -- `requested_by` (`--requested-by`, `MOBENCH_REQUESTED_BY`, `GITHUB_ACTOR`) -- `pr_number` (`--pr-number`, `MOBENCH_PR_NUMBER`, `PR_NUMBER`, `GITHUB_PR_NUMBER`, `GITHUB_PULL_REQUEST_NUMBER`, or parsed from `GITHUB_REF`) -- `request_command` (`--request-command`, fallback to argv) -- `mobench_ref` (`--mobench-ref`, `MOBENCH_REF`, `GITHUB_SHA`, `GITHUB_REF`) -- `mobench_version` (derived from package version) - -## Output Contract - -Default directory: `target/mobench/ci/` - -Required files: -- `summary.json` -- `summary.md` -- `results.csv` - -Optional additive artifacts: -- `plots/*.svg` when local plot rendering is enabled for `ci run` - -`summary.json` MUST include: -- run summary data -- `ci.metadata` object with: - - `requested_by` - - `pr_number` (optional) - - `request_command` - - `mobench_ref` (optional) - - `mobench_version` -- `ci.outputs` object with: - - `summary_json` - - `summary_md` - - `results_csv` - -Additive summary fields currently emitted by v1 include: -- per-benchmark `resource_usage.cpu_total_ms` -- per-benchmark `resource_usage.peak_memory_kb` -- optional raw memory breakdown fields such as `total_pss_kb`, `private_dirty_kb`, `native_heap_kb`, and `java_heap_kb` - -Behavior notes: -- `summary.md` may contain relative image links into `plots/` for local viewing. These links are additive and are not required for contract consumers. -- The reusable workflow may resolve a baseline from the latest successful default-branch run and pass it explicitly to `ci check-run`; this does not change the required output set. - -Machine-readable schema artifacts: -- `docs/schemas/summary-v1.schema.json` -- `docs/schemas/ci-contract-v1.schema.json` - -## Error Taxonomy - -The following categories are used for contract-aligned checks: -- `config_error` -- `preflight_error` -- `provider_error` -- `build_error` -- `benchmark_error` - -Current command mapping: -- `cargo mobench doctor` and `cargo mobench config validate` emit category-aligned issues for config/preflight/provider failures. -- Build/benchmark failures remain surfaced by run/build/report commands and can be mapped by callers into the same taxonomy. - -## Breaking-Change Policy (v1) - -A change is breaking if it modifies or removes: -- required output filenames -- required metadata keys -- required schema fields/types - -Breaking changes require: -1. New versioned contract docs/schema files. -2. Backward-compatibility note in release notes. -3. Migration guidance update. - -## Compatibility Window - -- `v1` outputs and metadata are maintained for at least one minor release window after any successor is introduced. -- Additive fields are allowed in `summary.json` as long as required keys remain stable. - -## Non-goals - -- Defining provider-specific BrowserStack API payload formats. -- Real-time dashboard protocols. -- Enforcing thresholds by default in v1 reporting. diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index 90005c5..1a86641 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -86,8 +86,9 @@ jobs: ## Compatibility Notes -- Contract docs: `docs/CONTRACT_CI_V1.md` -- ADR: `docs/adr/0001-mobench-ci-contract-v1.md` -- Schemas: `docs/schemas/summary-v1.schema.json`, `docs/schemas/ci-contract-v1.schema.json` +- Versioned schemas: `docs/schemas/summary-v1.schema.json`, `docs/schemas/ci-contract-v1.schema.json` +- Current release history and support status: `RELEASE_NOTES.md` +- Current implementation reference: `README.md` and `docs/codebase/` -Any change to required output files or metadata keys requires a contract-version bump. +Any change to required output files or metadata keys requires updating the +versioned schemas and documenting the compatibility impact in `RELEASE_NOTES.md`. diff --git a/docs/adr/0001-mobench-ci-contract-v1.md b/docs/adr/0001-mobench-ci-contract-v1.md deleted file mode 100644 index 0394dcf..0000000 --- a/docs/adr/0001-mobench-ci-contract-v1.md +++ /dev/null @@ -1,76 +0,0 @@ -# ADR 0001: Mobench CI Contract v1 - -- Date: 2026-02-16 -- Status: Accepted -- Decision owners: Engineering / Mobench - -## Context - -Mobench CI integrations need a stable interface for inputs, outputs, and error categories so that workflows and PR reporting do not break across iterative CLI improvements. - -## Decision - -Adopt `v1` CI contract documented in `docs/CONTRACT_CI_V1.md` and versioned schemas under `docs/schemas/`. - -### Scope boundaries - -Included: -- `cargo mobench ci run` contract -- output files and metadata fields -- error taxonomy categories for CI-oriented validation -- deterministic device-matrix override semantics (`--device-matrix` over config value when both are provided) -- baseline comparison safety when baseline and candidate paths overlap -- additive local summary plot artifacts and resource-usage fields that do not modify the required v1 output set - -Excluded (non-goals): -- provider-specific API payloads -- dashboard/event streaming protocols -- default threshold gating policy changes - -### Versioning strategy - -- Contract artifacts are versioned as `vN`. -- Output schema and required metadata are append-only within a major contract version. -- Any removal/rename/type-breaking change requires a new contract version. - -### Action interface versioning - -- GitHub Action references use semantic tags plus immutable SHAs. -- Repository-local action examples must map to the same required output contract. - -### Deprecation policy - -- When introducing a successor to `v1`, keep `v1` compatibility for at least one minor release window. -- Mark deprecated fields/artifacts in docs before removal. - -### Reporting defaults - -- v1 default reporting mode is descriptive-only. -- Threshold gating remains explicit and opt-in. - -### Baseline default - -- The reusable workflow resolves the baseline from the latest successful default-branch run when matching artifacts are available. -- Pinned baseline artifacts remain supported explicitly through `--baseline`. - -### Minimum supported CI environments/toolchains - -- Linux: Ubuntu latest runner with Rust stable. -- macOS: macOS latest runner with Rust stable. -- Required Rust targets are documented in workflow templates. - -## Consequences - -Positive: -- Integrators get stable artifact paths and metadata contract. -- CI tooling can rely on fixed machine-readable schema files. - -Tradeoffs: -- Requires explicit version bumps for contract evolution. -- Maintainers must update schema/tests/docs together. - -## Links - -- Contract: `docs/CONTRACT_CI_V1.md` -- Schemas: `docs/schemas/summary-v1.schema.json`, `docs/schemas/ci-contract-v1.schema.json` -- Migration guide placeholder: `docs/MIGRATION_GUIDE.md` From 518a547e791655a697bf1d9b93c0123b15046d0b Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 23:31:52 -0700 Subject: [PATCH 159/196] release: cut 0.1.26 --- Cargo.toml | 2 +- README.md | 6 +- RELEASE_NOTES.md | 77 ++++++++++++++++---------- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- docs/CONCERNS_RESOLUTION_2026-02-16.md | 74 ------------------------- 6 files changed, 53 insertions(+), 110 deletions(-) delete mode 100644 docs/CONCERNS_RESOLUTION_2026-02-16.md diff --git a/Cargo.toml b/Cargo.toml index 0b6e1ea..17b377b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.25" +version = "0.1.26" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index cd8a0b1..1966d95 100644 --- a/README.md +++ b/README.md @@ -261,8 +261,8 @@ fn db_query(db: &Database) { ## Release Notes Published release history and support status live in -[`RELEASE_NOTES.md`](RELEASE_NOTES.md). Only `v0.1.25` is currently treated as -supported; earlier crates.io publishes are retained there as historical test -builds and should not be used. +[`RELEASE_NOTES.md`](RELEASE_NOTES.md). Only the latest release listed there is +treated as supported; earlier crates.io publishes are retained there as +historical test builds and should not be used. MIT licensed — World Foundation 2026. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e1226c9..e56512d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,17 +1,19 @@ # Release Notes -`mobench` and `mobench-sdk` were published rapidly during bring-up. Only the -current release line should be treated as supported. Every earlier crates.io -publish is retained here for auditability, but unless noted otherwise it should -be treated as a test build and should not be used for new integrations. +`mobench`, `mobench-sdk`, and `mobench-macros` were published rapidly during +bring-up. Only the current release line should be treated as supported. Every +earlier crates.io publish is retained here for auditability, but unless noted +otherwise it should be treated as a test build and should not be used for new +integrations. Crates.io release history: - [mobench](https://crates.io/crates/mobench) - [mobench-sdk](https://crates.io/crates/mobench-sdk) +- [mobench-macros](https://crates.io/crates/mobench-macros) ## Support Policy -- `v0.1.25` is the current supported release. +- `v0.1.26` is the current supported release. - Every earlier published version is a historical test build and should not be used. - Yanked versions are explicitly called out below. @@ -20,38 +22,53 @@ Crates.io release history: | Version | Published | Published crates | Status | |---------|-----------|------------------|--------| -| `v0.1.25` | 2026-03-26 | `mobench 0.1.25`, `mobench-sdk 0.1.25` | Current supported release | -| `v0.1.24` | 2026-03-26 | `mobench 0.1.24`, `mobench-sdk 0.1.24` | Test build. Do not use. | -| `v0.1.23` | 2026-03-26 | `mobench 0.1.23`, `mobench-sdk 0.1.23` | Test build. Do not use. | -| `v0.1.22` | 2026-03-25 | `mobench 0.1.22`, `mobench-sdk 0.1.22` | Test build. Do not use. | -| `v0.1.21` | 2026-03-24 | `mobench 0.1.21`, `mobench-sdk 0.1.21` | Test build. Do not use. | -| `v0.1.20` | 2026-03-24 | `mobench 0.1.20`, `mobench-sdk 0.1.20` | Test build. Do not use. | -| `v0.1.19` | 2026-03-24 | `mobench 0.1.19`, `mobench-sdk 0.1.19` | Test build. Do not use. | -| `v0.1.18` | 2026-03-24 | `mobench 0.1.18`, `mobench-sdk 0.1.18` | Test build. Do not use. | -| `v0.1.17` | 2026-03-23 | `mobench 0.1.17`, `mobench-sdk 0.1.17` | Test build. Do not use. | -| `v0.1.16` | 2026-03-18 | `mobench 0.1.16`, `mobench-sdk 0.1.16` | Test build. Do not use. | +| `v0.1.26` | 2026-03-28 | `mobench 0.1.26`, `mobench-sdk 0.1.26`, `mobench-macros 0.1.26` | Current supported release | +| `v0.1.25` | 2026-03-26 | `mobench 0.1.25`, `mobench-sdk 0.1.25`, `mobench-macros 0.1.25` | Test build. Do not use. | +| `v0.1.24` | 2026-03-26 | `mobench 0.1.24`, `mobench-sdk 0.1.24`, `mobench-macros 0.1.24` | Test build. Do not use. | +| `v0.1.23` | 2026-03-26 | `mobench 0.1.23`, `mobench-sdk 0.1.23`, `mobench-macros 0.1.23` | Test build. Do not use. | +| `v0.1.22` | 2026-03-25 | `mobench 0.1.22`, `mobench-sdk 0.1.22`, `mobench-macros 0.1.22` | Test build. Do not use. | +| `v0.1.21` | 2026-03-24 | `mobench 0.1.21`, `mobench-sdk 0.1.21`, `mobench-macros 0.1.21` | Test build. Do not use. | +| `v0.1.20` | 2026-03-24 | `mobench 0.1.20`, `mobench-sdk 0.1.20`, `mobench-macros 0.1.20` | Test build. Do not use. | +| `v0.1.19` | 2026-03-24 | `mobench 0.1.19`, `mobench-sdk 0.1.19`, `mobench-macros 0.1.19` | Test build. Do not use. | +| `v0.1.18` | 2026-03-24 | `mobench 0.1.18`, `mobench-sdk 0.1.18`, `mobench-macros 0.1.18` | Test build. Do not use. | +| `v0.1.17` | 2026-03-23 | `mobench 0.1.17`, `mobench-sdk 0.1.17`, `mobench-macros 0.1.17` | Test build. Do not use. | +| `v0.1.16` | 2026-03-18 | `mobench 0.1.16`, `mobench-sdk 0.1.16`, `mobench-macros 0.1.16` | Test build. Do not use. | | `v0.1.15-patch-1` | 2026-03-12 | `mobench 0.1.15-patch-1` | Test build. Do not use. | -| `v0.1.15` | 2026-03-06 | `mobench 0.1.15`, `mobench-sdk 0.1.15` | Test build. Do not use. | -| `v0.1.14` | 2026-02-16 | `mobench 0.1.14`, `mobench-sdk 0.1.14` | Test build. Do not use. | -| `v0.1.13` | 2026-01-21 | `mobench 0.1.13`, `mobench-sdk 0.1.13` | Test build. Do not use. | -| `v0.1.12` | 2026-01-20 | `mobench 0.1.12`, `mobench-sdk 0.1.12` | Test build. Do not use. | -| `v0.1.11` | 2026-01-19 | `mobench 0.1.11`, `mobench-sdk 0.1.11` | Test build. Do not use. | -| `v0.1.10` | 2026-01-19 | `mobench 0.1.10`, `mobench-sdk 0.1.10` | Test build. Do not use. | -| `v0.1.9` | 2026-01-19 | `mobench 0.1.9`, `mobench-sdk 0.1.9` | Test build. Do not use. | -| `v0.1.8` | 2026-01-19 | `mobench 0.1.8`, `mobench-sdk 0.1.8` | Test build. Do not use. | -| `v0.1.7` | 2026-01-19 | `mobench 0.1.7`, `mobench-sdk 0.1.7` | Test build. Do not use. | -| `v0.1.6` | 2026-01-16 | `mobench 0.1.6`, `mobench-sdk 0.1.6` | Test build. Do not use. | +| `v0.1.15` | 2026-03-06 | `mobench 0.1.15`, `mobench-sdk 0.1.15`, `mobench-macros 0.1.15` | Test build. Do not use. | +| `v0.1.14` | 2026-02-16 | `mobench 0.1.14`, `mobench-sdk 0.1.14`, `mobench-macros 0.1.14` | Test build. Do not use. | +| `v0.1.13` | 2026-01-21 | `mobench 0.1.13`, `mobench-sdk 0.1.13`, `mobench-macros 0.1.13` | Test build. Do not use. | +| `v0.1.12` | 2026-01-20 | `mobench 0.1.12`, `mobench-sdk 0.1.12`, `mobench-macros 0.1.12` | Test build. Do not use. | +| `v0.1.11` | 2026-01-19 | `mobench 0.1.11`, `mobench-sdk 0.1.11`, `mobench-macros 0.1.11` | Test build. Do not use. | +| `v0.1.10` | 2026-01-19 | `mobench 0.1.10`, `mobench-sdk 0.1.10`, `mobench-macros 0.1.10` | Test build. Do not use. | +| `v0.1.9` | 2026-01-19 | `mobench 0.1.9`, `mobench-sdk 0.1.9`, `mobench-macros 0.1.9` | Test build. Do not use. | +| `v0.1.8` | 2026-01-19 | `mobench 0.1.8`, `mobench-sdk 0.1.8`, `mobench-macros 0.1.8` | Test build. Do not use. | +| `v0.1.7` | 2026-01-19 | `mobench 0.1.7`, `mobench-sdk 0.1.7`, `mobench-macros 0.1.7` | Test build. Do not use. | +| `v0.1.6` | 2026-01-16 | `mobench 0.1.6`, `mobench-sdk 0.1.6`, `mobench-macros 0.1.6` | Test build. Do not use. | | `v0.1.5` | 2026-01-15 | `mobench 0.1.5`, `mobench-sdk 0.1.5` | Test build. Do not use. | -| `v0.1.4` | 2026-01-14 | `mobench 0.1.4`, `mobench-sdk 0.1.4` | Test build. Do not use. | -| `v0.1.3` | 2026-01-14 | `mobench 0.1.3`, `mobench-sdk 0.1.3` | Yanked test build. Do not use. | -| `v0.1.2` | 2026-01-14 | `mobench 0.1.2`, `mobench-sdk 0.1.2` | Yanked test build. Do not use. | +| `v0.1.4` | 2026-01-14 | `mobench 0.1.4`, `mobench-sdk 0.1.4`, `mobench-macros 0.1.4` | Test build. Do not use. | +| `v0.1.3` | 2026-01-14 | `mobench 0.1.3`, `mobench-sdk 0.1.3`, `mobench-macros 0.1.3` | Yanked test build. Do not use. | +| `v0.1.2` | 2026-01-14 | `mobench 0.1.2`, `mobench-sdk 0.1.2`, `mobench-macros 0.1.2` | Yanked test build. Do not use. | | `v0.1.1` | 2026-01-13 | `mobench 0.1.1`, `mobench-sdk 0.1.1` | Yanked test build. Do not use. | -| `v0.1.0` | 2026-01-13 | `mobench 0.1.0`, `mobench-sdk 0.1.0` | Yanked test build. Do not use. | +| `v0.1.0` | 2026-01-13 | `mobench 0.1.0`, `mobench-sdk 0.1.0`, `mobench-macros 0.1.0` | Yanked test build. Do not use. | -## v0.1.25 +## v0.1.26 Status: current supported release. +- Published a synchronized `mobench`, `mobench-sdk`, and `mobench-macros` + release so the registry dependency graph matches the current profiling and + packaging APIs. +- Moved release history out of the root README into this standalone + `RELEASE_NOTES.md` file and backfilled the published crate history. +- Cleaned up obsolete planning and contract docs from the repository-facing + docs surface. +- Normalized crate README references so published crate pages link back to the + correct GitHub-hosted schema and release-history sources. + +## v0.1.25 + +Status: test build. Do not use. + - Clarified that profiling remains local-first in this release; BrowserStack native profiling is explicitly unsupported with actionable error text and a visible capability matrix. diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 21572f0..881ecba 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.25", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.26", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 7071f9b..3a969f7 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.25", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.26", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/docs/CONCERNS_RESOLUTION_2026-02-16.md b/docs/CONCERNS_RESOLUTION_2026-02-16.md deleted file mode 100644 index 796d56c..0000000 --- a/docs/CONCERNS_RESOLUTION_2026-02-16.md +++ /dev/null @@ -1,74 +0,0 @@ -# Concerns Resolution (2026-02-16) - -This document records the disposition of concerns previously tracked in the old codebase-planning backlog artifact that lived under `.planning/codebase/`. - -Disposition labels: -- `fixed`: implemented in code on `codex/ci-devex` -- `accepted`: reviewed and explicitly accepted as non-blocking for current release scope -- `deferred`: valid concern, intentionally deferred to follow-up engineering work - -## Tech Debt - -- Large monolithic build modules (`ios.rs`, `android.rs`): `deferred` -- Unwrap calls in production code paths (`ios.rs` packaging flows): `fixed` -- Path canonicalization fallback in `IosBuilder::new`: `fixed` (now emits explicit warning on fallback) -- String parsing of cargo metadata target dir (`common.rs`): `fixed` (now parses JSON payload) -- Generated code validation before use: `deferred` - -## Known Bugs - -- iOS XCUITest packaging with complex paths/spaces: `fixed` (OsStr-safe command args) -- Cargo metadata fallback behavior in workspaces: `fixed` (JSON parse path; clearer fallback behavior) -- Template variable name collision (`sample_fns`): `deferred` - -## Security Considerations - -- BrowserStack credentials in verbose output: `accepted` (current code logs command invocations, not secret env values) -- User path validation / traversal guardrails: `deferred` -- ZIP/command execution with unchecked path length/characters: `deferred` -- Secret redaction wrapper types for config/env credentials: `deferred` - -## Performance Bottlenecks - -- Sequential multi-target compilation: `deferred` -- Cargo metadata invocation on every build (no cache): `deferred` -- Manual xcframework directory construction overhead: `deferred` - -## Fragile Areas - -- Manual xcframework plist generation: `deferred` -- String-substitution template rendering: `deferred` -- Gradle artifact validation after build: `deferred` -- CLI inter-argument validation coverage: `deferred` - -## Scaling Limits - -- APK size and BrowserStack upload limit behavior: `accepted` (documented operational constraint) -- Large benchmark registry scaling profile: `deferred` -- Large device matrix/session limit handling: `deferred` - -## Dependencies at Risk - -- Rustls backend feature fragility: `accepted` (monitoring required) -- UniFFI version lock consistency: `deferred` -- Embedded template updatability via `include_dir`: `accepted` - -## Missing Features - -- Resume/restart after partial build failure: `deferred` -- Artifact correctness validation beyond build success: `deferred` -- Incremental rebuild strategy: `deferred` -- Android compatibility matrix validation guidance: `deferred` - -## Test Coverage Gaps - -- Builder error-path tests: `deferred` -- Template rendering edge-case tests: `deferred` -- BrowserStack end-to-end integration tests: `deferred` -- CLI invalid-combination tests: `deferred` -- Cross-platform path handling tests: `deferred` - -## Notes - -- This file replaces the previous codebase-planning concerns backlog artifact. -- Resolved items in this pass include safer iOS packaging command construction, cargo metadata JSON parsing, and canonicalization fallback visibility. From 2037c99e6b21fe05d57fc05991fbccd9b5b2c11a Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 27 Mar 2026 23:33:05 -0700 Subject: [PATCH 160/196] build: refresh lockfile for 0.1.26 --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69ec781..0b12914 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.25" +version = "0.1.26" dependencies = [ "anyhow", "clap", @@ -1071,7 +1071,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.25" +version = "0.1.26" dependencies = [ "proc-macro2", "quote", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.25" +version = "0.1.26" dependencies = [ "anyhow", "include_dir", @@ -1582,7 +1582,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.25" +version = "0.1.26" dependencies = [ "camino", "mobench-sdk", From a5442f50bd0ff055164820ffe269dbfa4153206a Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 1 Apr 2026 23:14:30 -0700 Subject: [PATCH 161/196] Release 0.1.27 (#20) --- .gitignore | 1 + CLAUDE.md | 26 +- Cargo.lock | 8 +- Cargo.toml | 2 +- README.md | 46 +- RELEASE_NOTES.md | 31 +- assets/flamegraph-viewer.png | Bin 0 -> 364465 bytes crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench-sdk/src/builders/ios.rs | 6 +- crates/mobench-sdk/src/codegen.rs | 31 +- crates/mobench-sdk/src/ffi.rs | 31 + crates/mobench-sdk/src/lib.rs | 6 +- crates/mobench-sdk/src/timing.rs | 214 +- crates/mobench-sdk/src/types.rs | 4 +- crates/mobench-sdk/src/uniffi_types.rs | 3 + crates/mobench/Cargo.toml | 3 +- crates/mobench/src/browserstack.rs | 4 +- crates/mobench/src/flamegraph_viewer.rs | 1235 ++++---- .../src/flamegraph_viewer_template.html | 2684 +++++++++++++++++ crates/mobench/src/lib.rs | 12 +- crates/mobench/src/profile.rs | 2102 ++++++++++++- crates/mobench/tests/profile_cli.rs | 20 + docs/codebase/ARCHITECTURE.md | 7 +- docs/codebase/CONVENTIONS.md | 8 +- docs/codebase/INTEGRATIONS.md | 5 +- docs/codebase/README.md | 3 + docs/codebase/STACK.md | 6 +- docs/codebase/STRUCTURE.md | 9 +- docs/codebase/TESTING.md | 4 +- docs/guides/README.md | 13 + .../guides/browserstack-ci.md | 8 +- .../guides/browserstack-metrics.md | 38 +- BUILD.md => docs/guides/build.md | 19 +- .../guides/fetch-results.md | 4 +- docs/guides/profiling.md | 330 ++ .../guides/sdk-integration.md | 4 +- TESTING.md => docs/guides/testing.md | 60 +- .../specs/dx-improvement-spec.md | 7 + 38 files changed, 6153 insertions(+), 843 deletions(-) create mode 100644 assets/flamegraph-viewer.png create mode 100644 crates/mobench/src/flamegraph_viewer_template.html create mode 100644 docs/guides/README.md rename BROWSERSTACK_CI_INTEGRATION.md => docs/guides/browserstack-ci.md (97%) rename BROWSERSTACK_METRICS.md => docs/guides/browserstack-metrics.md (83%) rename BUILD.md => docs/guides/build.md (95%) rename FETCH_RESULTS_GUIDE.md => docs/guides/fetch-results.md (98%) create mode 100644 docs/guides/profiling.md rename BENCH_SDK_INTEGRATION.md => docs/guides/sdk-integration.md (99%) rename TESTING.md => docs/guides/testing.md (92%) rename mobench-dx-spec.md => docs/specs/dx-improvement-spec.md (92%) diff --git a/.gitignore b/.gitignore index a3871ef..a65997d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ run-summary.json run-summary-*.json run-summary-*.md run-summary-*.csv +crates/mobench/artifacts/ bench-config.toml device-matrix.yaml run-*.json diff --git a/CLAUDE.md b/CLAUDE.md index cfd0580..332346c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. +mobile-bench-rs (now **mobench**) is a mobile benchmarking toolkit for Rust. It supports benchmark execution on local devices or BrowserStack and local-first native profiling on Android and iOS. It provides a library-first design with a `#[benchmark]` attribute macro plus CLI tools for building, testing, running, reporting, and profiling benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.25):** +**Published on crates.io as the mobench ecosystem (v0.1.27):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation @@ -70,10 +70,11 @@ The CLI supports both Espresso (Android) and XCUITest (iOS) test automation fram **Primary Documentation:** -- **`BUILD.md`**: Complete build reference with prerequisites, step-by-step instructions, and troubleshooting for both Android and iOS -- **`TESTING.md`**: Comprehensive testing guide with advanced scenarios and detailed troubleshooting +- **`docs/guides/build.md`**: Complete build reference with prerequisites, step-by-step instructions, and troubleshooting for both Android and iOS +- **`docs/guides/testing.md`**: Comprehensive testing guide with advanced scenarios and detailed troubleshooting +- **`docs/guides/sdk-integration.md`**: Integration guide for SDK users -For comprehensive testing instructions, see **`TESTING.md`** which includes: +For comprehensive testing instructions, see **`docs/guides/testing.md`** which includes: - Prerequisites and setup - Host testing (cargo test) @@ -418,7 +419,7 @@ fn my_expensive_operation() { The macro automatically registers functions at compile time via the `inventory` crate. -**Setup and Teardown (v0.1.13+)**: The `#[benchmark]` macro supports setup and teardown for excluding expensive initialization from timing: +**Setup and teardown**: The `#[benchmark]` macro supports setup and teardown for excluding expensive initialization from timing: ```rust // Setup runs once before all iterations (not measured) @@ -450,7 +451,7 @@ fn db_query(db: &Database) { } ``` -**Macro Validation (v0.1.13+)**: The `#[benchmark]` macro validates function signatures at compile time: +**Macro validation**: The `#[benchmark]` macro validates function signatures at compile time: - Simple benchmarks: no parameters, returns `()` - With setup: one parameter matching setup return type - Compile errors include helpful messages about requirements @@ -553,7 +554,7 @@ Credentials are resolved in this order: 2. Environment variables: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `BROWSERSTACK_PROJECT` 3. `.env.local` file (loaded automatically via `dotenvy`) -**Improved Error Messages (v0.1.13+)**: Missing credentials now show setup instructions: +**Credential error messages**: Missing credentials now show setup instructions: - Instructions for setting environment variables - Link to BrowserStack account settings page - Hints for `.env.local` file setup @@ -594,7 +595,7 @@ The workflow supports manual dispatch with platform selection: ```toml [dependencies] -mobench-sdk = "0.1.25" +mobench-sdk = "0.1.27" inventory = "0.3" ``` @@ -796,9 +797,10 @@ ios-simulator-arm64/sample_fns.framework/ (not ios-simulator-arm64.framework/) ### Documentation -- **`BUILD.md`**: Complete build reference with prerequisites and troubleshooting -- **`TESTING.md`**: Comprehensive testing guide with detailed troubleshooting -- **`BENCH_SDK_INTEGRATION.md`**: Integration guide for SDK users +- **`docs/guides/build.md`**: Complete build reference with prerequisites and troubleshooting +- **`docs/guides/testing.md`**: Comprehensive testing guide with detailed troubleshooting +- **`docs/guides/sdk-integration.md`**: Integration guide for SDK users +- **`docs/guides/browserstack-ci.md`**: BrowserStack benchmark execution and CI guidance - **`docs/codebase/ARCHITECTURE.md`**: Current architecture reference - **`RELEASE_NOTES.md`**: Published release history and support status - **`CLAUDE.md`**: This file - developer guide for the codebase diff --git a/Cargo.lock b/Cargo.lock index 0b12914..a8b6ae0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.26" +version = "0.1.27" dependencies = [ "anyhow", "clap", @@ -1071,7 +1071,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.26" +version = "0.1.27" dependencies = [ "proc-macro2", "quote", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.26" +version = "0.1.27" dependencies = [ "anyhow", "include_dir", @@ -1582,7 +1582,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.26" +version = "0.1.27" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 17b377b..6795aac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.26" +version = "0.1.27" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 1966d95..d0e95b1 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,10 @@ cargo mobench report github --pr 123 --summary target/mobench/ci/summary.json cargo mobench profile run --target android --function sample_fns::fibonacci \ --provider local --backend android-native cargo mobench profile summarize --profile target/mobench/profile/profile.json +cargo mobench profile diff \ + --baseline target/mobench/profile/android-sample_fns--fibonacci/profile.json \ + --candidate target/mobench/profile/profile.json \ + --normalize ``` CI contract outputs are written to `target/mobench/ci/`: @@ -101,7 +105,9 @@ Profiling commands are local-first in this release. Each session writes its current manifest and summary under `target/mobench/profile//`, and the CLI also refreshes top-level `target/mobench/profile/profile.json` and `summary.md` as convenience copies of -the latest run. +the latest run. Differential comparisons write to +`target/mobench/profile/diff/--vs--/` and +refresh top-level `profile-diff.json` / `summary.md` under the diff root. The manifest is split into three explicit sections: @@ -109,6 +115,12 @@ The manifest is split into three explicit sections: - `semantic_profile`: optional benchmark phase data such as `prove` and `serialize` - `capture_metadata`: device resolution, capture settings, and warnings +Android-native sessions also emit `artifacts/processed/frame-locations.json` +when `llvm-addr2line` can recover file/line metadata. The interactive viewer +uses that sidecar to surface source links for selected frames and hot-path +entries. iOS simulator-host `sample` sessions do not expose source links in the +current release. + The summary renderer keeps native and semantic outputs separate so the interactive flamegraph viewer stays focused on native stacks while phase timings remain readable as benchmark metadata. @@ -136,6 +148,20 @@ one preparatory launch before recording to prime startup caches and reduce first noise. That improves the capture, but it does not remove all per-process bridge initialization from the recorded run. +For flamegraph regression work, the recommended workflow is: + +- archive the per-session `profile.json` plus processed folded stacks as CI artifacts +- fetch a baseline session and a candidate session +- run `cargo mobench profile diff --baseline --candidate --normalize` +- inspect `target/mobench/profile/diff/.../artifacts/processed/flamegraph.html` + +The current flamegraph viewer keeps aggregate hotspot analysis and exact harness +timing separate: `Benchmark Only` and `Full Process` stay aggregate flamegraphs, +while `Timeline` exposes exact harness intervals and any recorded chronological +samples without relabeling the aggregate x-axis as wall-clock time. + +![Mobench flamegraph viewer](assets/flamegraph-viewer.png) + When you need device-specific planning inputs for profiling, `profile run` reuses the same resolution model as `devices resolve`: @@ -176,15 +202,19 @@ CLI flags override config file values when provided. ## Project docs +- `docs/guides/README.md`: guide index for setup, integration, BrowserStack CI, fetch flows, and troubleshooting +- `docs/guides/sdk-integration.md`: SDK integration guide +- `docs/guides/build.md`: build prerequisites and troubleshooting +- `docs/guides/profiling.md`: local native profiling guide, artifact layout, and symbol requirements +- `docs/guides/testing.md`: testing guide and device workflows +- `docs/guides/browserstack-ci.md`: BrowserStack benchmark CI setup +- `docs/guides/browserstack-metrics.md`: BrowserStack metric normalization and limits +- `docs/guides/fetch-results.md`: fetching and summarizing results - `docs/codebase/README.md`: current codebase reference map -- `BENCH_SDK_INTEGRATION.md`: SDK integration guide -- `BUILD.md`: build prerequisites and troubleshooting -- `TESTING.md`: testing guide and device workflows -- `BROWSERSTACK_CI_INTEGRATION.md`: BrowserStack CI setup -- `RELEASE_NOTES.md`: published release history and support status -- `docs/schemas/`: machine-readable CI/summary schema artifacts - `docs/MIGRATION_GUIDE.md`: migration notes for CI and reporting changes -- `FETCH_RESULTS_GUIDE.md`: fetching and summarizing results +- `docs/specs/dx-improvement-spec.md`: historical DX design spec, kept for context only +- `docs/schemas/`: machine-readable CI/summary schema artifacts +- `RELEASE_NOTES.md`: published release history and support status - `CLAUDE.md`: developer guide ## Setup and Teardown diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e56512d..7d5ede6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -13,7 +13,9 @@ Crates.io release history: ## Support Policy -- `v0.1.26` is the current supported release. +- `v0.1.27` is the current supported release. +- `v0.1.26` is the immediately previous supported release, superseded by + `v0.1.27`. - Every earlier published version is a historical test build and should not be used. - Yanked versions are explicitly called out below. @@ -22,7 +24,8 @@ Crates.io release history: | Version | Published | Published crates | Status | |---------|-----------|------------------|--------| -| `v0.1.26` | 2026-03-28 | `mobench 0.1.26`, `mobench-sdk 0.1.26`, `mobench-macros 0.1.26` | Current supported release | +| `v0.1.27` | 2026-04-01 | `mobench 0.1.27`, `mobench-sdk 0.1.27`, `mobench-macros 0.1.27` | Current supported release | +| `v0.1.26` | 2026-03-28 | `mobench 0.1.26`, `mobench-sdk 0.1.26`, `mobench-macros 0.1.26` | Superseded by `v0.1.27` | | `v0.1.25` | 2026-03-26 | `mobench 0.1.25`, `mobench-sdk 0.1.25`, `mobench-macros 0.1.25` | Test build. Do not use. | | `v0.1.24` | 2026-03-26 | `mobench 0.1.24`, `mobench-sdk 0.1.24`, `mobench-macros 0.1.24` | Test build. Do not use. | | `v0.1.23` | 2026-03-26 | `mobench 0.1.23`, `mobench-sdk 0.1.23`, `mobench-macros 0.1.23` | Test build. Do not use. | @@ -51,10 +54,32 @@ Crates.io release history: | `v0.1.1` | 2026-01-13 | `mobench 0.1.1`, `mobench-sdk 0.1.1` | Yanked test build. Do not use. | | `v0.1.0` | 2026-01-13 | `mobench 0.1.0`, `mobench-sdk 0.1.0`, `mobench-macros 0.1.0` | Yanked test build. Do not use. | -## v0.1.26 +## v0.1.27 Status: current supported release. +- Promoted `cargo mobench profile diff` from internal/demo-only code into the + shipped CLI surface, including normalized diff manifests, summaries, SVGs, + and an interactive viewer bundle. +- Added exact harness timeline capture to `mobench-sdk` and exposed that data + throughout the profile/session contract, enabling truthful phase metadata in + the flamegraph viewer. +- Added source-link sidecars (`frame-locations.json`) and shipped viewer support + for Android file/line navigation when symbolization can recover source + metadata. +- Redesigned the flamegraph viewer shell with consistent typography, warm + palette retinting, timeline/fullscreen/legend controls, keyboard navigation, + and a more legible metadata layout. +- Added BrowserStack-facing naming normalization so project labels and uploaded + binaries follow the benchmarked repository name for demos and recordings. +- Reorganized user-facing docs under `docs/guides/`, updated codebase + references, and added a checked-in flamegraph screenshot to the profiling + docs. + +## v0.1.26 + +Status: superseded by `v0.1.27`. + - Published a synchronized `mobench`, `mobench-sdk`, and `mobench-macros` release so the registry dependency graph matches the current profiling and packaging APIs. diff --git a/assets/flamegraph-viewer.png b/assets/flamegraph-viewer.png new file mode 100644 index 0000000000000000000000000000000000000000..cce9004f852aba2f904f9d70df0b5530ad03e95b GIT binary patch literal 364465 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

t7v2RhO;H! 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 literal 0 HcmV?d00001 diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 881ecba..194cb4a 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.26", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.27", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index af0c34d..9510347 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -2030,11 +2030,7 @@ impl IosBuilder { })?; let mut extract = Command::new("ditto"); - extract - .arg("-x") - .arg("-k") - .arg(ipa_path) - .arg(&extract_root); + extract.arg("-x").arg("-k").arg(ipa_path).arg(&extract_root); let extract_result = run_command(extract, "extract IPA for validation"); if let Err(err) = extract_result { diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index e34b54c..f142f50 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -173,11 +173,20 @@ pub struct SemanticPhase { pub duration_ns: u64, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub struct HarnessTimelineSpan { + pub phase: String, + pub start_offset_ns: u64, + pub end_offset_ns: u64, + pub iteration: Option, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct BenchReport { pub spec: BenchSpec, pub samples: Vec, pub phases: Vec, + pub timeline: Vec, } #[derive(Debug, thiserror::Error, uniffi::Error)] @@ -231,12 +240,24 @@ impl From for SemanticPhase { } } +impl From for HarnessTimelineSpan { + fn from(span: mobench_sdk::HarnessTimelineSpan) -> Self { + Self { + phase: span.phase, + start_offset_ns: span.start_offset_ns, + end_offset_ns: span.end_offset_ns, + iteration: span.iteration, + } + } +} + impl From for BenchReport { fn from(report: mobench_sdk::RunnerReport) -> Self { Self { spec: report.spec.into(), samples: report.samples.into_iter().map(Into::into).collect(), phases: report.phases.into_iter().map(Into::into).collect(), + timeline: report.timeline.into_iter().map(Into::into).collect(), } } } @@ -1285,7 +1306,10 @@ mod tests { generate_android_project(&temp_dir, "ffi_benchmark", "ffi_benchmark::bench_fibonacci") .unwrap(); let old_package_dir = temp_dir.join("android/app/src/main/java/dev/world/ffibenchmark"); - assert!(old_package_dir.exists(), "expected first package tree to exist"); + assert!( + old_package_dir.exists(), + "expected first package tree to exist" + ); generate_android_project( &temp_dir, @@ -1295,7 +1319,10 @@ mod tests { .unwrap(); let new_package_dir = temp_dir.join("android/app/src/main/java/dev/world/basicbenchmark"); - assert!(new_package_dir.exists(), "expected new package tree to exist"); + assert!( + new_package_dir.exists(), + "expected new package tree to exist" + ); assert!( !old_package_dir.exists(), "old package tree should be removed when regenerating the Android scaffold" diff --git a/crates/mobench-sdk/src/ffi.rs b/crates/mobench-sdk/src/ffi.rs index 3c4ae93..3792893 100644 --- a/crates/mobench-sdk/src/ffi.rs +++ b/crates/mobench-sdk/src/ffi.rs @@ -102,6 +102,8 @@ pub struct BenchReportFfi { pub samples: Vec, /// Optional semantic phase timings captured during measured iterations. pub phases: Vec, + /// Exact harness timeline spans in execution order. + pub timeline: Vec, } /// FFI-ready semantic phase timing. @@ -120,12 +122,33 @@ impl From for SemanticPhaseFfi { } } +/// FFI-ready exact harness timeline span. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HarnessTimelineSpanFfi { + pub phase: String, + pub start_offset_ns: u64, + pub end_offset_ns: u64, + pub iteration: Option, +} + +impl From for HarnessTimelineSpanFfi { + fn from(span: crate::HarnessTimelineSpan) -> Self { + Self { + phase: span.phase, + start_offset_ns: span.start_offset_ns, + end_offset_ns: span.end_offset_ns, + iteration: span.iteration, + } + } +} + impl From for BenchReportFfi { fn from(report: crate::RunnerReport) -> Self { Self { spec: report.spec.into(), samples: report.samples.into_iter().map(Into::into).collect(), phases: report.phases.into_iter().map(Into::into).collect(), + timeline: report.timeline.into_iter().map(Into::into).collect(), } } } @@ -260,6 +283,12 @@ mod tests { name: "prove".to_string(), duration_ns: 300, }], + timeline: vec![crate::HarnessTimelineSpan { + phase: "measured-benchmark".to_string(), + start_offset_ns: 0, + end_offset_ns: 100, + iteration: Some(0), + }], }; let ffi: BenchReportFfi = report.into(); @@ -268,6 +297,8 @@ mod tests { assert_eq!(ffi.samples[0].duration_ns, 100); assert_eq!(ffi.phases.len(), 1); assert_eq!(ffi.phases[0].name, "prove"); + assert_eq!(ffi.timeline.len(), 1); + assert_eq!(ffi.timeline[0].phase, "measured-benchmark"); } #[test] diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 7d4de1c..14dbf43 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -375,12 +375,14 @@ pub use registry::{BenchFunction, discover_benchmarks, find_benchmark, list_benc pub use runner::{BenchmarkBuilder, run_benchmark}; // Re-export types that are always available -pub use types::{BenchError, BenchSample, BenchSpec, RunnerReport}; +pub use types::{BenchError, BenchSample, BenchSpec, HarnessTimelineSpan, RunnerReport}; // Re-export types that require full feature #[cfg(feature = "full")] #[cfg_attr(docsrs, doc(cfg(feature = "full")))] -pub use types::{BuildConfig, BuildProfile, BuildResult, InitConfig, NativeLibraryArtifact, Target}; +pub use types::{ + BuildConfig, BuildProfile, BuildResult, InitConfig, NativeLibraryArtifact, Target, +}; // Re-export timing types at the crate root for convenience pub use timing::{BenchSummary, SemanticPhase, TimingError, profile_phase, run_closure}; diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index 312ced7..b82e923 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -236,6 +236,17 @@ pub struct BenchReport { /// Optional semantic phase timings captured during measured iterations. pub phases: Vec, + + /// Exact harness timeline spans in execution order. + pub timeline: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct HarnessTimelineSpan { + pub phase: String, + pub start_offset_ns: u64, + pub end_offset_ns: u64, + pub iteration: Option, } impl BenchReport { @@ -335,6 +346,29 @@ impl BenchReport { } } +fn instant_offset_ns(origin: Instant, instant: Instant) -> u64 { + instant + .duration_since(origin) + .as_nanos() + .min(u128::from(u64::MAX)) as u64 +} + +fn push_timeline_span( + timeline: &mut Vec, + origin: Instant, + phase: &str, + started_at: Instant, + ended_at: Instant, + iteration: Option, +) { + timeline.push(HarnessTimelineSpan { + phase: phase.to_string(), + start_offset_ns: instant_offset_ns(origin, started_at), + end_offset_ns: instant_offset_ns(origin, ended_at), + iteration, + }); +} + /// Statistical summary of benchmark results. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BenchSummary { @@ -585,22 +619,42 @@ where } reset_semantic_phase_collection(); + let harness_origin = Instant::now(); + let mut timeline = Vec::new(); // Warmup phase - not measured - for _ in 0..spec.warmup { + for iteration in 0..spec.warmup { + let phase_start = Instant::now(); f()?; + push_timeline_span( + &mut timeline, + harness_origin, + "warmup-benchmark", + phase_start, + Instant::now(), + Some(iteration), + ); } // Measurement phase begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); - for _ in 0..spec.iterations { + for iteration in 0..spec.iterations { let start = Instant::now(); if let Err(err) = f() { let _ = finish_semantic_phase_collection(); return Err(err); } - samples.push(BenchSample::from_duration(start.elapsed())); + let end = Instant::now(); + samples.push(BenchSample::from_duration(end.duration_since(start))); + push_timeline_span( + &mut timeline, + harness_origin, + "measured-benchmark", + start, + end, + Some(iteration), + ); } let phases = finish_semantic_phase_collection(); @@ -608,6 +662,7 @@ where spec, samples, phases, + timeline, }) } @@ -654,25 +709,54 @@ where } reset_semantic_phase_collection(); + let harness_origin = Instant::now(); + let mut timeline = Vec::new(); // Setup phase - not timed + let setup_start = Instant::now(); let input = setup(); + push_timeline_span( + &mut timeline, + harness_origin, + "setup", + setup_start, + Instant::now(), + None, + ); // Warmup phase - not recorded - for _ in 0..spec.warmup { + for iteration in 0..spec.warmup { + let phase_start = Instant::now(); f(&input)?; + push_timeline_span( + &mut timeline, + harness_origin, + "warmup-benchmark", + phase_start, + Instant::now(), + Some(iteration), + ); } // Measurement phase begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); - for _ in 0..spec.iterations { + for iteration in 0..spec.iterations { let start = Instant::now(); if let Err(err) = f(&input) { let _ = finish_semantic_phase_collection(); return Err(err); } - samples.push(BenchSample::from_duration(start.elapsed())); + let end = Instant::now(); + samples.push(BenchSample::from_duration(end.duration_since(start))); + push_timeline_span( + &mut timeline, + harness_origin, + "measured-benchmark", + start, + end, + Some(iteration), + ); } let phases = finish_semantic_phase_collection(); @@ -680,6 +764,7 @@ where spec, samples, phases, + timeline, }) } @@ -727,25 +812,63 @@ where } reset_semantic_phase_collection(); + let harness_origin = Instant::now(); + let mut timeline = Vec::new(); // Warmup phase - for _ in 0..spec.warmup { + for iteration in 0..spec.warmup { + let setup_start = Instant::now(); let input = setup(); + push_timeline_span( + &mut timeline, + harness_origin, + "fixture-setup", + setup_start, + Instant::now(), + Some(iteration), + ); + let phase_start = Instant::now(); f(input)?; + push_timeline_span( + &mut timeline, + harness_origin, + "warmup-benchmark", + phase_start, + Instant::now(), + Some(iteration), + ); } // Measurement phase begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); - for _ in 0..spec.iterations { + for iteration in 0..spec.iterations { + let setup_start = Instant::now(); let input = setup(); // Not timed + push_timeline_span( + &mut timeline, + harness_origin, + "fixture-setup", + setup_start, + Instant::now(), + Some(iteration), + ); let start = Instant::now(); if let Err(err) = f(input) { let _ = finish_semantic_phase_collection(); return Err(err); } - samples.push(BenchSample::from_duration(start.elapsed())); + let end = Instant::now(); + samples.push(BenchSample::from_duration(end.duration_since(start))); + push_timeline_span( + &mut timeline, + harness_origin, + "measured-benchmark", + start, + end, + Some(iteration), + ); } let phases = finish_semantic_phase_collection(); @@ -753,6 +876,7 @@ where spec, samples, phases, + timeline, }) } @@ -802,35 +926,74 @@ where } reset_semantic_phase_collection(); + let harness_origin = Instant::now(); + let mut timeline = Vec::new(); // Setup phase - not timed + let setup_start = Instant::now(); let input = setup(); + push_timeline_span( + &mut timeline, + harness_origin, + "setup", + setup_start, + Instant::now(), + None, + ); // Warmup phase - for _ in 0..spec.warmup { + for iteration in 0..spec.warmup { + let phase_start = Instant::now(); f(&input)?; + push_timeline_span( + &mut timeline, + harness_origin, + "warmup-benchmark", + phase_start, + Instant::now(), + Some(iteration), + ); } // Measurement phase begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); - for _ in 0..spec.iterations { + for iteration in 0..spec.iterations { let start = Instant::now(); if let Err(err) = f(&input) { let _ = finish_semantic_phase_collection(); return Err(err); } - samples.push(BenchSample::from_duration(start.elapsed())); + let end = Instant::now(); + samples.push(BenchSample::from_duration(end.duration_since(start))); + push_timeline_span( + &mut timeline, + harness_origin, + "measured-benchmark", + start, + end, + Some(iteration), + ); } let phases = finish_semantic_phase_collection(); // Teardown phase - not timed + let teardown_start = Instant::now(); teardown(input); + push_timeline_span( + &mut timeline, + harness_origin, + "teardown", + teardown_start, + Instant::now(), + None, + ); Ok(BenchReport { spec, samples, phases, + timeline, }) } @@ -1011,4 +1174,31 @@ mod tests { assert_eq!(TEARDOWN_COUNT.load(Ordering::SeqCst), 1); assert_eq!(report.samples.len(), 3); } + + #[test] + fn bench_report_serializes_exact_harness_timeline() { + let spec = BenchSpec::new("timeline", 2, 1).unwrap(); + let report = run_closure_with_setup_teardown( + spec, + || { + std::thread::sleep(Duration::from_millis(1)); + "resource" + }, + |_resource| { + std::thread::sleep(Duration::from_millis(1)); + Ok(()) + }, + |_resource| { + std::thread::sleep(Duration::from_millis(1)); + }, + ) + .unwrap(); + + let json = serde_json::to_value(&report).unwrap(); + assert_eq!(json["timeline"][0]["phase"], "setup"); + assert_eq!(json["timeline"][1]["phase"], "warmup-benchmark"); + assert_eq!(json["timeline"][2]["phase"], "measured-benchmark"); + assert_eq!(json["timeline"][3]["phase"], "measured-benchmark"); + assert_eq!(json["timeline"][4]["phase"], "teardown"); + } } diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs index dbef5f1..29b5812 100644 --- a/crates/mobench-sdk/src/types.rs +++ b/crates/mobench-sdk/src/types.rs @@ -18,8 +18,8 @@ // Re-export timing types for convenience pub use crate::timing::{ - BenchReport as RunnerReport, BenchSample, BenchSpec, BenchSummary, SemanticPhase, - TimingError as RunnerError, + BenchReport as RunnerReport, BenchSample, BenchSpec, BenchSummary, HarnessTimelineSpan, + SemanticPhase, TimingError as RunnerError, }; use std::path::PathBuf; diff --git a/crates/mobench-sdk/src/uniffi_types.rs b/crates/mobench-sdk/src/uniffi_types.rs index c3018df..8f08b78 100644 --- a/crates/mobench-sdk/src/uniffi_types.rs +++ b/crates/mobench-sdk/src/uniffi_types.rs @@ -211,6 +211,8 @@ pub struct BenchReportTemplate { pub samples: Vec, /// Optional semantic phase timings captured during measured iterations. pub phases: Vec, + /// Exact harness timeline spans in execution order. + pub timeline: Vec, } impl From for BenchReportTemplate { @@ -219,6 +221,7 @@ impl From for BenchReportTemplate { spec: report.spec.into(), samples: report.samples.into_iter().map(Into::into).collect(), phases: report.phases, + timeline: report.timeline, } } } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 3a969f7..2dbd285 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -10,6 +10,7 @@ documentation = "https://docs.rs/mobench" readme = "README.md" keywords = ["benchmark", "mobile", "android", "ios", "performance"] categories = ["development-tools", "command-line-utilities"] +exclude = ["artifacts/**"] [badges] maintenance = { status = "actively-developed" } @@ -36,7 +37,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.26", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.27", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index 4876e17..83d1084 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -1928,9 +1928,7 @@ mod tests { match listener.accept() { Ok((mut stream, _peer)) => { last_activity = Instant::now(); - stream - .set_nonblocking(false) - .expect("set stream blocking"); + stream.set_nonblocking(false).expect("set stream blocking"); let mut buf = [0_u8; 4096]; let bytes_read = stream.read(&mut buf).expect("read request"); diff --git a/crates/mobench/src/flamegraph_viewer.rs b/crates/mobench/src/flamegraph_viewer.rs index 2554b02..c723cf2 100644 --- a/crates/mobench/src/flamegraph_viewer.rs +++ b/crates/mobench/src/flamegraph_viewer.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; +use std::hash::{Hash, Hasher}; use std::io::Cursor; #[derive(Debug, Clone, PartialEq, Eq)] @@ -44,11 +45,60 @@ impl ArtifactLink { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ViewerMetadataItem { + pub label: String, + pub value: String, +} + +#[cfg(test)] +impl ViewerMetadataItem { + pub(crate) fn new(label: impl Into, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ViewerHarnessTimelineSpan { + pub phase: String, + pub start_offset_ns: u64, + pub end_offset_ns: u64, + pub iteration: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ViewerTraceEvent { + pub event_kind: String, + pub start_offset_ns: u64, + pub end_offset_ns: Option, + pub frames: Vec, + pub phase: Option, + pub iteration: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ViewerTraceLane { + pub id: String, + pub label: String, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct FrameSourceLink { + pub frame: String, + pub location: String, + pub href: String, +} + #[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum FlamegraphMode { Focused, Full, + Timeline, } impl FlamegraphMode { @@ -56,6 +106,7 @@ impl FlamegraphMode { match self { Self::Focused => "focused", Self::Full => "full", + Self::Timeline => "timeline", } } } @@ -63,12 +114,21 @@ impl FlamegraphMode { #[derive(Debug, Clone)] pub(crate) struct FlamegraphViewerDoc { pub title: String, + pub browser_title: String, pub full_svg_document: String, pub focused_svg_document: String, pub full_summary: ModeSummary, pub focused_summary: ModeSummary, + pub sampled_duration_secs: Option, + pub run_metadata: Vec, + pub harness_timeline: Vec, + pub timeline_lanes: Vec, + pub timeline_total_duration_ns: Option, + pub timeline_note: Option, pub default_mode: FlamegraphMode, pub artifact_links: Vec, + pub source_links: Vec, + pub source_link_note: Option, } pub(crate) fn derive_benchmark_focused_folded_stacks( @@ -152,16 +212,18 @@ pub(crate) fn summarize_folded_stacks( } pub(crate) fn count_folded_stack_lines(folded: &str) -> usize { - folded.lines().filter(|line| !line.trim().is_empty()).count() + folded + .lines() + .filter(|line| !line.trim().is_empty()) + .count() } -pub(crate) fn render_standalone_flamegraph_svg( - folded_stacks: &str, - title: &str, -) -> Result { +pub(crate) fn render_standalone_flamegraph_svg(folded_stacks: &str, title: &str) -> Result { if folded_stacks.trim().is_empty() { - return Ok("

No native frames were symbolized.

" - .into()); + return Ok( + "

No native frames were symbolized.

" + .into(), + ); } let mut options = inferno::flamegraph::Options::default(); @@ -178,7 +240,11 @@ pub(crate) fn render_standalone_flamegraph_svg( } pub(crate) fn render_flamegraph_viewer_html(doc: FlamegraphViewerDoc) -> String { - let default_mode = doc.default_mode.as_str(); + let template = include_str!("flamegraph_viewer_template.html"); + let default_mode = escape_json_for_inline_script( + &serde_json::to_string(doc.default_mode.as_str()) + .expect("serialize default flamegraph mode"), + ); let full_svg = escape_json_for_inline_script( &serde_json::to_string(&doc.full_svg_document).expect("serialize full svg"), ); @@ -194,668 +260,148 @@ pub(crate) fn render_flamegraph_viewer_html(doc: FlamegraphViewerDoc) -> String let artifact_links = escape_json_for_inline_script( &serde_json::to_string(&doc.artifact_links).expect("serialize flamegraph artifact links"), ); - let default_mode_json = escape_json_for_inline_script( - &serde_json::to_string(default_mode).expect("serialize default flamegraph mode"), + let sampled_duration_secs = escape_json_for_inline_script( + &serde_json::to_string(&doc.sampled_duration_secs) + .expect("serialize sampled duration seconds"), ); + let harness_timeline = escape_json_for_inline_script( + &serde_json::to_string(&doc.harness_timeline).expect("serialize harness timeline"), + ); + let timeline_lanes = escape_json_for_inline_script( + &serde_json::to_string(&doc.timeline_lanes).expect("serialize timeline lanes"), + ); + let timeline_total_duration_ns = escape_json_for_inline_script( + &serde_json::to_string(&doc.timeline_total_duration_ns) + .expect("serialize timeline total duration"), + ); + let timeline_note = escape_json_for_inline_script( + &serde_json::to_string(&doc.timeline_note).expect("serialize timeline note"), + ); + let source_links = escape_json_for_inline_script( + &serde_json::to_string(&doc.source_links).expect("serialize source links"), + ); + let source_link_note = escape_json_for_inline_script( + &serde_json::to_string(&doc.source_link_note).expect("serialize source link note"), + ); + + template + .replace("__BROWSER_TITLE__", &escape_html(&doc.browser_title)) + .replace("__VIEWER_TITLE__", &escape_html(&doc.title)) + .replace( + "__RUN_METADATA_MARKUP__", + &render_run_metadata_markup(&doc.run_metadata), + ) + .replace( + "__HARNESS_TIMELINE_MARKUP__", + &render_harness_timeline_markup(&doc.harness_timeline), + ) + .replace("__DEFAULT_MODE__", &default_mode) + .replace("__FOCUSED_SVG__", &focused_svg) + .replace("__FULL_SVG__", &full_svg) + .replace("__FOCUSED_SUMMARY__", &focused_summary) + .replace("__FULL_SUMMARY__", &full_summary) + .replace("__ARTIFACT_LINKS__", &artifact_links) + .replace("__SAMPLED_DURATION_SECS__", &sampled_duration_secs) + .replace("__HARNESS_TIMELINE_JSON__", &harness_timeline) + .replace("__TIMELINE_LANES_JSON__", &timeline_lanes) + .replace( + "__TIMELINE_TOTAL_DURATION_NS__", + &timeline_total_duration_ns, + ) + .replace("__TIMELINE_NOTE__", &timeline_note) + .replace("__SOURCE_LINKS__", &source_links) + .replace("__SOURCE_LINK_NOTE__", &source_link_note) +} + +fn render_run_metadata_markup(items: &[ViewerMetadataItem]) -> String { + items.iter() + .map(|item| { + format!( + "", + escape_html(&item.label), + escape_html(&item.value) + ) + }) + .collect::>() + .join("") +} + +fn render_harness_timeline_markup(spans: &[ViewerHarnessTimelineSpan]) -> String { + let total_duration_ns = spans + .iter() + .map(|span| span.end_offset_ns) + .max() + .unwrap_or(0); + let mut segments = String::new(); + for span in spans { + let left = if total_duration_ns == 0 { + 0.0 + } else { + (span.start_offset_ns as f64 / total_duration_ns as f64) * 100.0 + }; + let width = if total_duration_ns == 0 { + 100.0 + } else { + (((span.end_offset_ns.saturating_sub(span.start_offset_ns)) as f64) + / total_duration_ns as f64) + * 100.0 + }; + let label = harness_phase_label(&span.phase, span.iteration); + let duration = format_duration_ns(span.end_offset_ns.saturating_sub(span.start_offset_ns)); + let title = format!( + "{} · {} to {}", + label, + format_duration_ns(span.start_offset_ns), + format_duration_ns(span.end_offset_ns) + ); + segments.push_str(&format!( + "{}{}", + escape_html(&span.phase), + left, + width.max(0.5), + escape_html(&title), + escape_html(&label), + escape_html(&duration), + )); + } format!( - r#" - - - - - {title} - - - -
-
-
- {title} - - -
-
- - - - - -
-
-
-
- Mode - Benchmark Only -
-
- Current Root - all -
-
- Visible Samples - - -
-
- Selection Width - 100% -
-
-
-
-
- - -
-
-
-
Drag across the graph to zoom the current x-axis range
-
-
- -
-
- - -"#, - title = escape_html(&doc.title), - focused_svg = focused_svg, - full_svg = full_svg, - focused_summary = focused_summary, - full_summary = full_summary, - artifact_links = artifact_links, - default_mode_json = default_mode_json + "
Harness TimelineExact harness time · {}
{}
0{}
Hover a harness segment to inspect its full label.
", + escape_html(&format_duration_ns(total_duration_ns)), + segments, + escape_html(&format_duration_ns(total_duration_ns)), ) } +fn harness_phase_label(phase: &str, iteration: Option) -> String { + let base = match phase { + "setup" => "Setup", + "fixture-setup" => "Fixture Setup", + "warmup-benchmark" => "Warmup", + "measured-benchmark" => "Bench Body", + "teardown" => "Teardown", + "fixture-teardown" => "Fixture Teardown", + "harness" => "Harness", + _ => phase, + }; + match iteration { + Some(iteration) => format!("{base} #{}", iteration + 1), + None => base.to_string(), + } +} + +fn format_duration_ns(ns: u64) -> String { + if ns >= 1_000_000_000 { + format!("{:.2} s", ns as f64 / 1_000_000_000.0) + } else if ns >= 1_000_000 { + format!("{:.2} ms", ns as f64 / 1_000_000.0) + } else if ns >= 1_000 { + format!("{:.2} µs", ns as f64 / 1_000.0) + } else { + format!("{ns} ns") + } +} + fn escape_json_for_inline_script(json: &str) -> String { json.replace(" String { if trimmed.is_empty() { continue; } - let Some((stack, count)) = split_folded_stack_line(trimmed) else { + let Some((stack, counts)) = split_folded_stack_counts(trimmed) else { lines.push(trimmed.to_string()); continue; }; @@ -902,7 +448,7 @@ fn prettify_folded_stacks_for_display(folded_stacks: &str) -> String { .map(prettify_frame_label) .collect::>() .join(";"); - lines.push(format!("{pretty_stack} {count}")); + lines.push(format!("{pretty_stack} {counts}")); } lines.join("\n") } @@ -937,7 +483,10 @@ fn prettify_frame_label(frame: &str) -> String { pretty } -fn trim_stack_to_first_anchor<'a>(frames: &'a [&'a str], anchors: &[&str]) -> Option<&'a [&'a str]> { +fn trim_stack_to_first_anchor<'a>( + frames: &'a [&'a str], + anchors: &[&str], +) -> Option<&'a [&'a str]> { frames .iter() .position(|frame| anchors.iter().any(|anchor| frame.contains(anchor))) @@ -950,6 +499,19 @@ fn split_folded_stack_line(line: &str) -> Option<(&str, u64)> { Some((&line[..split], count)) } +fn split_folded_stack_counts(line: &str) -> Option<(&str, &str)> { + let (rest, last) = line.rsplit_once(' ')?; + if last.parse::().is_err() { + return None; + } + if let Some((stack, previous)) = rest.rsplit_once(' ') + && previous.parse::().is_ok() + { + return Some((stack, &line[stack.len() + 1..])); + } + Some((rest, last)) +} + fn finalize_standalone_flamegraph_document(rendered: String) -> String { let rendered = rendered.replacen( " String { "#unzoom { cursor:pointer; display:none; }\n#search, #matched, #details, #title { display:none; }", 1, ); + let rendered = retint_flamegraph_background(rendered); + let rendered = retint_flamegraph_palette(rendered); inject_svg_script(rendered, MOBENCH_SVG_HELPER_SCRIPT) } +fn retint_flamegraph_background(document: String) -> String { + document + .replace(r##"stop-color="#eeeeee""##, r##"stop-color="#ffffff""##) + .replace(r##"stop-color="#eeeeb0""##, r##"stop-color="#ffffff""##) +} + +fn retint_flamegraph_palette(document: String) -> String { + const NEEDLE: &str = r#"fill="rgb("#; + if !document.contains(NEEDLE) { + return document; + } + + let mut output = String::with_capacity(document.len()); + let mut cursor = 0usize; + + while let Some(relative) = document[cursor..].find(NEEDLE) { + let start = cursor + relative; + output.push_str(&document[cursor..start]); + let value_start = start + NEEDLE.len(); + let Some(value_end) = document[value_start..].find(")\"") else { + output.push_str(&document[start..]); + return output; + }; + let value_end = value_start + value_end; + let raw = &document[value_start..value_end]; + let replacement = parse_fill_rgb(raw) + .map(|rgb| flamegraph_fill_for_rgb(rgb, frame_title_for_fill(&document, start))) + .unwrap_or_else(|| format!(r#"fill="rgb({raw})""#)); + output.push_str(&replacement); + cursor = value_end + 2; + } + + output.push_str(&document[cursor..]); + output +} + +fn frame_title_for_fill<'a>(document: &'a str, fill_start: usize) -> &'a str { + document[..fill_start] + .rfind("") + .and_then(|title_start| { + let title_body_start = title_start + "".len(); + document[title_body_start..fill_start] + .find("") + .map(|title_end| &document[title_body_start..title_body_start + title_end]) + }) + .unwrap_or("neutral-frame") +} + +fn parse_fill_rgb(raw: &str) -> Option<(u8, u8, u8)> { + let mut parts = raw.split(','); + let r = parts.next()?.trim().parse().ok()?; + let g = parts.next()?.trim().parse().ok()?; + let b = parts.next()?.trim().parse().ok()?; + if parts.next().is_some() { + return None; + } + Some((r, g, b)) +} + +fn flamegraph_fill_for_rgb(rgb: (u8, u8, u8), title: &str) -> String { + let (r, g, b) = rgb; + if rgb == (0, 0, 0) { + return format!(r#"fill="rgb({r},{g},{b})""#); + } + + if is_neutral_fill(rgb) { + return neutral_frame_fill_for_title(title); + } + + if r >= g && r >= b && r.saturating_sub(b) > 12 { + let palette = [(255, 107, 116), (255, 123, 122), (255, 139, 127)]; + let shade = ((r as usize + g as usize + b as usize) / 48).min(palette.len() - 1); + let (nr, ng, nb) = palette[shade]; + return format!(r#"fill="rgb({nr},{ng},{nb})""#); + } + + if b >= r && b >= g && b.saturating_sub(r) > 8 { + let palette = [(255, 214, 194), (255, 202, 179), (255, 191, 164)]; + let shade = ((r as usize + g as usize + b as usize) / 96).min(palette.len() - 1); + let (nr, ng, nb) = palette[shade]; + return format!(r#"fill="rgb({nr},{ng},{nb})""#); + } + + format!(r#"fill="rgb({r},{g},{b})""#) +} + +fn is_neutral_fill((r, g, b): (u8, u8, u8)) -> bool { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + max >= 242 && max.saturating_sub(min) <= 10 +} + +fn neutral_frame_fill_for_title(title: &str) -> String { + const PALETTE: [(u8, u8, u8); 8] = [ + (255, 107, 116), + (255, 166, 95), + (255, 178, 92), + (255, 194, 99), + (255, 220, 107), + (255, 208, 123), + (255, 156, 120), + (255, 189, 118), + ]; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + title.hash(&mut hasher); + let (r, g, b) = PALETTE[(hasher.finish() as usize) % PALETTE.len()]; + format!(r#"fill="rgb({r},{g},{b})""#) +} + fn inject_svg_script(document: String, script: &str) -> String { let Some(index) = document.rfind("") else { return document; @@ -1212,6 +885,21 @@ const MOBENCH_SVG_HELPER_SCRIPT: &str = r#" return view.width < total; } + window.mobenchScrollToBase = function () { + function applyScroll() { + var doc = document.documentElement || document.body; + var body = document.body || document.documentElement; + var maxScroll = Math.max( + 0, + (doc ? doc.scrollHeight : 0), + (body ? body.scrollHeight : 0) + ) - window.innerHeight; + window.scrollTo(0, Math.max(0, maxScroll)); + } + applyScroll(); + setTimeout(applyScroll, 0); + }; + window.mobenchResetView = function () { var view = { xmin: 0, @@ -1325,6 +1013,7 @@ const MOBENCH_SVG_HELPER_SCRIPT: &str = r#" window.addEventListener("load", function () { setTimeout(function () { window.mobenchResetView(); + window.mobenchScrollToBase(); }, 0); }); })(); @@ -1337,6 +1026,7 @@ mod tests { fn sample_doc() -> FlamegraphViewerDoc { FlamegraphViewerDoc { title: "iOS Native Profile".into(), + browser_title: "Mobench Flamegraph - mobile-bench-rs".into(), full_svg_document: "".into(), focused_svg_document: "".into(), full_summary: ModeSummary { @@ -1373,8 +1063,65 @@ mod tests { percent_total: 100, }], }, + sampled_duration_secs: Some(10.0), + run_metadata: vec![ + ViewerMetadataItem::new("Target", "ios"), + ViewerMetadataItem::new("Benchmark", "sample_fns::fibonacci"), + ViewerMetadataItem::new("Run ID", "ios-demo"), + ], + harness_timeline: vec![ + ViewerHarnessTimelineSpan { + phase: "setup".into(), + start_offset_ns: 0, + end_offset_ns: 100, + iteration: None, + }, + ViewerHarnessTimelineSpan { + phase: "measured-benchmark".into(), + start_offset_ns: 100, + end_offset_ns: 300, + iteration: Some(0), + }, + ViewerHarnessTimelineSpan { + phase: "teardown".into(), + start_offset_ns: 300, + end_offset_ns: 420, + iteration: None, + }, + ], + timeline_lanes: vec![ViewerTraceLane { + id: "main".into(), + label: "Main Thread".into(), + events: vec![ + ViewerTraceEvent { + event_kind: "span".into(), + start_offset_ns: 0, + end_offset_ns: Some(100), + frames: vec!["fixture::setup".into()], + phase: Some("setup".into()), + iteration: None, + }, + ViewerTraceEvent { + event_kind: "sample".into(), + start_offset_ns: 140, + end_offset_ns: None, + frames: vec![ + "sample_fns::run_benchmark".into(), + "sample_fns::fibonacci".into(), + ], + phase: Some("measured-benchmark".into()), + iteration: Some(0), + }, + ], + }], + timeline_total_duration_ns: Some(420), + timeline_note: Some( + "Timeline shows exact harness chronology and available trace samples.".into(), + ), default_mode: FlamegraphMode::Focused, artifact_links: vec![ArtifactLink::new("native-report.txt", "native-report.txt")], + source_links: Vec::new(), + source_link_note: Some("Source links are unavailable in this fixture.".into()), } } @@ -1416,7 +1163,10 @@ mod tests { let focused = derive_benchmark_focused_folded_stacks( folded, - &["sample_fns::run_benchmark", "mobench_sdk::timing::run_closure"], + &[ + "sample_fns::run_benchmark", + "mobench_sdk::timing::run_closure", + ], ); assert_eq!( @@ -1429,24 +1179,75 @@ mod tests { fn standalone_viewer_html_embeds_full_and_focused_modes() { let html = render_flamegraph_viewer_html(sample_doc()); + assert!(html.contains("rel=\"icon\"")); + assert!(html.contains("data:image/svg+xml")); + assert!(html.contains("Mobench Flamegraph - mobile-bench-rs")); + assert!(html.contains("iOS Native Profile")); assert!(html.contains("Benchmark Only")); assert!(html.contains("Full Process")); + assert!(html.contains("Timeline")); assert!(html.contains("data-mode=\"focused\"")); assert!(html.contains("data-mode=\"full\"")); + assert!(html.contains("data-mode=\"timeline\"")); assert!(html.contains("<\\/svg>")); assert!(html.contains("<\\/svg>")); } + #[test] + fn viewer_html_embeds_reference_palette_shell_tokens() { + let html = render_flamegraph_viewer_html(sample_doc()); + + assert!(html.contains("--accent: #ff6b74;")); + assert!(html.contains("--accent-strong: #f25c68;")); + assert!(html.contains("--bg: #fffdfb;")); + assert!(html.contains("font: 14px/1.45 \"Avenir Next\", \"SF Pro Display\"")); + } + #[test] fn viewer_html_includes_history_and_brush_zoom_controls() { let html = render_flamegraph_viewer_html(sample_doc()); assert!(html.contains("id=\"viewer-back\"")); assert!(html.contains("id=\"viewer-forward\"")); + assert!(html.contains("id=\"viewer-fullscreen\"")); + assert!(html.contains("id=\"viewer-toggle-timeline\"")); + assert!(html.contains("id=\"viewer-legend\"")); assert!(html.contains("id=\"viewer-reset\"")); assert!(html.contains("id=\"viewer-search\"")); - assert!(html.contains("id=\"viewer-select-range\"")); + assert!(html.contains("id=\"viewer-select-range\" hidden")); assert!(html.contains("id=\"selection-overlay\"")); - assert!(html.contains("data-history-scope=\"focused\"")); + assert!(html.contains("Drag across the graph to zoom. Double-click to step back one range. Press T to toggle the timeline. Press R to reset. Press F for fullscreen.")); + assert!(html.contains("function stepBackCurrentRange()")); + assert!(html.contains("function requestStepBackCurrentRange()")); + assert!(html.contains("function stepForwardCurrentRange()")); + assert!(html.contains("function toggleFullscreenGraph(force)")); + assert!(html.contains("function toggleTimelineStrip(force)")); + assert!(html.contains("function toggleShortcutLegend(force)")); + assert!(html.contains("function showSelectionHintFor(durationMs)")); + assert!(html.contains("function scrollAggregateFrameToBase(mode)")); + assert!(html.contains("function resetZoomFully()")); + assert!(html.contains("function scrollActiveGraphSurface(deltaX, deltaY)")); + assert!(html.contains("function isRepeatedOverlayTap(event)")); + assert!(html.contains("window.addEventListener(\"keydown\"")); + assert!(html.contains("id=\"shortcut-overlay\"")); + assert!( + html.contains("Keyboard and graph gestures for moving around the profiler quickly.") + ); + assert!(html.contains("shortcut-badge")); + assert!(html.contains("showSelectionHintFor(3000);")); + assert!(html.contains("event.key === \"1\"")); + assert!(html.contains("event.key === \"2\"")); + assert!(html.contains("event.key === \"3\"")); + assert!(html.contains("event.key === \"t\" || event.key === \"T\"")); + assert!(html.contains("event.key === \"j\" || event.key === \"J\"")); + assert!(html.contains("event.key === \"k\" || event.key === \"K\"")); + assert!(html.contains("event.key === \"f\" || event.key === \"F\"")); + assert!(html.contains("event.key === \"Escape\" && fullscreenVisible()")); + assert!(html.contains("event.key === \"?\" || (event.key === \"/\" && event.shiftKey)")); + assert!(html.contains("selectionOverlay.addEventListener(\"wheel\"")); + assert!(html.contains("selectionOverlay.addEventListener(\"dblclick\"")); + assert!(html.contains("id=\"timeline-stage\"")); + assert!(html.contains("body.graph-fullscreen .graph-axis")); + assert!(html.contains("body.graph-fullscreen .harness-timeline")); assert!(!html.contains("target=\"_blank\"")); } @@ -1470,14 +1271,38 @@ mod tests { assert!(html.contains("focused warning")); } + #[test] + fn viewer_html_restores_metadata_harness_and_sampled_time_shell() { + let html = render_flamegraph_viewer_html(sample_doc()); + + assert!(html.contains("Visible Duration")); + assert!(html.contains("id=\"run-metadata\"")); + assert!(html.contains( + "grid-template-columns: repeat(var(--run-metadata-columns, 1), minmax(0, 1fr));" + )); + assert!(html.contains("function syncRunMetadataColumns()")); + assert!(html.contains("id=\"harness-timeline\"")); + assert!(html.contains("id=\"harness-readout\"")); + assert!(html.contains("id=\"graph-axis\"")); + assert!(html.contains(".harness-timeline {\n grid-row: 1;")); + assert!(html.contains(".graph-stage {\n grid-row: 2;")); + assert!(html.contains(".graph-axis {\n grid-row: 3;")); + } + + #[test] + fn viewer_html_tracks_aggregate_dataset_separately_from_timeline_mode() { + let html = render_flamegraph_viewer_html(sample_doc()); + + assert!(html.contains("lastAggregateMode")); + assert!(html.contains("getAggregateDatasetMode")); + assert!(html.contains("savedAggregateViews")); + assert!(html.contains("syncAggregateWindowToTimelineView")); + } + #[test] fn summarize_folded_stacks_caps_inclusive_percent_for_repeated_frames() { - let summary = summarize_folded_stacks( - "root;repeat;repeat 4\nroot;repeat;leaf 1\n", - 2, - 0, - None, - ); + let summary = + summarize_folded_stacks("root;repeat;repeat 4\nroot;repeat;leaf 1\n", 2, 0, None); let repeat = summary .inclusive_frames @@ -1505,14 +1330,25 @@ mod tests { None, ); - assert!(summary - .self_frames - .iter() - .any(|frame| frame.frame == "sample_fns::fibonacci")); - assert!(summary - .self_frames - .iter() - .any(|frame| frame.frame == "::forward_unchecked")); + assert!( + summary + .self_frames + .iter() + .any(|frame| frame.frame == "sample_fns::fibonacci") + ); + assert!( + summary + .self_frames + .iter() + .any(|frame| frame.frame == "::forward_unchecked") + ); + } + + #[test] + fn prettify_folded_stacks_preserves_differential_counts() { + let pretty = + prettify_folded_stacks_for_display("root;sample_fns::fibonacci::ha1ebbae54edac99d 3 7"); + assert_eq!(pretty, "root;sample_fns::fibonacci 3 7"); } #[test] @@ -1535,6 +1371,7 @@ mod tests { assert!(svg.contains("var fluiddrawing = false;")); assert!(svg.contains("width:100vw")); assert!(svg.contains("mobenchZoomVisibleFraction")); + assert!(svg.contains("mobenchScrollToBase")); } #[test] @@ -1547,4 +1384,32 @@ mod tests { assert!(svg.contains("mobenchClearCollapsedTowerPresentation")); assert!(svg.contains("mobenchZoomAbsoluteRange")); } + + #[test] + fn standalone_svg_retints_neutral_differential_frames() { + let svg = render_standalone_flamegraph_svg( + "root;sample_fns::fibonacci 1 1\nroot;sample_fns::checksum 1 1", + "Diff Flamegraph", + ) + .expect("render differential svg"); + assert!(!svg.contains("fill=\"rgb(250,250,250)\"")); + assert!(!svg.contains("rgb(217, 215, 255)")); + assert!(!svg.contains("rgb(232, 228, 255)")); + assert!( + svg.contains("rgb(255,107,116)") + || svg.contains("rgb(255,178,92)") + || svg.contains("rgb(255,220,107)") + || svg.contains("rgb(255,207,177)") + ); + } + + #[test] + fn standalone_svg_replaces_default_gray_background_gradient() { + let svg = + render_standalone_flamegraph_svg("root;sample_fns::fibonacci 1", "Test Flamegraph") + .expect("render svg"); + assert!(!svg.contains("stop-color=\"#eeeeee\"")); + assert!(!svg.contains("stop-color=\"#eeeeb0\"")); + assert!(svg.contains("stop-color=\"#ffffff\"")); + } } diff --git a/crates/mobench/src/flamegraph_viewer_template.html b/crates/mobench/src/flamegraph_viewer_template.html new file mode 100644 index 0000000..ace0290 --- /dev/null +++ b/crates/mobench/src/flamegraph_viewer_template.html @@ -0,0 +1,2684 @@ + + + + + + __BROWSER_TITLE__ + + + + +
+
+
+ __VIEWER_TITLE__ + + + +
+
+ + + + + + + + +
+
+
+
+ Mode + Benchmark Only +
+
+ Current Root + all +
+
+ Visible Samples + - +
+
+ Selection Width + 100% +
+
+ Visible Duration + - +
+
+ +
+
+
+ __HARNESS_TIMELINE_MARKUP__ +
+
+ + + +
+
Benchmark Only
+ + +
+
+
+
+
Drag across the graph to zoom. Double-click to step back one range. Press T to toggle the timeline. Press R to reset. Press F for fullscreen.
+
+ +
+ +
+
+ + + + diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 6e4a933..5b2cccb 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -669,6 +669,8 @@ enum ProfileCommand { about = "Plan or execute a native profiling session; local android-native and ios-instruments now attempt real native capture" )] Run(profile::ProfileRunArgs), + /// Generate a differential flamegraph bundle from two normalized profile manifests. + Diff(profile::ProfileDiffArgs), /// Render markdown or JSON from a normalized profile manifest. Summarize(profile::ProfileSummarizeArgs), } @@ -1880,6 +1882,9 @@ pub fn run() -> Result<()> { ProfileCommand::Run(args) => { profile::cmd_profile_run(&args, cli.dry_run)?; } + ProfileCommand::Diff(args) => { + profile::cmd_profile_diff(&args)?; + } ProfileCommand::Summarize(args) => { profile::cmd_profile_summarize(&args)?; } @@ -5450,7 +5455,7 @@ pub(crate) fn load_dotenv_for_layout(layout: &ResolvedProjectLayout) { } } -fn repo_root() -> Result { +pub(crate) fn repo_root() -> Result { let cwd = std::env::current_dir().context("resolving repo root from current directory")?; if let Some(root) = find_repo_root(&cwd) { return Ok(root); @@ -5795,7 +5800,10 @@ fn cmd_list(project_root: Option, crate_path: Option) -> Resul println!("Usage:"); println!( " cargo mobench run --target android --function {} --iterations 100", - all_benchmarks.first().map(|s| s.as_str()).unwrap_or("my_benchmark") + all_benchmarks + .first() + .map(|s| s.as_str()) + .unwrap_or("my_benchmark") ); } diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 436d19d..98e58d3 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -2,19 +2,22 @@ use anyhow::{Context, Result, bail}; use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::BTreeMap; use std::fmt::Write; +use std::fs::File; +use std::io::BufWriter; use std::path::{Path, PathBuf}; use std::process::Command; use crate::{ DevicePlatform, MobileTarget, ProjectLayoutOptions, ResolvedMatrixDevice, RunSpec, flamegraph_viewer::{ - ArtifactLink as ViewerArtifactLink, FlamegraphMode, FlamegraphViewerDoc, + ArtifactLink as ViewerArtifactLink, FlamegraphMode, FlamegraphViewerDoc, FrameSourceLink, + ViewerHarnessTimelineSpan, ViewerMetadataItem, ViewerTraceEvent, ViewerTraceLane, count_folded_stack_lines, derive_benchmark_focused_folded_stacks, - render_flamegraph_viewer_html, render_standalone_flamegraph_svg, - summarize_folded_stacks, + render_flamegraph_viewer_html, render_standalone_flamegraph_svg, summarize_folded_stacks, }, - load_dotenv_for_layout, persist_mobile_spec, resolve_devices_for_profile, + load_dotenv_for_layout, persist_mobile_spec, repo_root, resolve_devices_for_profile, resolve_project_layout, run_android_build, run_ios_build, validate_benchmark_function, }; use mobench_sdk::types::NativeLibraryArtifact; @@ -143,6 +146,25 @@ pub struct ProfileSummarizeArgs { pub output_format: ProfileSummaryFormat, } +#[derive(Debug, Clone, Args)] +pub struct ProfileDiffArgs { + #[arg(long, help = "Path to the baseline profile.json manifest")] + pub baseline: PathBuf, + #[arg(long, help = "Path to the candidate profile.json manifest")] + pub candidate: PathBuf, + #[arg( + long, + default_value = "target/mobench/profile/diff", + help = "Output directory for differential profile artifacts" + )] + pub output_dir: PathBuf, + #[arg( + long, + help = "Normalize baseline sample counts to candidate totals before diffing" + )] + pub normalize: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum CaptureStatus { @@ -216,11 +238,22 @@ pub struct SemanticPhaseRecord { pub percent_total: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HarnessTimelineSpanRecord { + pub phase: String, + pub start_offset_ns: u64, + pub end_offset_ns: u64, + pub iteration: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SemanticProfileRecord { pub status: SemanticCaptureStatus, pub phases: Vec, pub spans_path: Option, + #[serde(default)] + pub harness_timeline: Vec, + pub timeline_path: Option, } impl Default for SemanticProfileRecord { @@ -229,6 +262,8 @@ impl Default for SemanticProfileRecord { status: SemanticCaptureStatus::Planned, phases: Vec::new(), spans_path: None, + harness_timeline: Vec::new(), + timeline_path: None, } } } @@ -236,12 +271,44 @@ impl Default for SemanticProfileRecord { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct CaptureMetadataRecord { pub device: Option, + pub os: Option, pub sample_duration_secs: Option, + pub benchmark_iterations: Option, + pub benchmark_warmup: Option, pub warmup_mode: Option, pub capture_method: Option, pub warnings: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ChronologicalTraceSourceRecord { + kind: String, + profiler: String, + origin: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ChronologicalTraceRecord { + source: ChronologicalTraceSourceRecord, + total_duration_ns: u64, + lanes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct FrameLocationRecord { + frame: String, + source_path: PathBuf, + line: u32, +} + +#[derive(Debug, Clone)] +struct TimelinePayload { + lanes: Vec, + total_duration_ns: Option, + note: Option, + trace_path: Option, +} + #[derive(Debug, Clone, Serialize)] pub struct ProfileManifest { pub run_id: String, @@ -529,7 +596,15 @@ where execute(args, &target, &mut manifest) }; - write_profile_session_outputs(args, &run_output_dir, &manifest)?; + let should_persist_outputs = dry_run + || execution_result.is_ok() + || manifest.native_capture.status != CaptureStatus::Planned + || manifest.native_capture.symbolization.status != CaptureStatus::Planned + || manifest.semantic_profile.status != SemanticCaptureStatus::Planned; + + if should_persist_outputs { + write_profile_session_outputs(args, &run_output_dir, &manifest)?; + } execution_result?; println!( @@ -567,6 +642,8 @@ fn write_profile_session_outputs( let run_profile_path = run_output_dir.join("profile.json"); let run_summary_path = run_output_dir.join("summary.md"); write_semantic_phase_sidecar(manifest)?; + write_harness_timeline_sidecar(manifest)?; + refresh_flamegraph_viewer_from_manifest(run_output_dir, manifest)?; write_profile_manifest(&run_profile_path, &manifest)?; std::fs::write(&run_summary_path, rendered_summary.as_bytes())?; @@ -594,17 +671,1283 @@ fn write_semantic_phase_sidecar(manifest: &ProfileManifest) -> Result<()> { Ok(()) } +fn write_harness_timeline_sidecar(manifest: &ProfileManifest) -> Result<()> { + let Some(path) = manifest.semantic_profile.timeline_path.as_ref() else { + return Ok(()); + }; + if manifest.semantic_profile.harness_timeline.is_empty() { + return Ok(()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write( + path, + serde_json::to_vec_pretty(&manifest.semantic_profile.harness_timeline)?, + )?; + Ok(()) +} + +fn prepare_viewer_timeline_payload( + run_output_dir: &Path, + processed_root: &Path, + manifest: &ProfileManifest, +) -> Result { + let trace_path = artifact_path_by_label( + &manifest.native_capture.processed_artifacts, + "chronological-trace", + ) + .map(|path| resolve_run_relative_path(run_output_dir, path)) + .unwrap_or_else(|| processed_root.join("chronological-trace.json")); + if trace_path.exists() + && let Ok(record) = load_chronological_trace_record(&trace_path) + { + let lanes = sanitize_trace_lanes(&record); + return Ok(TimelinePayload { + total_duration_ns: Some(record.total_duration_ns), + note: build_timeline_note(&lanes), + lanes, + trace_path: Some(trace_path), + }); + } + + let lanes = build_harness_only_viewer_timeline_lanes(manifest); + let total_duration_ns = compute_timeline_total_duration_ns( + &build_viewer_harness_timeline(manifest), + manifest.capture_metadata.sample_duration_secs, + ); + let trace_path = write_chronological_trace_sidecar( + &trace_path, + manifest, + &lanes, + total_duration_ns, + "mobench-harness-timeline", + )?; + Ok(TimelinePayload { + note: build_timeline_note(&lanes), + lanes, + total_duration_ns, + trace_path, + }) +} + +fn load_chronological_trace_record(path: &Path) -> Result { + let body = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?; + serde_json::from_slice(&body).with_context(|| format!("parsing {}", path.display())) +} + +fn sanitize_trace_lanes(trace: &ChronologicalTraceRecord) -> Vec { + if trace.source.kind != "mobench-harness-timeline" { + return trace.lanes.clone(); + } + trace + .lanes + .iter() + .map(|lane| ViewerTraceLane { + id: lane.id.clone(), + label: lane.label.clone(), + events: lane + .events + .iter() + .filter(|event| event.event_kind != "sample") + .cloned() + .collect(), + }) + .filter(|lane| !lane.events.is_empty()) + .collect() +} + +fn refresh_flamegraph_viewer_from_manifest( + run_output_dir: &Path, + manifest: &ProfileManifest, +) -> Result<()> { + let Some(viewer_path) = artifact_path_by_label( + &manifest.native_capture.processed_artifacts, + "flamegraph-viewer", + ) + .map(|path| resolve_run_relative_path(run_output_dir, path)) else { + return Ok(()); + }; + let Some(processed_root) = viewer_path.parent() else { + return Ok(()); + }; + + let Some(full_svg_path) = artifact_path_by_label( + &manifest.native_capture.processed_artifacts, + "flamegraph-full-svg", + ) + .map(|path| resolve_run_relative_path(run_output_dir, path)) else { + return Ok(()); + }; + let Some(focused_svg_path) = artifact_path_by_label( + &manifest.native_capture.processed_artifacts, + "flamegraph-focused-svg", + ) + .map(|path| resolve_run_relative_path(run_output_dir, path)) else { + return Ok(()); + }; + let Some(full_folded_path) = artifact_path_by_label( + &manifest.native_capture.processed_artifacts, + "collapsed-stacks", + ) + .map(|path| resolve_run_relative_path(run_output_dir, path)) else { + return Ok(()); + }; + + if !full_svg_path.exists() || !focused_svg_path.exists() || !full_folded_path.exists() { + return Ok(()); + } + + let full_svg = std::fs::read_to_string(&full_svg_path) + .with_context(|| format!("reading {}", full_svg_path.display()))?; + let focused_svg = std::fs::read_to_string(&focused_svg_path) + .with_context(|| format!("reading {}", focused_svg_path.display()))?; + let full_folded = std::fs::read_to_string(&full_folded_path) + .with_context(|| format!("reading {}", full_folded_path.display()))?; + + let focused = derive_benchmark_focused_folded_stacks( + &full_folded, + benchmark_anchors_for_backend(manifest.backend), + ); + let focused_warning = if focused.folded.trim().is_empty() { + Some( + "No benchmark anchor frames were detected; the benchmark-only view is falling back to the full-process flamegraph." + .to_string(), + ) + } else { + None + }; + let focused_folded = if focused.folded.trim().is_empty() { + full_folded.as_str() + } else { + focused.folded.as_str() + }; + + let harness_timeline = build_viewer_harness_timeline(manifest); + let timeline_payload = + prepare_viewer_timeline_payload(run_output_dir, processed_root, manifest)?; + let source_links = load_viewer_source_links(run_output_dir, processed_root, manifest)?; + let browser_title = + flamegraph_browser_title(project_name_from_workspace_path(run_output_dir).as_deref()); + let viewer_html = render_flamegraph_viewer_html(FlamegraphViewerDoc { + title: flamegraph_title_for_manifest(manifest), + browser_title, + full_svg_document: full_svg, + focused_svg_document: focused_svg, + full_summary: summarize_folded_stacks( + &full_folded, + count_folded_stack_lines(&full_folded), + 0, + None, + ), + focused_summary: summarize_folded_stacks( + focused_folded, + focused.matched_stack_count, + focused.excluded_stack_count, + focused_warning, + ), + sampled_duration_secs: manifest + .capture_metadata + .sample_duration_secs + .map(|value| value as f64), + run_metadata: build_viewer_run_metadata(manifest), + harness_timeline, + timeline_lanes: timeline_payload.lanes.clone(), + timeline_total_duration_ns: timeline_payload.total_duration_ns, + timeline_note: timeline_payload.note, + default_mode: FlamegraphMode::Focused, + artifact_links: build_viewer_artifact_links( + run_output_dir, + processed_root, + manifest, + timeline_payload.trace_path.as_deref(), + ), + source_links: source_links.clone(), + source_link_note: viewer_source_link_note(manifest, &source_links), + }); + + std::fs::write(&viewer_path, viewer_html) + .with_context(|| format!("writing {}", viewer_path.display()))?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DifferentialViewerManifest { + run_id: String, + baseline: String, + candidate: String, + #[serde(default)] + target: Option, + #[serde(default)] + function: Option, + #[serde(default)] + backend: Option, + #[serde(default)] + normalize: bool, + viewer_path: String, + #[serde(default)] + summary_path: Option, + warnings: Vec, + modes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DifferentialViewerModeRecord { + mode: String, + #[serde(default)] + baseline_folded: Option, + #[serde(default)] + candidate_folded: Option, + diff_folded: String, + flamegraph_svg: String, + #[serde(default)] + baseline_samples: Option, + #[serde(default)] + candidate_samples: Option, +} + +fn refresh_differential_flamegraph_viewer_from_manifest_path( + diff_manifest_path: &Path, +) -> Result<()> { + let diff_manifest_dir = diff_manifest_path + .parent() + .context("differential manifest path must have a parent directory")?; + let diff_manifest: DifferentialViewerManifest = serde_json::from_slice( + &std::fs::read(diff_manifest_path) + .with_context(|| format!("reading {}", diff_manifest_path.display()))?, + ) + .with_context(|| format!("parsing {}", diff_manifest_path.display()))?; + + let baseline_path = resolve_external_manifest_path(diff_manifest_dir, &diff_manifest.baseline); + let candidate_path = + resolve_external_manifest_path(diff_manifest_dir, &diff_manifest.candidate); + let viewer_path = resolve_external_manifest_path(diff_manifest_dir, &diff_manifest.viewer_path); + let processed_root = viewer_path + .parent() + .context("differential viewer path must have a parent directory")?; + + let baseline_manifest = load_profile_manifest(&baseline_path)?; + let candidate_manifest = load_profile_manifest(&candidate_path)?; + let candidate_run_dir = candidate_path + .parent() + .context("candidate manifest path must have a parent directory")?; + + let full_mode = differential_mode_record(&diff_manifest, "full")?; + let focused_mode = differential_mode_record(&diff_manifest, "focused")?; + + let full_folded_path = + resolve_external_manifest_path(diff_manifest_dir, &full_mode.diff_folded); + let focused_folded_path = + resolve_external_manifest_path(diff_manifest_dir, &focused_mode.diff_folded); + let full_svg_path = + resolve_external_manifest_path(diff_manifest_dir, &full_mode.flamegraph_svg); + let focused_svg_path = + resolve_external_manifest_path(diff_manifest_dir, &focused_mode.flamegraph_svg); + + let full_folded = std::fs::read_to_string(&full_folded_path) + .with_context(|| format!("reading {}", full_folded_path.display()))?; + let focused_folded = std::fs::read_to_string(&focused_folded_path) + .with_context(|| format!("reading {}", focused_folded_path.display()))?; + let full_svg = std::fs::read_to_string(&full_svg_path) + .with_context(|| format!("reading {}", full_svg_path.display()))?; + let focused_svg = std::fs::read_to_string(&focused_svg_path) + .with_context(|| format!("reading {}", focused_svg_path.display()))?; + + let shared_warning = diff_manifest.warnings.first().cloned(); + let full_summary = summarize_folded_stacks( + &full_folded, + count_folded_stack_lines(&full_folded), + 0, + shared_warning.clone(), + ); + let focused_summary = summarize_folded_stacks( + &focused_folded, + count_folded_stack_lines(&focused_folded), + 0, + shared_warning, + ); + + let harness_timeline = build_viewer_harness_timeline(&candidate_manifest); + let timeline_payload = + prepare_viewer_timeline_payload(candidate_run_dir, processed_root, &candidate_manifest)?; + let source_links = + load_viewer_source_links(candidate_run_dir, processed_root, &candidate_manifest)?; + let browser_title = + flamegraph_browser_title(project_name_from_workspace_path(&candidate_path).as_deref()); + + let viewer_html = render_flamegraph_viewer_html(FlamegraphViewerDoc { + title: "Differential Flamegraph".into(), + browser_title, + full_svg_document: full_svg, + focused_svg_document: focused_svg, + full_summary, + focused_summary, + sampled_duration_secs: candidate_manifest + .capture_metadata + .sample_duration_secs + .map(|value| value as f64), + run_metadata: build_differential_viewer_run_metadata( + &diff_manifest.run_id, + &baseline_manifest, + &candidate_manifest, + ), + harness_timeline, + timeline_lanes: timeline_payload.lanes.clone(), + timeline_total_duration_ns: timeline_payload.total_duration_ns, + timeline_note: timeline_payload.note, + default_mode: FlamegraphMode::Focused, + artifact_links: build_differential_viewer_artifact_links( + processed_root, + candidate_run_dir, + &candidate_manifest, + &full_folded_path, + &focused_folded_path, + &full_svg_path, + &focused_svg_path, + timeline_payload.trace_path.as_deref(), + ), + source_links: source_links.clone(), + source_link_note: viewer_source_link_note(&candidate_manifest, &source_links), + }); + + std::fs::write(&viewer_path, viewer_html) + .with_context(|| format!("writing {}", viewer_path.display()))?; + Ok(()) +} + +fn differential_mode_record<'a>( + manifest: &'a DifferentialViewerManifest, + mode: &str, +) -> Result<&'a DifferentialViewerModeRecord> { + manifest + .modes + .iter() + .find(|record| record.mode == mode) + .with_context(|| format!("missing `{mode}` mode in differential viewer manifest")) +} + +fn resolve_external_manifest_path(base_dir: &Path, path: &str) -> PathBuf { + let path = Path::new(path); + if path.is_absolute() { + path.to_path_buf() + } else { + let manifest_relative = base_dir.join(path); + if manifest_relative.exists() { + return manifest_relative; + } + if let Some(workspace_root) = find_workspace_root(base_dir) { + let workspace_relative = workspace_root.join(path); + if workspace_relative.exists() || !manifest_relative.exists() { + return workspace_relative; + } + } + manifest_relative + } +} + +fn find_workspace_root(start: &Path) -> Option { + for ancestor in start.ancestors() { + if ancestor.join("Cargo.toml").exists() { + return Some(ancestor.to_path_buf()); + } + } + None +} + +fn build_differential_viewer_run_metadata( + diff_run_id: &str, + baseline_manifest: &ProfileManifest, + candidate_manifest: &ProfileManifest, +) -> Vec { + let mut metadata = Vec::new(); + metadata.push(ViewerMetadataItem { + label: "Baseline Run".into(), + value: baseline_manifest.run_id.clone(), + }); + metadata.push(ViewerMetadataItem { + label: "Candidate Run".into(), + value: candidate_manifest.run_id.clone(), + }); + metadata.push(ViewerMetadataItem { + label: "Target".into(), + value: match candidate_manifest.target { + MobileTarget::Android => "android".into(), + MobileTarget::Ios => "ios".into(), + }, + }); + metadata.push(ViewerMetadataItem { + label: "Backend".into(), + value: match candidate_manifest.backend { + ProfileBackend::AndroidNative => "android-native".into(), + ProfileBackend::IosInstruments => "ios-instruments".into(), + ProfileBackend::RustTracing => "rust-tracing".into(), + ProfileBackend::Auto => "auto".into(), + }, + }); + metadata.push(ViewerMetadataItem { + label: "Benchmark".into(), + value: candidate_manifest.function.clone(), + }); + if let Some(device) = &candidate_manifest.capture_metadata.device { + metadata.push(ViewerMetadataItem { + label: "Device".into(), + value: device.clone(), + }); + } + if let Some(os) = &candidate_manifest.capture_metadata.os { + metadata.push(ViewerMetadataItem { + label: "OS".into(), + value: os.clone(), + }); + } + if candidate_manifest + .capture_metadata + .benchmark_iterations + .is_some() + || candidate_manifest + .capture_metadata + .benchmark_warmup + .is_some() + { + let measured = candidate_manifest + .capture_metadata + .benchmark_iterations + .unwrap_or(0); + let warmup = candidate_manifest + .capture_metadata + .benchmark_warmup + .unwrap_or(0); + metadata.push(ViewerMetadataItem { + label: "Iterations".into(), + value: format!("{measured} measured / {warmup} warmup"), + }); + } + let mut capture_parts = Vec::new(); + if let Some(method) = &candidate_manifest.capture_metadata.capture_method { + capture_parts.push(method.clone()); + } + if let Some(mode) = candidate_manifest.capture_metadata.warmup_mode { + capture_parts.push(mode.as_str().to_string()); + } + if let Some(duration) = candidate_manifest.capture_metadata.sample_duration_secs { + capture_parts.push(format!("{duration}s sample")); + } + if !capture_parts.is_empty() { + metadata.push(ViewerMetadataItem { + label: "Capture".into(), + value: capture_parts.join(" · "), + }); + } + metadata.push(ViewerMetadataItem { + label: "Run ID".into(), + value: diff_run_id.to_string(), + }); + metadata +} + +fn build_differential_viewer_artifact_links( + processed_root: &Path, + candidate_run_dir: &Path, + candidate_manifest: &ProfileManifest, + full_folded_path: &Path, + focused_folded_path: &Path, + full_svg_path: &Path, + focused_svg_path: &Path, + trace_path: Option<&Path>, +) -> Vec { + let mut links = Vec::new(); + + for label in [ + "sample", + "simpleperf", + "trace-events", + "native-report", + "frame-locations", + ] { + if let Some(path) = + artifact_path_by_label(&candidate_manifest.native_capture.raw_artifacts, label) + .or_else(|| { + artifact_path_by_label( + &candidate_manifest.native_capture.processed_artifacts, + label, + ) + }) + .map(|path| resolve_run_relative_path(candidate_run_dir, path)) + .filter(|path| path.exists()) + { + links.push(ViewerArtifactLink::new( + artifact_display_label(label), + relative_path_from(processed_root, &path), + )); + } + } + + links.push(ViewerArtifactLink::new( + "Full folded stacks", + relative_path_from(processed_root, full_folded_path), + )); + links.push(ViewerArtifactLink::new( + "Benchmark-focused folded stacks", + relative_path_from(processed_root, focused_folded_path), + )); + links.push(ViewerArtifactLink::new( + "Full-process SVG", + relative_path_from(processed_root, full_svg_path), + )); + links.push(ViewerArtifactLink::new( + "Benchmark-only SVG", + relative_path_from(processed_root, focused_svg_path), + )); + + if let Some(path) = trace_path { + links.push(ViewerArtifactLink::new( + "Chronological trace", + relative_path_from(processed_root, path), + )); + } + + if let Some(path) = candidate_manifest.semantic_profile.spans_path.as_deref() { + links.push(ViewerArtifactLink::new( + "Semantic phases", + relative_path_from( + processed_root, + &resolve_run_relative_path(candidate_run_dir, path), + ), + )); + } + if let Some(path) = candidate_manifest.semantic_profile.timeline_path.as_deref() { + links.push(ViewerArtifactLink::new( + "Harness timeline", + relative_path_from( + processed_root, + &resolve_run_relative_path(candidate_run_dir, path), + ), + )); + } + + links +} + +fn artifact_path_by_label<'a>(artifacts: &'a [ArtifactRecord], label: &str) -> Option<&'a Path> { + artifacts + .iter() + .find(|artifact| artifact.label == label) + .map(|artifact| artifact.path.as_path()) +} + +fn benchmark_anchors_for_backend(backend: ProfileBackend) -> &'static [&'static str] { + match backend { + ProfileBackend::AndroidNative => ANDROID_BENCHMARK_ANCHORS, + ProfileBackend::IosInstruments => IOS_BENCHMARK_ANCHORS, + ProfileBackend::RustTracing | ProfileBackend::Auto => &[], + } +} + +fn flamegraph_browser_title(project_name: Option<&str>) -> String { + match project_name.map(str::trim).filter(|name| !name.is_empty()) { + Some(name) => format!("Mobench Flamegraph - {name}"), + None => "Mobench Flamegraph".into(), + } +} + +fn project_name_from_workspace_path(path: &Path) -> Option { + find_workspace_root(path) + .and_then(|root| { + root.file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) + .or_else(|| { + repo_root().ok().and_then(|root| { + root.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .filter(|name| !name.is_empty()) + }) + }) +} + +fn flamegraph_title_for_manifest(manifest: &ProfileManifest) -> String { + match manifest.backend { + ProfileBackend::AndroidNative => "Android Native Profile".into(), + ProfileBackend::IosInstruments => "iOS Native Profile".into(), + ProfileBackend::RustTracing => "Rust Tracing Profile".into(), + ProfileBackend::Auto => "Native Profile".into(), + } +} + +fn build_viewer_run_metadata(manifest: &ProfileManifest) -> Vec { + let mut metadata = Vec::new(); + metadata.push(ViewerMetadataItem { + label: "Run ID".into(), + value: manifest.run_id.clone(), + }); + metadata.push(ViewerMetadataItem { + label: "Target".into(), + value: match manifest.target { + MobileTarget::Android => "android".into(), + MobileTarget::Ios => "ios".into(), + }, + }); + metadata.push(ViewerMetadataItem { + label: "Backend".into(), + value: match manifest.backend { + ProfileBackend::AndroidNative => "android-native".into(), + ProfileBackend::IosInstruments => "ios-instruments".into(), + ProfileBackend::RustTracing => "rust-tracing".into(), + ProfileBackend::Auto => "auto".into(), + }, + }); + metadata.push(ViewerMetadataItem { + label: "Benchmark".into(), + value: manifest.function.clone(), + }); + if let Some(device) = &manifest.capture_metadata.device { + metadata.push(ViewerMetadataItem { + label: "Device".into(), + value: device.clone(), + }); + } + if let Some(os) = &manifest.capture_metadata.os { + metadata.push(ViewerMetadataItem { + label: "OS".into(), + value: os.clone(), + }); + } + if manifest.capture_metadata.benchmark_iterations.is_some() + || manifest.capture_metadata.benchmark_warmup.is_some() + { + let measured = manifest.capture_metadata.benchmark_iterations.unwrap_or(0); + let warmup = manifest.capture_metadata.benchmark_warmup.unwrap_or(0); + metadata.push(ViewerMetadataItem { + label: "Iterations".into(), + value: format!("{measured} measured / {warmup} warmup"), + }); + } + let mut capture_parts = Vec::new(); + if let Some(method) = &manifest.capture_metadata.capture_method { + capture_parts.push(method.clone()); + } + if let Some(mode) = manifest.capture_metadata.warmup_mode { + capture_parts.push(mode.as_str().to_string()); + } + if let Some(duration) = manifest.capture_metadata.sample_duration_secs { + capture_parts.push(format!("{duration}s sample")); + } + if !capture_parts.is_empty() { + metadata.push(ViewerMetadataItem { + label: "Capture".into(), + value: capture_parts.join(" · "), + }); + } + metadata +} + +fn build_viewer_harness_timeline(manifest: &ProfileManifest) -> Vec { + manifest + .semantic_profile + .harness_timeline + .iter() + .map(|span| ViewerHarnessTimelineSpan { + phase: span.phase.clone(), + start_offset_ns: span.start_offset_ns, + end_offset_ns: span.end_offset_ns, + iteration: span.iteration, + }) + .collect() +} + +fn build_harness_only_viewer_timeline_lanes(manifest: &ProfileManifest) -> Vec { + let harness_events: Vec = manifest + .semantic_profile + .harness_timeline + .iter() + .map(|span| ViewerTraceEvent { + event_kind: "span".into(), + start_offset_ns: span.start_offset_ns, + end_offset_ns: Some(span.end_offset_ns), + frames: Vec::new(), + phase: Some(span.phase.clone()), + iteration: span.iteration, + }) + .collect(); + + let mut lanes = Vec::new(); + if !harness_events.is_empty() { + lanes.push(ViewerTraceLane { + id: "harness".into(), + label: "Harness".into(), + events: harness_events, + }); + } + lanes +} + +fn compute_timeline_total_duration_ns( + harness_timeline: &[ViewerHarnessTimelineSpan], + sampled_duration_secs: Option, +) -> Option { + harness_timeline + .iter() + .map(|span| span.end_offset_ns) + .max() + .or_else(|| sampled_duration_secs.map(|value| value.saturating_mul(1_000_000_000))) +} + +fn trace_lanes_have_sample_events(lanes: &[ViewerTraceLane]) -> bool { + lanes.iter().any(|lane| { + lane.events + .iter() + .any(|event| event.event_kind == "sample" && !event.frames.is_empty()) + }) +} + +fn build_timeline_note(lanes: &[ViewerTraceLane]) -> Option { + if lanes.is_empty() { + return Some( + "Timeline mode becomes available once exact harness intervals or chronological trace events are recorded." + .into(), + ); + } + if trace_lanes_have_sample_events(lanes) { + Some( + "Timeline mode shows exact harness chronology plus recorded stack samples. Aggregate flamegraph views remain full-session hotspot summaries." + .into(), + ) + } else { + Some( + "Harness-only timeline. This capture recorded exact phase timing, but it does not include time-ordered stack samples for the selected interval." + .into(), + ) + } +} + +fn write_chronological_trace_sidecar( + trace_path: &Path, + manifest: &ProfileManifest, + lanes: &[ViewerTraceLane], + total_duration_ns: Option, + source_kind: &str, +) -> Result> { + let Some(total_duration_ns) = total_duration_ns else { + return Ok(None); + }; + if lanes.is_empty() { + return Ok(None); + } + + let trace = ChronologicalTraceRecord { + source: ChronologicalTraceSourceRecord { + kind: source_kind.into(), + profiler: manifest + .capture_metadata + .capture_method + .clone() + .unwrap_or_else(|| match manifest.backend { + ProfileBackend::AndroidNative => "simpleperf".into(), + ProfileBackend::IosInstruments => "sample".into(), + ProfileBackend::RustTracing => "trace-events".into(), + ProfileBackend::Auto => "unknown".into(), + }), + origin: match manifest.provider { + ProfileProvider::Local => "local".into(), + ProfileProvider::Browserstack => "browserstack".into(), + }, + }, + total_duration_ns, + lanes: lanes.to_vec(), + }; + if let Some(parent) = trace_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&trace_path, serde_json::to_vec_pretty(&trace)?) + .with_context(|| format!("writing {}", trace_path.display()))?; + Ok(Some(trace_path.to_path_buf())) +} + +fn build_viewer_artifact_links( + run_output_dir: &Path, + processed_root: &Path, + manifest: &ProfileManifest, + trace_path: Option<&Path>, +) -> Vec { + let mut links = Vec::new(); + let artifact_order = [ + "simpleperf", + "sample", + "trace-events", + "native-report", + "frame-locations", + "collapsed-stacks", + "benchmark-focused-stacks", + "flamegraph-full-svg", + "flamegraph-focused-svg", + ]; + + for label in artifact_order { + if let Some(path) = artifact_path_by_label(&manifest.native_capture.raw_artifacts, label) + .or_else(|| artifact_path_by_label(&manifest.native_capture.processed_artifacts, label)) + .map(|path| resolve_run_relative_path(run_output_dir, path)) + .filter(|path| path.exists()) + { + links.push(ViewerArtifactLink::new( + artifact_display_label(label), + relative_path_from(processed_root, &path), + )); + } + } + + if let Some(path) = trace_path { + links.push(ViewerArtifactLink::new( + "Chronological trace", + relative_path_from(processed_root, path), + )); + } + + if let Some(path) = manifest.semantic_profile.spans_path.as_deref() { + links.push(ViewerArtifactLink::new( + "Semantic phases", + relative_path_from( + processed_root, + &resolve_run_relative_path(run_output_dir, path), + ), + )); + } + if let Some(path) = manifest.semantic_profile.timeline_path.as_deref() { + links.push(ViewerArtifactLink::new( + "Harness timeline", + relative_path_from( + processed_root, + &resolve_run_relative_path(run_output_dir, path), + ), + )); + } + + links +} + +fn artifact_display_label(label: &str) -> String { + match label { + "simpleperf" => "Raw sample.perf".into(), + "sample" => "Raw sample.txt".into(), + "trace-events" => "Raw trace-events.json".into(), + "native-report" => "Native report".into(), + "frame-locations" => "Frame locations".into(), + "collapsed-stacks" => "Full folded stacks".into(), + "benchmark-focused-stacks" => "Benchmark-focused folded stacks".into(), + "flamegraph-full-svg" => "Full-process SVG".into(), + "flamegraph-focused-svg" => "Benchmark-only SVG".into(), + _ => label.to_string(), + } +} + +fn resolve_run_relative_path(run_output_dir: &Path, path: &Path) -> PathBuf { + if path.is_absolute() || path.starts_with(run_output_dir) { + path.to_path_buf() + } else { + run_output_dir.join(path) + } +} + +fn relative_path_from(base_dir: &Path, target: &Path) -> String { + let base_components: Vec<_> = base_dir.components().collect(); + let target_components: Vec<_> = target.components().collect(); + let mut shared = 0; + while shared < base_components.len() + && shared < target_components.len() + && base_components[shared] == target_components[shared] + { + shared += 1; + } + + let mut relative = PathBuf::new(); + for _ in shared..base_components.len() { + relative.push(".."); + } + for component in &target_components[shared..] { + relative.push(component.as_os_str()); + } + + if relative.as_os_str().is_empty() { + ".".into() + } else { + relative.to_string_lossy().replace('\\', "/") + } +} + +fn default_source_link_note(manifest: &ProfileManifest) -> Option { + match manifest.backend { + ProfileBackend::IosInstruments => Some( + "Source links are unavailable for simulator-host `sample` sessions in this release." + .into(), + ), + ProfileBackend::AndroidNative => Some( + "Source links are unavailable because this capture did not record Android frame location metadata.".into(), + ), + ProfileBackend::RustTracing => Some( + "Source links are unavailable for trace-events output in this release.".into(), + ), + ProfileBackend::Auto => None, + } +} + +fn viewer_source_link_note( + manifest: &ProfileManifest, + source_links: &[FrameSourceLink], +) -> Option { + if source_links.is_empty() { + default_source_link_note(manifest) + } else { + None + } +} + +fn load_viewer_source_links( + run_output_dir: &Path, + processed_root: &Path, + manifest: &ProfileManifest, +) -> Result> { + let Some(sidecar_path) = artifact_path_by_label( + &manifest.native_capture.processed_artifacts, + "frame-locations", + ) + .map(|path| resolve_run_relative_path(run_output_dir, path)) else { + return Ok(Vec::new()); + }; + if !sidecar_path.exists() { + return Ok(Vec::new()); + } + let records: Vec = serde_json::from_slice( + &std::fs::read(&sidecar_path) + .with_context(|| format!("reading {}", sidecar_path.display()))?, + ) + .with_context(|| format!("parsing {}", sidecar_path.display()))?; + let repo_root = repo_root().ok(); + Ok(records + .into_iter() + .filter_map(|record| { + frame_location_record_to_source_link(processed_root, repo_root.as_deref(), record) + }) + .collect()) +} + +fn frame_location_record_to_source_link( + processed_root: &Path, + repo_root: Option<&Path>, + record: FrameLocationRecord, +) -> Option { + let absolute_path = if record.source_path.is_absolute() { + record.source_path.clone() + } else if let Some(root) = repo_root { + root.join(&record.source_path) + } else { + record.source_path.clone() + }; + let display_path = if let Some(root) = repo_root { + absolute_path + .strip_prefix(root) + .map(Path::to_path_buf) + .unwrap_or_else(|_| absolute_path.clone()) + } else { + absolute_path.clone() + }; + let href = format!( + "{}#L{}", + relative_path_from(processed_root, &absolute_path), + record.line + ); + Some(FrameSourceLink { + frame: record.frame, + location: format!("{}:{}", display_path.display(), record.line), + href, + }) +} + pub fn cmd_profile_summarize(args: &ProfileSummarizeArgs) -> Result<()> { let rendered = cmd_profile_summarize_for_test(args)?; if let Some(path) = &args.output { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(path, rendered.as_bytes())?; - } else { - println!("{rendered}"); + std::fs::write(path, rendered.as_bytes())?; + } else { + println!("{rendered}"); + } + Ok(()) +} + +pub fn cmd_profile_diff(args: &ProfileDiffArgs) -> Result<()> { + let baseline_manifest = load_profile_manifest(&args.baseline)?; + let candidate_manifest = load_profile_manifest(&args.candidate)?; + validate_profile_diff_inputs( + &args.baseline, + &baseline_manifest, + &args.candidate, + &candidate_manifest, + )?; + + let baseline_run_dir = args + .baseline + .parent() + .context("baseline manifest path must have a parent directory")?; + let candidate_run_dir = args + .candidate + .parent() + .context("candidate manifest path must have a parent directory")?; + let diff_run_id = format!( + "{}--vs--{}", + baseline_manifest.run_id, candidate_manifest.run_id + ); + let diff_run_dir = args.output_dir.join(&diff_run_id); + let processed_root = diff_run_dir.join("artifacts/processed"); + std::fs::create_dir_all(&processed_root)?; + + let full_mode = build_profile_diff_mode( + baseline_run_dir, + &baseline_manifest, + candidate_run_dir, + &candidate_manifest, + "full", + "collapsed-stacks", + processed_root.join("diff.full.folded"), + processed_root.join("flamegraph.full.svg"), + args.normalize, + )?; + let focused_mode = build_profile_diff_mode( + baseline_run_dir, + &baseline_manifest, + candidate_run_dir, + &candidate_manifest, + "focused", + "benchmark-focused-stacks", + processed_root.join("diff.focused.folded"), + processed_root.join("flamegraph.focused.svg"), + args.normalize, + )?; + + let viewer_path = processed_root.join("flamegraph.html"); + let summary_path = diff_run_dir.join("summary.md"); + let diff_manifest = DifferentialViewerManifest { + run_id: diff_run_id, + baseline: path_string(&args.baseline), + candidate: path_string(&args.candidate), + target: Some(candidate_manifest.target), + function: Some(candidate_manifest.function.clone()), + backend: Some(candidate_manifest.backend), + normalize: args.normalize, + viewer_path: path_string(&viewer_path), + summary_path: Some(path_string(&summary_path)), + warnings: vec![ + "Differential flamegraph colors: red = hotter in candidate, blue = hotter in baseline. Frame widths follow candidate sample counts." + .into(), + ], + modes: vec![full_mode, focused_mode], + }; + + let diff_manifest_path = diff_run_dir.join("profile-diff.json"); + write_differential_manifest(&diff_manifest_path, &diff_manifest)?; + refresh_differential_flamegraph_viewer_from_manifest_path(&diff_manifest_path)?; + + let summary = render_profile_diff_markdown(&diff_manifest); + std::fs::write(&summary_path, summary.as_bytes()) + .with_context(|| format!("writing {}", summary_path.display()))?; + + std::fs::create_dir_all(&args.output_dir)?; + write_differential_manifest(&args.output_dir.join("profile-diff.json"), &diff_manifest)?; + std::fs::write(args.output_dir.join("summary.md"), summary.as_bytes())?; + + println!( + "Differential profile written to {}", + diff_manifest_path.display() + ); + println!("Differential summary written to {}", summary_path.display()); + println!("Differential viewer written to {}", viewer_path.display()); + Ok(()) +} + +fn validate_profile_diff_inputs( + baseline_path: &Path, + baseline_manifest: &ProfileManifest, + candidate_path: &Path, + candidate_manifest: &ProfileManifest, +) -> Result<()> { + if baseline_manifest.target != candidate_manifest.target { + bail!( + "profile diff requires the same target on both sides, got `{}` from {} and `{}` from {}", + baseline_manifest.target.as_str(), + baseline_path.display(), + candidate_manifest.target.as_str(), + candidate_path.display() + ); + } + if baseline_manifest.backend != candidate_manifest.backend { + bail!( + "profile diff requires the same backend on both sides, got `{:?}` from {} and `{:?}` from {}", + baseline_manifest.backend, + baseline_path.display(), + candidate_manifest.backend, + candidate_path.display() + ); + } + if baseline_manifest.function != candidate_manifest.function { + bail!( + "profile diff requires the same benchmark function on both sides, got `{}` from {} and `{}` from {}", + baseline_manifest.function, + baseline_path.display(), + candidate_manifest.function, + candidate_path.display() + ); + } + Ok(()) +} + +fn build_profile_diff_mode( + baseline_run_dir: &Path, + baseline_manifest: &ProfileManifest, + candidate_run_dir: &Path, + candidate_manifest: &ProfileManifest, + mode: &str, + artifact_label: &str, + diff_folded_path: PathBuf, + flamegraph_svg_path: PathBuf, + normalize: bool, +) -> Result { + let baseline_folded_path = + resolve_required_processed_artifact(baseline_run_dir, baseline_manifest, artifact_label)?; + let candidate_folded_path = + resolve_required_processed_artifact(candidate_run_dir, candidate_manifest, artifact_label)?; + + if let Some(parent) = diff_folded_path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut writer = BufWriter::new( + File::create(&diff_folded_path) + .with_context(|| format!("creating {}", diff_folded_path.display()))?, + ); + inferno::differential::from_files( + inferno::differential::Options { + normalize, + strip_hex: false, + }, + &baseline_folded_path, + &candidate_folded_path, + &mut writer, + ) + .with_context(|| format!("diffing folded stacks for `{mode}` mode"))?; + + let diff_folded = std::fs::read_to_string(&diff_folded_path) + .with_context(|| format!("reading {}", diff_folded_path.display()))?; + let svg = render_standalone_flamegraph_svg(&diff_folded, "Differential Flamegraph")?; + std::fs::write(&flamegraph_svg_path, svg.as_bytes()) + .with_context(|| format!("writing {}", flamegraph_svg_path.display()))?; + + Ok(DifferentialViewerModeRecord { + mode: mode.into(), + baseline_folded: Some(path_string(&baseline_folded_path)), + candidate_folded: Some(path_string(&candidate_folded_path)), + diff_folded: path_string(&diff_folded_path), + flamegraph_svg: path_string(&flamegraph_svg_path), + baseline_samples: Some(total_samples_in_folded_path(&baseline_folded_path)?), + candidate_samples: Some(total_samples_in_folded_path(&candidate_folded_path)?), + }) +} + +fn resolve_required_processed_artifact( + run_output_dir: &Path, + manifest: &ProfileManifest, + label: &str, +) -> Result { + artifact_path_by_label(&manifest.native_capture.processed_artifacts, label) + .map(|path| resolve_run_relative_path(run_output_dir, path)) + .filter(|path| path.exists()) + .with_context(|| { + format!( + "profile manifest `{}` is missing processed artifact `{label}`", + manifest.run_id + ) + }) +} + +fn total_samples_in_folded_path(path: &Path) -> Result { + let folded = + std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + Ok(folded + .lines() + .filter_map(split_folded_stack_line) + .map(|(_, count)| count) + .sum()) +} + +fn split_folded_stack_line(line: &str) -> Option<(&str, u64)> { + let (stack, count) = line.rsplit_once(' ')?; + if stack.is_empty() || !count.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + Some((stack, count.parse().ok()?)) +} + +fn write_differential_manifest(path: &Path, manifest: &DifferentialViewerManifest) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, serde_json::to_vec_pretty(manifest)?) + .with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +fn render_profile_diff_markdown(manifest: &DifferentialViewerManifest) -> String { + let mut markdown = String::new(); + let _ = writeln!(markdown, "# Differential Flamegraph Summary"); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "- Run ID: `{}`", manifest.run_id); + if let Some(target) = manifest.target { + let _ = writeln!(markdown, "- Target: `{}`", target.as_str()); + } + if let Some(function) = &manifest.function { + let _ = writeln!(markdown, "- Function: `{function}`"); + } + let _ = writeln!(markdown, "- Baseline: `{}`", manifest.baseline); + let _ = writeln!(markdown, "- Candidate: `{}`", manifest.candidate); + let _ = writeln!(markdown, "- Normalize: `{}`", manifest.normalize); + let _ = writeln!(markdown, "- Viewer: `{}`", manifest.viewer_path); + let _ = writeln!(markdown); + let _ = writeln!( + markdown, + "- Differential semantics: `red = hotter in candidate, blue = hotter in baseline, widths = candidate samples`" + ); + if !manifest.warnings.is_empty() { + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## Notes"); + let _ = writeln!(markdown); + for warning in &manifest.warnings { + let _ = writeln!(markdown, "- {}", warning); + } + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## Modes"); + let _ = writeln!(markdown); + for mode in &manifest.modes { + let _ = writeln!(markdown, "### {}", mode.mode); + if let Some(path) = &mode.baseline_folded { + let _ = writeln!(markdown, "- Baseline folded: `{}`", path); + } + if let Some(path) = &mode.candidate_folded { + let _ = writeln!(markdown, "- Candidate folded: `{}`", path); + } + let _ = writeln!(markdown, "- Diff folded: `{}`", mode.diff_folded); + let _ = writeln!(markdown, "- SVG: `{}`", mode.flamegraph_svg); + if let (Some(before), Some(after)) = (mode.baseline_samples, mode.candidate_samples) { + let _ = writeln!( + markdown, + "- Samples: baseline `{}` -> candidate `{}`", + before, after + ); + } + let _ = writeln!(markdown); } - Ok(()) + markdown +} + +fn path_string(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") } fn capture_status_label(status: CaptureStatus) -> &'static str { @@ -729,7 +2072,7 @@ pub(crate) fn write_android_symbolized_outputs( runtime_abi: Option<&str>, llvm_addr2line_path: &Path, ) -> Result { - write_android_symbolized_outputs_with_resolver( + let record = write_android_symbolized_outputs_with_resolver( folded_stacks, native_libraries, processed_root, @@ -741,7 +2084,15 @@ pub(crate) fn write_android_symbolized_outputs( offset, ) }, - ) + )?; + write_android_frame_location_sidecar( + folded_stacks, + native_libraries, + processed_root, + runtime_abi, + llvm_addr2line_path, + )?; + Ok(record) } pub(crate) fn write_android_symbolized_outputs_with_resolver( @@ -756,12 +2107,13 @@ where { std::fs::create_dir_all(processed_root)?; - let (symbolized_stacks, mut record, report) = symbolize_android_folded_stacks_with_native_libraries( - folded_stacks, - native_libraries, - runtime_abi, - resolve, - ); + let (symbolized_stacks, mut record, report) = + symbolize_android_folded_stacks_with_native_libraries( + folded_stacks, + native_libraries, + runtime_abi, + resolve, + ); std::fs::write(processed_root.join("stacks.folded"), &symbolized_stacks)?; std::fs::write(processed_root.join("native-report.txt"), &report)?; @@ -779,6 +2131,140 @@ where Ok(record) } +fn write_android_frame_location_sidecar( + folded_stacks: &str, + native_libraries: &[NativeLibraryArtifact], + processed_root: &Path, + runtime_abi: Option<&str>, + llvm_addr2line_path: &Path, +) -> Result<()> { + let records = collect_android_frame_location_records( + folded_stacks, + native_libraries, + runtime_abi, + llvm_addr2line_path, + )?; + if records.is_empty() { + return Ok(()); + } + let sidecar_path = processed_root.join("frame-locations.json"); + std::fs::write(&sidecar_path, serde_json::to_vec_pretty(&records)?) + .with_context(|| format!("writing {}", sidecar_path.display()))?; + Ok(()) +} + +fn collect_android_frame_location_records( + folded_stacks: &str, + native_libraries: &[NativeLibraryArtifact], + runtime_abi: Option<&str>, + llvm_addr2line_path: &Path, +) -> Result> { + let mut records = BTreeMap::::new(); + for line in folded_stacks.lines().filter(|line| !line.trim().is_empty()) { + let Some((stack, _count)) = split_folded_stack_line(line) else { + continue; + }; + for frame in stack.split(';') { + let Some((library_name, offset)) = parse_android_native_offset_frame(frame) else { + continue; + }; + let Some(library_path) = + resolve_android_native_library_path(native_libraries, library_name, runtime_abi) + else { + continue; + }; + let Some(record) = + resolve_android_frame_location_with_tool(llvm_addr2line_path, library_path, offset) + else { + continue; + }; + records.entry(record.frame.clone()).or_insert(record); + } + } + Ok(records.into_values().collect()) +} + +fn resolve_android_frame_location_with_tool( + tool_path: &Path, + library_path: &Path, + offset: u64, +) -> Option { + let output = Command::new(tool_path) + .args(["-Cfpe"]) + .arg(library_path) + .arg(format!("0x{offset:x}")) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_android_addr2line_frame_location(&String::from_utf8_lossy(&output.stdout)) +} + +fn parse_android_addr2line_frame_location(stdout: &str) -> Option { + let mut symbol = None::; + let mut location = None::; + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed == "??" || trimmed.starts_with("?? ") { + continue; + } + if let Some((parsed_symbol, parsed_location)) = trimmed.split_once(" at ") { + symbol = Some(parsed_symbol.trim().to_owned()); + if !parsed_location.trim().is_empty() && !parsed_location.starts_with("??") { + location = Some(parsed_location.trim().to_owned()); + break; + } + continue; + } + if symbol.is_none() { + symbol = Some(trimmed.to_owned()); + continue; + } + if !trimmed.starts_with("??") { + location = Some(trimmed.to_owned()); + break; + } + } + let symbol = symbol?; + let location = location?; + let (source_path, line) = parse_addr2line_location(&location)?; + Some(FrameLocationRecord { + frame: symbol, + source_path, + line, + }) +} + +fn parse_addr2line_location(location: &str) -> Option<(PathBuf, u32)> { + let trimmed = location + .split(" (discriminator ") + .next() + .unwrap_or(location) + .trim(); + if trimmed.is_empty() || trimmed.starts_with("??") { + return None; + } + let (path, line) = trimmed.rsplit_once(':')?; + Some((PathBuf::from(path), line.parse().ok()?)) +} + +fn parse_android_native_offset_frame(frame: &str) -> Option<(&str, u64)> { + let marker = ".so[+"; + let marker_index = frame.find(marker)?; + let library_end = marker_index + 3; + let library_name = frame[..library_end].rsplit('/').next()?; + let offset_start = marker_index + marker.len(); + let offset_end = frame[offset_start..].find(']')? + offset_start; + let offset_raw = &frame[offset_start..offset_end]; + let offset = if let Some(hex) = offset_raw.strip_prefix("0x") { + u64::from_str_radix(hex, 16).ok()? + } else { + offset_raw.parse().ok()? + }; + Some((library_name, offset)) +} + fn write_dual_view_flamegraph_bundle( full_folded_stacks: &str, processed_root: &Path, @@ -835,19 +2321,33 @@ fn write_dual_view_flamegraph_bundle( let viewer_html = render_flamegraph_viewer_html(FlamegraphViewerDoc { title: title.to_string(), + browser_title: flamegraph_browser_title( + project_name_from_workspace_path(processed_root).as_deref(), + ), full_svg_document: full_svg, focused_svg_document: focused_svg, full_summary, focused_summary, + sampled_duration_secs: None, + run_metadata: Vec::new(), + harness_timeline: Vec::new(), + timeline_lanes: Vec::new(), + timeline_total_duration_ns: None, + timeline_note: None, default_mode: FlamegraphMode::Focused, artifact_links: vec![ ViewerArtifactLink::new(raw_artifact_label, raw_artifact_path), ViewerArtifactLink::new("Native report", "native-report.txt"), ViewerArtifactLink::new("Full folded stacks", "stacks.folded"), - ViewerArtifactLink::new("Benchmark-focused folded stacks", "benchmark.focused.folded"), + ViewerArtifactLink::new( + "Benchmark-focused folded stacks", + "benchmark.focused.folded", + ), ViewerArtifactLink::new("Full-process SVG", "flamegraph.full.svg"), ViewerArtifactLink::new("Benchmark-only SVG", "flamegraph.focused.svg"), ], + source_links: Vec::new(), + source_link_note: None, }); std::fs::write(processed_root.join("flamegraph.html"), viewer_html)?; @@ -1451,9 +2951,7 @@ fn execute_local_ios_capture(args: &ProfileRunArgs, manifest: &mut ProfileManife let stderr_path = log_dir.join("app.stderr.log"); let app_args = [ format!("--mobench-profile-bench-delay-ms={DEFAULT_IOS_BENCH_DELAY_MS}"), - format!( - "--mobench-profile-repeat-until-ms={DEFAULT_IOS_PROFILE_REPEAT_UNTIL_MS}" - ), + format!("--mobench-profile-repeat-until-ms={DEFAULT_IOS_PROFILE_REPEAT_UNTIL_MS}"), format!( "--mobench-profile-result-hold-ms={}", DEFAULT_IOS_CAPTURE_DURATION_SECS * 1_000 @@ -2315,6 +3813,34 @@ fn populate_semantic_profile_from_benchmark_value( manifest: &mut ProfileManifest, benchmark_value: &Value, ) { + if let Some(spec) = benchmark_value.get("spec") { + manifest.capture_metadata.benchmark_iterations = spec + .get("iterations") + .and_then(Value::as_u64) + .map(|value| value as u32); + manifest.capture_metadata.benchmark_warmup = spec + .get("warmup") + .and_then(Value::as_u64) + .map(|value| value as u32); + } + + if let Some(timeline) = benchmark_value.get("timeline").and_then(Value::as_array) { + manifest.semantic_profile.harness_timeline = timeline + .iter() + .filter_map(|span| { + Some(HarnessTimelineSpanRecord { + phase: span.get("phase")?.as_str()?.to_string(), + start_offset_ns: span.get("start_offset_ns")?.as_u64()?, + end_offset_ns: span.get("end_offset_ns")?.as_u64()?, + iteration: span + .get("iteration") + .and_then(Value::as_u64) + .map(|value| value as u32), + }) + }) + .collect(); + } + let Some(phases) = benchmark_value.get("phases").and_then(Value::as_array) else { return; }; @@ -2457,6 +3983,10 @@ fn build_capture_plan( label: "native-report".into(), path: processed_root.join("native-report.txt"), }, + ArtifactRecord { + label: "frame-locations".into(), + path: processed_root.join("frame-locations.json"), + }, ArtifactRecord { label: "benchmark-focused-stacks".into(), path: processed_root.join("benchmark.focused.folded"), @@ -2473,6 +4003,10 @@ fn build_capture_plan( label: "flamegraph-viewer".into(), path: processed_root.join("flamegraph.html"), }, + ArtifactRecord { + label: "chronological-trace".into(), + path: processed_root.join("chronological-trace.json"), + }, ], ), ProfileBackend::IosInstruments => ( @@ -2505,6 +4039,10 @@ fn build_capture_plan( label: "flamegraph-viewer".into(), path: processed_root.join("flamegraph.html"), }, + ArtifactRecord { + label: "chronological-trace".into(), + path: processed_root.join("chronological-trace.json"), + }, ], ), ProfileBackend::RustTracing => ( @@ -2539,6 +4077,7 @@ fn build_capture_plan( }, semantic_profile: SemanticProfileRecord { spans_path: Some(output_root.join("artifacts/semantic/phases.json")), + timeline_path: Some(output_root.join("artifacts/semantic/timeline.json")), ..SemanticProfileRecord::default() }, capture_metadata: CaptureMetadataRecord { @@ -2546,7 +4085,13 @@ fn build_capture_plan( .device .as_ref() .map(|device| device.identifier.clone()), + os: target + .device + .as_ref() + .map(|device| format!("{} {}", device.os, device.os_version)), sample_duration_secs: None, + benchmark_iterations: None, + benchmark_warmup: None, warmup_mode: resolve_capture_warmup_mode(args.provider, backend, args.warmup_mode), capture_method: Some(match backend { ProfileBackend::AndroidNative => "simpleperf".into(), @@ -3117,6 +4662,440 @@ mod tests { assert!(sidecar.contains("\"serialize\"")); } + fn write_timeline_demo_session( + output_dir: &Path, + run_output_dir: &Path, + ) -> Result { + let raw_root = run_output_dir.join("artifacts/raw"); + let processed_root = run_output_dir.join("artifacts/processed"); + let semantic_root = run_output_dir.join("artifacts/semantic"); + + std::fs::create_dir_all(&raw_root)?; + std::fs::create_dir_all(&processed_root)?; + std::fs::create_dir_all(&semantic_root)?; + + let mut manifest = sample_manifest(); + manifest.run_id = "ios-demo".into(); + manifest.target = MobileTarget::Ios; + manifest.backend = ProfileBackend::IosInstruments; + manifest.native_capture.status = CaptureStatus::Captured; + manifest.native_capture.symbolization.status = CaptureStatus::Captured; + manifest.native_capture.symbolization.tool = Some("sample".into()); + manifest.capture_metadata.device = Some("iPhone 17 Pro-26.2".into()); + manifest.capture_metadata.os = Some("iOS 26.2".into()); + manifest.capture_metadata.capture_method = Some("sample/simctl".into()); + manifest.capture_metadata.sample_duration_secs = Some(15); + manifest.capture_metadata.benchmark_iterations = Some(20); + manifest.capture_metadata.benchmark_warmup = Some(3); + manifest.capture_metadata.warmup_mode = Some(CaptureWarmupMode::Warm); + manifest.semantic_profile.spans_path = Some(semantic_root.join("phases.json")); + manifest.semantic_profile.timeline_path = Some(semantic_root.join("timeline.json")); + manifest.semantic_profile.harness_timeline = vec![ + HarnessTimelineSpanRecord { + phase: "setup".into(), + start_offset_ns: 0, + end_offset_ns: 500_000_000, + iteration: None, + }, + HarnessTimelineSpanRecord { + phase: "warmup-benchmark".into(), + start_offset_ns: 500_000_000, + end_offset_ns: 1_000_000_000, + iteration: Some(0), + }, + HarnessTimelineSpanRecord { + phase: "measured-benchmark".into(), + start_offset_ns: 1_000_000_000, + end_offset_ns: 1_400_000_000, + iteration: Some(0), + }, + HarnessTimelineSpanRecord { + phase: "measured-benchmark".into(), + start_offset_ns: 1_400_000_000, + end_offset_ns: 1_800_000_000, + iteration: Some(1), + }, + HarnessTimelineSpanRecord { + phase: "teardown".into(), + start_offset_ns: 1_800_000_000, + end_offset_ns: 2_100_000_000, + iteration: None, + }, + ]; + manifest.native_capture.raw_artifacts = vec![ArtifactRecord { + label: "sample".into(), + path: raw_root.join("sample.txt"), + }]; + manifest.native_capture.processed_artifacts = vec![ + ArtifactRecord { + label: "collapsed-stacks".into(), + path: processed_root.join("stacks.folded"), + }, + ArtifactRecord { + label: "native-report".into(), + path: processed_root.join("native-report.txt"), + }, + ArtifactRecord { + label: "benchmark-focused-stacks".into(), + path: processed_root.join("benchmark.focused.folded"), + }, + ArtifactRecord { + label: "flamegraph-full-svg".into(), + path: processed_root.join("flamegraph.full.svg"), + }, + ArtifactRecord { + label: "flamegraph-focused-svg".into(), + path: processed_root.join("flamegraph.focused.svg"), + }, + ArtifactRecord { + label: "flamegraph-viewer".into(), + path: processed_root.join("flamegraph.html"), + }, + ArtifactRecord { + label: "chronological-trace".into(), + path: processed_root.join("chronological-trace.json"), + }, + ]; + + std::fs::write(raw_root.join("sample.txt"), "synthetic sample output")?; + let folded = concat!( + "UIKitMain;runBenchmark(spec:);sample_fns::run_benchmark;sample_fns::fibonacci 5\n", + "UIKitMain;runBenchmark(spec:);sample_fns::run_benchmark;sample_fns::checksum 2\n", + ); + write_dual_view_flamegraph_bundle( + folded, + &processed_root, + "iOS Native Profile", + IOS_BENCHMARK_ANCHORS, + "../raw/sample.txt", + "Raw sample.txt", + )?; + std::fs::write( + processed_root.join("chronological-trace.json"), + serde_json::to_vec_pretty(&ChronologicalTraceRecord { + source: ChronologicalTraceSourceRecord { + kind: "mobench-demo-trace".into(), + profiler: "sample/simctl".into(), + origin: "local".into(), + }, + total_duration_ns: 2_100_000_000, + lanes: vec![ViewerTraceLane { + id: "main-thread".into(), + label: "Main Thread".into(), + events: vec![ + ViewerTraceEvent { + event_kind: "sample".into(), + start_offset_ns: 1_050_000_000, + end_offset_ns: Some(1_180_000_000), + frames: vec![ + "sample_fns::run_benchmark".into(), + "sample_fns::fibonacci".into(), + ], + phase: Some("measured-benchmark".into()), + iteration: Some(0), + }, + ViewerTraceEvent { + event_kind: "sample".into(), + start_offset_ns: 1_430_000_000, + end_offset_ns: Some(1_560_000_000), + frames: vec![ + "sample_fns::run_benchmark".into(), + "sample_fns::checksum".into(), + ], + phase: Some("measured-benchmark".into()), + iteration: Some(1), + }, + ], + }], + }) + .expect("serialize demo trace"), + )?; + + let args = ProfileRunArgs { + target: MobileTarget::Ios, + function: "sample_fns::fibonacci".into(), + provider: ProfileProvider::Local, + backend: ProfileBackend::IosInstruments, + format: ProfileFormat::Both, + output_dir: output_dir.to_path_buf(), + crate_path: None, + device: None, + os_version: None, + profile: None, + device_matrix: None, + config: None, + warmup_mode: Some(CaptureWarmupMode::Warm), + }; + write_profile_session_outputs(&args, run_output_dir, &manifest)?; + Ok(manifest) + } + + #[test] + fn write_profile_session_outputs_rewrites_flamegraph_with_timeline_payload() { + let dir = tempfile::tempdir().expect("temp dir"); + let run_output_dir = dir.path().join("ios-demo"); + + let manifest = + write_timeline_demo_session(dir.path(), &run_output_dir).expect("write demo session"); + + let viewer_html = + std::fs::read_to_string(run_output_dir.join("artifacts/processed/flamegraph.html")) + .expect("read flamegraph viewer"); + let trace_json = std::fs::read_to_string( + run_output_dir.join("artifacts/processed/chronological-trace.json"), + ) + .expect("read chronological trace"); + + assert!(viewer_html.contains("Timeline")); + assert!(viewer_html.contains("iPhone 17 Pro-26.2")); + assert!(viewer_html.contains("20 measured / 3 warmup")); + assert!(viewer_html.contains("sample/simctl")); + assert!(viewer_html.contains("\"Main Thread\"")); + assert!(viewer_html.contains("\"warmup-benchmark\"")); + assert!(trace_json.contains("\"mobench-demo-trace\"")); + assert!(trace_json.contains("\"Main Thread\"")); + assert!(trace_json.contains(&manifest.function)); + } + + #[test] + #[ignore] + fn generate_flamegraph_timeline_demo_artifact() { + let output_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target/mobench/flamegraph-timeline-demo"); + let _ = std::fs::remove_dir_all(&output_dir); + let run_output_dir = output_dir.join("ios-demo"); + + write_timeline_demo_session(&output_dir, &run_output_dir).expect("generate demo artifact"); + } + + #[test] + fn refresh_differential_viewer_manifest_writes_timeline_capable_html() { + let dir = tempfile::tempdir().expect("temp dir"); + let baseline_run_dir = dir.path().join("baseline-run"); + let candidate_run_dir = dir.path().join("candidate-run"); + let diff_run_dir = dir.path().join("diff-run"); + let diff_processed_dir = diff_run_dir.join("artifacts/processed"); + std::fs::create_dir_all(&baseline_run_dir).expect("create baseline run dir"); + std::fs::create_dir_all(&candidate_run_dir).expect("create candidate run dir"); + std::fs::create_dir_all(&diff_processed_dir).expect("create diff processed dir"); + + let mut baseline_manifest = sample_manifest(); + baseline_manifest.run_id = "baseline-run".into(); + baseline_manifest.target = MobileTarget::Ios; + baseline_manifest.backend = ProfileBackend::IosInstruments; + baseline_manifest.capture_metadata.device = Some("iPhone 17 Pro-26.2".into()); + baseline_manifest.capture_metadata.os = Some("iOS 26.2".into()); + baseline_manifest.capture_metadata.capture_method = Some("sample/simctl".into()); + + let mut candidate_manifest = baseline_manifest.clone(); + candidate_manifest.run_id = "candidate-run".into(); + + std::fs::write( + baseline_run_dir.join("profile.json"), + serde_json::to_vec_pretty(&baseline_manifest).expect("serialize baseline manifest"), + ) + .expect("write baseline manifest"); + std::fs::write( + candidate_run_dir.join("profile.json"), + serde_json::to_vec_pretty(&candidate_manifest).expect("serialize candidate manifest"), + ) + .expect("write candidate manifest"); + + std::fs::write( + diff_processed_dir.join("diff.full.folded"), + "root;main 12\n", + ) + .expect("write diff full folded"); + std::fs::write( + diff_processed_dir.join("diff.focused.folded"), + "bench;sample_fns::fibonacci 7\n", + ) + .expect("write diff focused folded"); + std::fs::write( + diff_processed_dir.join("flamegraph.full.svg"), + "", + ) + .expect("write diff full svg"); + std::fs::write( + diff_processed_dir.join("flamegraph.focused.svg"), + "", + ) + .expect("write diff focused svg"); + + let diff_manifest_path = dir.path().join("profile-diff.json"); + std::fs::write( + &diff_manifest_path, + serde_json::to_vec_pretty(&serde_json::json!({ + "run_id": "baseline-run--vs--candidate-run", + "baseline": "baseline-run/profile.json", + "candidate": "candidate-run/profile.json", + "viewer_path": "diff-run/artifacts/processed/flamegraph.html", + "warnings": [ + "Differential flamegraph colors: red = hotter in candidate, blue = hotter in baseline. Frame widths follow candidate sample counts." + ], + "modes": [ + { + "mode": "full", + "diff_folded": "diff-run/artifacts/processed/diff.full.folded", + "flamegraph_svg": "diff-run/artifacts/processed/flamegraph.full.svg" + }, + { + "mode": "focused", + "diff_folded": "diff-run/artifacts/processed/diff.focused.folded", + "flamegraph_svg": "diff-run/artifacts/processed/flamegraph.focused.svg" + } + ] + })) + .expect("serialize diff manifest"), + ) + .expect("write diff manifest"); + + refresh_differential_flamegraph_viewer_from_manifest_path(&diff_manifest_path) + .expect("refresh differential viewer"); + + let html = std::fs::read_to_string(diff_processed_dir.join("flamegraph.html")) + .expect("read differential flamegraph html"); + assert!(html.contains("data-mode=\"timeline\"")); + assert!(html.contains("Baseline Run")); + assert!(html.contains("Candidate Run")); + assert!(html.contains("Chronological trace")); + assert!(html.contains("Exact harness time")); + } + + #[test] + fn cmd_profile_diff_writes_runtime_bundle() { + let dir = tempfile::tempdir().expect("temp dir"); + let baseline_run_dir = dir.path().join("baseline-run"); + let candidate_run_dir = dir.path().join("candidate-run"); + std::fs::create_dir_all(baseline_run_dir.join("artifacts/processed")) + .expect("create baseline processed"); + std::fs::create_dir_all(candidate_run_dir.join("artifacts/processed")) + .expect("create candidate processed"); + + let mut baseline_manifest = sample_manifest(); + baseline_manifest.run_id = "baseline-run".into(); + baseline_manifest.native_capture.status = CaptureStatus::Captured; + baseline_manifest.native_capture.symbolization.status = CaptureStatus::Captured; + let mut candidate_manifest = baseline_manifest.clone(); + candidate_manifest.run_id = "candidate-run".into(); + + std::fs::write( + baseline_run_dir.join("artifacts/processed/stacks.folded"), + "root;sample_fns::fibonacci 4\n", + ) + .expect("write baseline full"); + std::fs::write( + baseline_run_dir.join("artifacts/processed/benchmark.focused.folded"), + "sample_fns::run_benchmark;sample_fns::fibonacci 4\n", + ) + .expect("write baseline focused"); + std::fs::write( + candidate_run_dir.join("artifacts/processed/stacks.folded"), + "root;sample_fns::fibonacci 7\nroot;sample_fns::checksum 1\n", + ) + .expect("write candidate full"); + std::fs::write( + candidate_run_dir.join("artifacts/processed/benchmark.focused.folded"), + "sample_fns::run_benchmark;sample_fns::fibonacci 7\n", + ) + .expect("write candidate focused"); + + write_profile_manifest(&baseline_run_dir.join("profile.json"), &baseline_manifest) + .expect("write baseline manifest"); + write_profile_manifest(&candidate_run_dir.join("profile.json"), &candidate_manifest) + .expect("write candidate manifest"); + + let output_dir = dir.path().join("diff"); + cmd_profile_diff(&ProfileDiffArgs { + baseline: baseline_run_dir.join("profile.json"), + candidate: candidate_run_dir.join("profile.json"), + output_dir: output_dir.clone(), + normalize: true, + }) + .expect("run profile diff"); + + let diff_dir = output_dir.join("baseline-run--vs--candidate-run"); + assert!(diff_dir.join("profile-diff.json").exists()); + assert!(diff_dir.join("summary.md").exists()); + assert!( + diff_dir + .join("artifacts/processed/flamegraph.html") + .exists() + ); + let summary = std::fs::read_to_string(diff_dir.join("summary.md")).expect("read summary"); + assert!(summary.contains("Differential Flamegraph Summary")); + assert!(summary.contains("Normalize: `true`")); + } + + #[test] + fn refresh_flamegraph_viewer_includes_android_source_links_when_sidecar_exists() { + let dir = tempfile::tempdir().expect("temp dir"); + let run_output_dir = dir.path().join("android-source-demo"); + let processed_root = run_output_dir.join("artifacts/processed"); + std::fs::create_dir_all(&processed_root).expect("create processed root"); + + let mut manifest = sample_manifest(); + manifest.run_id = "android-source-demo".into(); + manifest.native_capture.status = CaptureStatus::Captured; + manifest.native_capture.symbolization.status = CaptureStatus::Captured; + manifest + .native_capture + .processed_artifacts + .push(ArtifactRecord { + label: "frame-locations".into(), + path: PathBuf::from("artifacts/processed/frame-locations.json"), + }); + + std::fs::write( + processed_root.join("stacks.folded"), + "root;sample_fns::fibonacci 5\n", + ) + .expect("write full folded"); + std::fs::write( + processed_root.join("benchmark.focused.folded"), + "sample_fns::run_benchmark;sample_fns::fibonacci 5\n", + ) + .expect("write focused folded"); + std::fs::write( + processed_root.join("flamegraph.full.svg"), + "", + ) + .expect("write full svg"); + std::fs::write( + processed_root.join("flamegraph.focused.svg"), + "", + ) + .expect("write focused svg"); + std::fs::write( + processed_root.join("frame-locations.json"), + serde_json::to_vec_pretty(&vec![FrameLocationRecord { + frame: "sample_fns::fibonacci".into(), + source_path: PathBuf::from("crates/sample-fns/src/lib.rs"), + line: 42, + }]) + .expect("serialize frame locations"), + ) + .expect("write frame locations"); + + refresh_flamegraph_viewer_from_manifest(&run_output_dir, &manifest) + .expect("refresh flamegraph viewer"); + + let html = std::fs::read_to_string(processed_root.join("flamegraph.html")) + .expect("read viewer html"); + assert!(html.contains("sample_fns::fibonacci")); + assert!(html.contains("crates/sample-fns/src/lib.rs:42")); + } + + #[test] + #[ignore] + fn refresh_profile_diff_demo_viewer_artifact() { + let diff_manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("target/mobench/profile-diff-demo/profile-diff.json"); + refresh_differential_flamegraph_viewer_from_manifest_path(&diff_manifest_path) + .expect("refresh profile diff demo viewer"); + } + #[test] fn profile_manifest_serializes_partial_failure_state() { let manifest = sample_manifest(); @@ -3321,6 +5300,69 @@ mod tests { assert_eq!(manifest.semantic_profile.phases[1].percent_total, Some(10)); } + #[test] + fn semantic_profile_ingests_exact_harness_timeline_and_run_counts_from_bench_report_json() { + let args = sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ); + let target = resolve_profile_target(&args).expect("resolve target"); + let mut manifest = + build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile")) + .expect("build capture plan"); + let bench_report = serde_json::json!({ + "spec": { + "name": "sample_fns::fibonacci", + "iterations": 2, + "warmup": 1 + }, + "samples": [ + {"duration_ns": 100}, + {"duration_ns": 300} + ], + "phases": [ + {"name": "prove", "duration_ns": 320}, + {"name": "serialize", "duration_ns": 40} + ], + "timeline": [ + { + "phase": "setup", + "start_offset_ns": 0, + "end_offset_ns": 10, + "iteration": null + }, + { + "phase": "measured-benchmark", + "start_offset_ns": 10, + "end_offset_ns": 30, + "iteration": 0 + } + ] + }); + + populate_semantic_profile_from_benchmark_value(&mut manifest, &bench_report); + + let json = serde_json::to_value(&manifest).expect("serialize manifest"); + assert_eq!( + json["capture_metadata"]["benchmark_iterations"], + serde_json::json!(2) + ); + assert_eq!( + json["capture_metadata"]["benchmark_warmup"], + serde_json::json!(1) + ); + assert_eq!( + json["semantic_profile"]["harness_timeline"][0]["phase"], + "setup" + ); + assert_eq!( + json["semantic_profile"]["harness_timeline"][1]["phase"], + "measured-benchmark" + ); + } + #[test] fn android_native_offsets_are_symbolized_into_rust_frames() { let (symbolized, record, report) = symbolize_android_folded_stacks_with_resolver( @@ -4128,13 +6170,31 @@ mod tests { }, ], spans_path: Some(PathBuf::from("artifacts/semantic/spans.json")), + harness_timeline: vec![ + HarnessTimelineSpanRecord { + phase: "setup".into(), + start_offset_ns: 0, + end_offset_ns: 100, + iteration: None, + }, + HarnessTimelineSpanRecord { + phase: "measured-benchmark".into(), + start_offset_ns: 100, + end_offset_ns: 300, + iteration: Some(0), + }, + ], + timeline_path: Some(PathBuf::from("artifacts/semantic/timeline.json")), }, capture_metadata: CaptureMetadataRecord { device: target .device .as_ref() .map(|device| device.identifier.clone()), + os: Some("android 13".into()), sample_duration_secs: Some(15), + benchmark_iterations: Some(20), + benchmark_warmup: Some(3), warmup_mode: Some(CaptureWarmupMode::Warm), capture_method: Some("simpleperf".into()), warnings: vec!["missing symbols".into()], diff --git a/crates/mobench/tests/profile_cli.rs b/crates/mobench/tests/profile_cli.rs index 71fe72d..fe10b30 100644 --- a/crates/mobench/tests/profile_cli.rs +++ b/crates/mobench/tests/profile_cli.rs @@ -126,3 +126,23 @@ fn profile_run_cli_surface_exposes_or_explicitly_omits_device_selection() { "expected help to expose device selection or explicitly document its absence, got:\n{stdout}" ); } + +#[test] +fn profile_diff_help_exposes_runtime_command_surface() { + let output = run_mobench(["profile", "diff", "--help"]); + assert!(output.status.success(), "expected diff help to succeed"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("--baseline"), + "expected baseline arg, got:\n{stdout}" + ); + assert!( + stdout.contains("--candidate"), + "expected candidate arg, got:\n{stdout}" + ); + assert!( + stdout.contains("--normalize"), + "expected normalize flag, got:\n{stdout}" + ); +} diff --git a/docs/codebase/ARCHITECTURE.md b/docs/codebase/ARCHITECTURE.md index e9f7b0c..887c992 100644 --- a/docs/codebase/ARCHITECTURE.md +++ b/docs/codebase/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -Updated: 2026-03-27 +Updated: 2026-04-01 ## System shape @@ -38,7 +38,8 @@ Responsibilities: Important modules: - `lib.rs`: command parsing and benchmark/CI orchestration - `profile.rs`: target resolution, capture planning, capture execution, manifest/summary writing -- `flamegraph_viewer.rs`: focused/full folded-stack derivation and interactive flamegraph HTML generation +- `flamegraph_viewer.rs`: focused/full folded-stack derivation, SVG retinting, and interactive viewer document generation +- `flamegraph_viewer_template.html`: browser-side flamegraph shell, timeline interactions, legend/fullscreen UX, and metadata layout - `browserstack.rs`: App Automate upload, schedule, polling, and fetch helpers ### SDK/runtime layer @@ -106,11 +107,13 @@ Current status: 4. post-processing writes: - `stacks.folded` - `native-report.txt` + - `frame-locations.json` on Android when file/line metadata is available - `flamegraph.full.svg` - `flamegraph.focused.svg` - `flamegraph.html` - `artifacts/semantic/phases.json` when phase data exists 5. `profile summarize` renders the manifest into Markdown or JSON +6. `profile diff` compares two profile sessions and writes a separate diff bundle under `target/mobench/profile/diff/` ### Device resolution flow diff --git a/docs/codebase/CONVENTIONS.md b/docs/codebase/CONVENTIONS.md index 840131e..5fb436f 100644 --- a/docs/codebase/CONVENTIONS.md +++ b/docs/codebase/CONVENTIONS.md @@ -1,6 +1,6 @@ # Conventions -Updated: 2026-03-27 +Updated: 2026-04-01 ## Naming @@ -17,9 +17,15 @@ Updated: 2026-03-27 - processed profile artifacts keep stable names: - `stacks.folded` - `native-report.txt` + - `frame-locations.json` when Android symbolization resolves file/line metadata - `flamegraph.full.svg` - `flamegraph.focused.svg` - `flamegraph.html` +- differential profile outputs live under `target/mobench/profile/diff/` and use: + - `profile-diff.json` + - `summary.md` + - `diff.full.folded` + - `diff.focused.folded` ## Config conventions diff --git a/docs/codebase/INTEGRATIONS.md b/docs/codebase/INTEGRATIONS.md index b41a2fe..adc1015 100644 --- a/docs/codebase/INTEGRATIONS.md +++ b/docs/codebase/INTEGRATIONS.md @@ -1,6 +1,6 @@ # Integrations -Updated: 2026-03-27 +Updated: 2026-04-01 ## BrowserStack @@ -13,7 +13,7 @@ Purpose: Implementation: - client code lives in `crates/mobench/src/browserstack.rs` - auth uses `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` -- benchmark CI workflows resolve device profiles through `cargo-mobench devices resolve` +- benchmark CI workflows resolve device profiles through `cargo mobench devices resolve` Supported BrowserStack flows: - Android Espresso benchmark runs @@ -23,6 +23,7 @@ Supported BrowserStack flows: Explicitly unsupported: - BrowserStack native profiling for `profile run` +- retrievable BrowserStack flamegraph/native stack artifacts ## Local native profiling diff --git a/docs/codebase/README.md b/docs/codebase/README.md index 9ae9592..7e9158e 100644 --- a/docs/codebase/README.md +++ b/docs/codebase/README.md @@ -3,6 +3,9 @@ These notes replace the older `.planning/codebase/` scratch docs and track the repo as it exists on `dev` after the fixture CI and local profiling work. +For end-user and integrator workflows, start in +[docs/guides/README.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/README.md). + - [ARCHITECTURE.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/ARCHITECTURE.md): how the CLI, SDK, generated runners, profiling pipeline, and CI workflows fit together - [STRUCTURE.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/STRUCTURE.md): where the important crates, templates, docs, and workflows live - [STACK.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/STACK.md): the main languages, tools, runtime dependencies, and native profiler toolchain diff --git a/docs/codebase/STACK.md b/docs/codebase/STACK.md index 4ceca76..487e1f5 100644 --- a/docs/codebase/STACK.md +++ b/docs/codebase/STACK.md @@ -1,6 +1,6 @@ # Technology Stack -Updated: 2026-03-27 +Updated: 2026-04-01 ## Languages @@ -66,8 +66,10 @@ Profile outputs: - `summary.md` - raw capture artifacts (`sample.perf`, `sample.txt`, etc.) - processed stacks (`stacks.folded`, `native-report.txt`) +- optional source links (`frame-locations.json` on Android) - viewer artifacts (`flamegraph.full.svg`, `flamegraph.focused.svg`, `flamegraph.html`) - semantic sidecar data (`artifacts/semantic/phases.json`) +- differential bundles under `target/mobench/profile/diff/` ## Supported execution modes @@ -77,4 +79,4 @@ Profile outputs: - Local iOS native profiling Explicitly not supported: -- BrowserStack native profiling with retrievable flamegraph-capable artifacts +- BrowserStack native profiling and retrievable flamegraph-capable artifacts diff --git a/docs/codebase/STRUCTURE.md b/docs/codebase/STRUCTURE.md index 601d897..ccc276a 100644 --- a/docs/codebase/STRUCTURE.md +++ b/docs/codebase/STRUCTURE.md @@ -1,6 +1,6 @@ # Structure -Updated: 2026-03-27 +Updated: 2026-04-01 ## Workspace layout @@ -18,9 +18,9 @@ mobile-bench-rs/ ├── ios/ # checked-in iOS runner/demo app ├── templates/ # editable source templates mirrored into SDK templates ├── docs/ -│ ├── adr/ +│ ├── guides/ # user-facing setup, integration, and BrowserStack guides │ ├── codebase/ # this reference set -│ ├── plans/ # design and implementation notes +│ ├── specs/ # historical design/reference specs kept for context │ └── schemas/ ├── .github/ │ ├── actions/mobench/ # local composite action for benchmark CI @@ -34,7 +34,8 @@ mobile-bench-rs/ - `crates/mobench/src/lib.rs`: clap surface and benchmark/CI command orchestration - `crates/mobench/src/profile.rs`: local native profiling flow, manifests, summaries, artifact contracts -- `crates/mobench/src/flamegraph_viewer.rs`: focused/full flamegraph generation and interactive HTML viewer +- `crates/mobench/src/flamegraph_viewer.rs`: focused/full flamegraph generation, SVG retinting, and HTML viewer assembly +- `crates/mobench/src/flamegraph_viewer_template.html`: interactive flamegraph shell, timeline mode, and keyboard/fullscreen controls - `crates/mobench/src/browserstack.rs`: BrowserStack App Automate REST client - `crates/mobench/src/config.rs`: config + matrix loading diff --git a/docs/codebase/TESTING.md b/docs/codebase/TESTING.md index ff51cc0..8b6b12a 100644 --- a/docs/codebase/TESTING.md +++ b/docs/codebase/TESTING.md @@ -1,6 +1,6 @@ # Testing -Updated: 2026-03-27 +Updated: 2026-04-01 ## Host-side Rust tests @@ -67,6 +67,8 @@ Expected outputs: - `summary.md` - raw and processed native artifacts - `flamegraph.html` +- `frame-locations.json` on Android when source metadata is available +- `profile-diff.json` / `summary.md` under `target/mobench/profile/diff/` for differential comparisons ## Workflow-level testing diff --git a/docs/guides/README.md b/docs/guides/README.md new file mode 100644 index 0000000..6ab7eda --- /dev/null +++ b/docs/guides/README.md @@ -0,0 +1,13 @@ +# Guides + +Current user-facing guides live here instead of the repository root. + +- [sdk-integration.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/sdk-integration.md): add `mobench-sdk` to an existing Rust crate and wire up benchmark execution +- [build.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/build.md): prerequisite checks, Android/iOS build flows, and build troubleshooting +- [profiling.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/profiling.md): local native profiling workflows, artifact layout, symbol requirements, and flamegraph tradeoffs +- [testing.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/testing.md): host tests, device workflows, profiling smoke checks, and workflow-level validation +- [browserstack-ci.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/browserstack-ci.md): BrowserStack benchmark execution, validation, and CI/reporting integration +- [browserstack-metrics.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/browserstack-metrics.md): what metrics BrowserStack contributes today and how mobench normalizes them +- [fetch-results.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/fetch-results.md): using `--fetch`, `mobench fetch`, and `mobench summary` to retrieve remote benchmark results + +Historical design material that is still useful for context lives under [`docs/specs/`](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/specs/). diff --git a/BROWSERSTACK_CI_INTEGRATION.md b/docs/guides/browserstack-ci.md similarity index 97% rename from BROWSERSTACK_CI_INTEGRATION.md rename to docs/guides/browserstack-ci.md index 4255bd9..30f4b51 100644 --- a/BROWSERSTACK_CI_INTEGRATION.md +++ b/docs/guides/browserstack-ci.md @@ -12,6 +12,10 @@ The BrowserStack client now supports: 5. **Fetching results** - Download device logs and extract benchmark data 6. **Result analysis** - Display summary statistics from reports +This guide covers BrowserStack benchmark execution only. Native profiling remains +local-first in the current release; `cargo mobench profile run --provider browserstack` +is explicitly unsupported. + ## Pre-flight Validation Before running benchmarks on BrowserStack, validate your setup: @@ -500,7 +504,7 @@ cargo mobench summary results.json [--format text|json|csv] ## Next Steps -- See `BROWSERSTACK_METRICS.md` for metrics and performance documentation -- See `FETCH_RESULTS_GUIDE.md` for detailed fetch and summary workflows +- See [browserstack-metrics.md](browserstack-metrics.md) for metrics and performance documentation +- See [fetch-results.md](fetch-results.md) for detailed fetch and summary workflows - Check `crates/mobench/src/browserstack.rs` for full API documentation - Run `cargo doc --open -p mobench` for detailed API docs diff --git a/BROWSERSTACK_METRICS.md b/docs/guides/browserstack-metrics.md similarity index 83% rename from BROWSERSTACK_METRICS.md rename to docs/guides/browserstack-metrics.md index 023eb67..e97d86d 100644 --- a/BROWSERSTACK_METRICS.md +++ b/docs/guides/browserstack-metrics.md @@ -2,6 +2,11 @@ This document describes what device metrics BrowserStack provides and what we currently capture. +This guide is about BrowserStack benchmark metrics, not native profiling. In +the current release, BrowserStack remains a benchmark execution target for +timing plus resource metrics. Native flamegraph/native stack capture is still +local-only. + ## Current Implementation ### ✅ What We Capture Now @@ -31,15 +36,17 @@ We recursively download ALL URLs from session JSON, which typically includes: - Timing samples (duration_ns for each iteration) - Statistical metrics (mean, median, min, max, stddev) -**Performance Metrics (v0.1.5+):** +**Performance Metrics:** - Extracted from device logs (JSON output with `"type": "performance"` or `memory`/`cpu` fields) +- Enriched from BrowserStack App Profiling v2 when that API returns additional session data - Memory usage (used_mb, max_mb, available_mb, total_mb) - Aggregate statistics: peak, average, min - CPU usage (usage_percent) - Aggregate statistics: peak, average, min -- Automatically included in RunSummary when using `--fetch` flag +- Normalized into run summaries and CI summaries when using `--fetch` +- Surfaced as summary resource fields such as `cpu_total_ms` and `peak_memory_kb` -### ⚠️ What We DON'T Capture (But BrowserStack Provides) +### ⚠️ What We do not currently capture Based on [BrowserStack App Automate API documentation](https://www.browserstack.com/docs/app-automate/api-reference): @@ -50,9 +57,10 @@ Based on [BrowserStack App Automate API documentation](https://www.browserstack. - `reason` - Failure reason if test failed - `build_tag` - Custom build tags -**Performance Metrics:** +**Performance metrics:** -BrowserStack does NOT provide built-in CPU/Memory/Battery metrics in standard API responses. However, **mobench v0.1.5+ now supports extracting these metrics** if your app logs them: +BrowserStack does not expose CPU/memory/battery metrics in the normal session +JSON payloads. mobench therefore combines two sources when you use `--fetch`: 1. **Collect metrics in your app** using Android/iOS APIs: - Android: `ActivityManager.MemoryInfo`, `Debug.MemoryInfo` @@ -60,9 +68,9 @@ BrowserStack does NOT provide built-in CPU/Memory/Battery metrics in standard AP 2. **Log to device logs** in JSON format (see example below) -3. **mobench automatically extracts** them alongside benchmark results when using `--fetch` +3. **mobench automatically extracts** them alongside benchmark results and merges in BrowserStack App Profiling v2 data when available -## BrowserStack Limitations +## BrowserStack limitations According to their documentation, BrowserStack App Automate **does not** provide: @@ -71,7 +79,9 @@ According to their documentation, BrowserStack App Automate **does not** provide - ❌ Built-in battery/power profiling - ❌ Built-in frame rate/rendering metrics -These metrics must be collected by **your application code** and logged. +These metrics must either be collected by **your application code** and logged, +or enriched from BrowserStack App Profiling v2 when that endpoint has data for a +session. Neither path provides native stacks or flamegraphs. ## How to Add Performance Metrics @@ -143,9 +153,7 @@ Ensure your app logs performance metrics as JSON to stdout/logcat: ### Step 3: Extract Metrics with mobench -**✅ Implemented in v0.1.5+** - -mobench now automatically extracts both benchmark results and performance metrics: +mobench automatically extracts both benchmark results and performance metrics: ```rust // Extracts benchmark results @@ -260,9 +268,9 @@ For more comprehensive profiling, consider: For CI/benchmarking on BrowserStack: -1. **Implement custom metric collection** in your app -2. **Log metrics as JSON** to stdout/logcat -3. **Use mobench `--fetch`** to extract performance metrics from logs +1. **Implement custom metric collection** in your app for the metrics you care about most +2. **Log metrics as JSON** to stdout/logcat so `mobench` can extract them deterministically +3. **Use `mobench --fetch`** so device logs and App Profiling v2 enrichment both contribute to the final summary 4. **Focus on metrics that matter** for your use case: - Memory: Peak usage, allocations during benchmark - CPU: Usage spikes during computation @@ -282,3 +290,5 @@ grep '"type":"performance"' target/browserstack/*/session-*/device-logs.txt | jq - BrowserStack API Docs: https://www.browserstack.com/docs/app-automate/api-reference - Android MemoryInfo: https://developer.android.com/reference/android/app/ActivityManager.MemoryInfo - iOS Memory Profiling: https://developer.apple.com/documentation/foundation/task_management +- [browserstack-ci.md](browserstack-ci.md): benchmark execution and CI usage +- [fetch-results.md](fetch-results.md): fetch/output flow and artifact layout diff --git a/BUILD.md b/docs/guides/build.md similarity index 95% rename from BUILD.md rename to docs/guides/build.md index ac1c955..2eddd19 100644 --- a/BUILD.md +++ b/docs/guides/build.md @@ -4,13 +4,13 @@ Complete build instructions for Android and iOS targets. Build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. -> **For SDK Integrators**: Use the CLI commands: +> **For SDK integrators**: use the CLI commands: > - `cargo mobench check --target android` (validate prerequisites first) > - `cargo mobench check --target ios` (validate prerequisites first) > - `cargo mobench build --target android` > - `cargo mobench build --target ios` > -> See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide. +> See [sdk-integration.md](sdk-integration.md) for the integration guide. ## Table of Contents - [Prerequisites Check](#prerequisites-check) @@ -545,10 +545,13 @@ The expected local toolchain is: BrowserStack remains an execution target for benchmark runs, but native profiling through BrowserStack remains explicitly unsupported. -## Additional Documentation +For the full profiling workflow, artifact contract, symbol requirements, and +overhead guidance, see [profiling.md](profiling.md). -- **`TESTING.md`**: Comprehensive testing guide with troubleshooting -- **`README.md`**: Project overview and quick start -- **`CLAUDE.md`**: Developer guide for this codebase -- **`docs/codebase/ARCHITECTURE.md`**: Current architecture reference -- **`RELEASE_NOTES.md`**: Published release history and support status +## Additional documentation + +- [testing.md](testing.md): comprehensive testing guide with troubleshooting +- [../../README.md](../../README.md): project overview and quick start +- [../../CLAUDE.md](../../CLAUDE.md): developer guide for this codebase +- [../codebase/ARCHITECTURE.md](../codebase/ARCHITECTURE.md): current architecture reference +- [../../RELEASE_NOTES.md](../../RELEASE_NOTES.md): published release history and support status diff --git a/FETCH_RESULTS_GUIDE.md b/docs/guides/fetch-results.md similarity index 98% rename from FETCH_RESULTS_GUIDE.md rename to docs/guides/fetch-results.md index c006b72..5115ebe 100644 --- a/FETCH_RESULTS_GUIDE.md +++ b/docs/guides/fetch-results.md @@ -350,8 +350,8 @@ For external crates configured via `mobench.toml`, use `cargo mobench list` and ## See Also -- `BROWSERSTACK_CI_INTEGRATION.md` - Programmatic API for custom workflows -- `BROWSERSTACK_METRICS.md` - Metrics and performance documentation +- [browserstack-ci.md](browserstack-ci.md) - programmatic API for custom workflows +- [browserstack-metrics.md](browserstack-metrics.md) - metrics and performance documentation - `cargo mobench run --help` - Full CLI options - `cargo mobench summary --help` - Summary command options - `cargo mobench verify --help` - Verification command options diff --git a/docs/guides/profiling.md b/docs/guides/profiling.md new file mode 100644 index 0000000..0514ddb --- /dev/null +++ b/docs/guides/profiling.md @@ -0,0 +1,330 @@ +# Profiling Guide + +This guide covers `cargo mobench profile ...`, the local native profiling +workflow, symbol requirements, and how profiling fits alongside normal mobench +benchmark runs. + +## Scope + +Profiling is local-first in the current release. + +Supported today: + +| Provider | Backend | What you get | +|----------|---------|--------------| +| `local` | `android-native` | `simpleperf` capture, symbolized folded stacks, `native-report.txt`, optional `frame-locations.json`, full/focused flamegraph SVGs, `flamegraph.html`, and optional semantic phase summaries | +| `local` | `ios-instruments` | simulator-host `sample` capture, collapsed folded stacks, `native-report.txt`, full/focused flamegraph SVGs, `flamegraph.html`, and optional semantic phase summaries | + +Not supported today: + +| Provider | Backend | Why | +|----------|---------|-----| +| `browserstack` | `android-native` / `ios-instruments` | BrowserStack benchmark runs can return timing and resource data, but not retrievable native-stack artifacts in the mobench session contract | +| `local` | `rust-tracing` | the backend is planned, but structured trace-event output is not implemented yet | + +## Quick start + +### Android + +```bash +cargo mobench profile run \ + --target android \ + --provider local \ + --backend android-native \ + --function sample_fns::fibonacci +``` + +### iOS + +```bash +cargo mobench profile run \ + --target ios \ + --provider local \ + --backend ios-instruments \ + --function sample_fns::fibonacci +``` + +Render the latest summary: + +```bash +cargo mobench profile summarize \ + --profile target/mobench/profile/profile.json +``` + +Generate a differential flamegraph from two mobench sessions: + +```bash +cargo mobench profile diff \ + --baseline target/mobench/profile/android-sample_fns--fibonacci/profile.json \ + --candidate target/mobench/profile/profile.json \ + --normalize +``` + +Current flamegraph viewer: + +![Mobench flamegraph viewer](../../assets/flamegraph-viewer.png) + +## How profiling integrates with normal mobench runs + +Profiling is a separate command surface from `cargo mobench run`. + +- `cargo mobench run` is still the right tool for timing-focused benchmark runs + locally or on BrowserStack +- `cargo mobench profile run` is for local native capture and flamegraph output +- a benchmark can optionally emit semantic phase data with + `mobench_sdk::timing::profile_phase(...)` + +When `profile_phase(...)` is present, mobench stores benchmark phases in +`artifacts/semantic/phases.json` and renders them separately from the native +flamegraph. That separation matters: phase timing is benchmark metadata, not a +sampled native stack. + +## Prerequisites + +### Android + +Required: + +- `adb` +- Android NDK +- `simpleperf` from the NDK toolchain +- `llvm-addr2line` from the NDK toolchain +- a locally reachable Android device or emulator + +Notes: + +- the generated Android benchmark app is marked `profileable`, which allows + local native profilers to attach +- mobench can discover `llvm-addr2line` in the NDK automatically; if needed, + override it with `MOBENCH_ANDROID_LLVM_ADDR2LINE` or `LLVM_ADDR2LINE` + +### iOS + +Required: + +- macOS +- Xcode command-line tools +- `xcrun` +- `simctl` +- an available iOS simulator runtime +- the macOS `sample` command + +Notes: + +- the current iOS profiling backend is simulator-host only +- for real-device deep analysis, continue to use Instruments/Xcode directly + +## Symbol requirements + +### Android + +Android flamegraphs are only as good as the symbols available to the +post-processor. + +mobench expects: + +- the raw `sample.perf` +- the matching unstripped Cargo-produced `.so` files +- NDK `llvm-addr2line` + +The important detail is that symbolization is done against the unstripped native +libraries produced during the build, not only the packaged copies under +`jniLibs/`. That means release-like app packaging is still compatible with +profiling as long as the unstripped build outputs remain available for the same +build. + +If symbols are missing, mobench still writes the session and marks +symbolization as partial or failed in `profile.json`. + +When `llvm-addr2line` returns file/line metadata, mobench also writes +`artifacts/processed/frame-locations.json` and the viewer exposes source links +for selected frames and hot-path entries. + +### iOS + +The current backend works from the textual call graph emitted by `sample` +against a simulator-host process. That is sufficient for the local flamegraph +path, but it is not a replacement for Instruments-based symbol workflows on +real devices. + +## Differential flamegraphs + +`cargo mobench profile diff` compares two normalized mobench profile sessions. + +Input contract: + +- baseline session: `profile.json` plus its processed folded stacks +- candidate session: `profile.json` plus its processed folded stacks +- optional source locations from `frame-locations.json` + +Output contract: + +- `profile-diff.json` +- `summary.md` +- `artifacts/processed/diff.full.folded` +- `artifacts/processed/diff.focused.folded` +- `artifacts/processed/flamegraph.full.svg` +- `artifacts/processed/flamegraph.focused.svg` +- `artifacts/processed/flamegraph.html` + +Color semantics follow the standard inferno differential model: + +- red = hotter in the candidate session +- blue = hotter in the baseline session +- frame widths follow candidate sample counts + +If you need the reverse width perspective for disappearing stacks, swap the +baseline and candidate inputs. + +The viewer intentionally keeps aggregate hotspot analysis and exact harness +timing separate: + +- `Benchmark Only` and `Full Process` remain aggregate flamegraphs over the + whole capture +- `Timeline` shows exact harness intervals and recorded chronological samples + when they exist +- if the capture only has harness timing, Timeline says so explicitly instead of + pretending to crop the aggregate flamegraph by wall-clock ratio + +## Artifact layout + +Each session writes to `target/mobench/profile//` and refreshes the +top-level latest copies under `target/mobench/profile/`. + +Common outputs: + +- `profile.json` +- `summary.md` +- `artifacts/raw/...` +- `artifacts/processed/stacks.folded` +- `artifacts/processed/native-report.txt` +- `artifacts/processed/frame-locations.json` on Android when file/line metadata is available +- `artifacts/processed/flamegraph.full.svg` +- `artifacts/processed/flamegraph.focused.svg` +- `artifacts/processed/flamegraph.html` + +Optional semantic output: + +- `artifacts/semantic/phases.json` + +Platform-specific raw artifacts: + +- Android: `artifacts/raw/sample.perf` +- iOS: `artifacts/raw/sample.txt` + +Differential outputs live under `target/mobench/profile/diff//` and +refresh top-level `target/mobench/profile/diff/profile-diff.json` / +`summary.md`. + +## Warmup and capture behavior + +Local Android and iOS native backends default to `--warmup-mode warm`. + +Why warm mode exists: + +- very short mobile benchmarks can disappear into profiler sampling noise +- first-run bridge and startup work can dominate otherwise useful captures + +Warm mode improves the signal, but it does not remove all initialization costs. +If you need a true first-run profile, use: + +```bash +cargo mobench profile run ... --warmup-mode cold +``` + +## Overhead and tradeoffs + +Choose the profiling surface based on the question you are asking. + +### Native profiling + +Best for: + +- identifying hot functions in Rust, FFI, bridge, allocator, or platform code +- inspecting where sampled wall-clock time actually accumulates +- comparing full-process and benchmark-focused stack shapes + +Tradeoffs: + +- sampling adds overhead +- very short functions may need repeated execution to appear in stacks +- Android and iOS require different host tooling even though the processed + output is normalized + +### Semantic phase timing + +Best for: + +- seeing benchmark-domain phases such as `prove`, `serialize`, or `verify` +- understanding logical work boundaries with low instrumentation cost + +Tradeoffs: + +- phase timing does not include arbitrary native stack context +- it complements flamegraphs; it does not replace them + +## Local vs. CI + +Use local profiling for native-stack work. + +- developer machines are the primary target +- controlled self-hosted or specialized CI runners can validate the local tool + path +- BrowserStack remains a benchmark execution surface, not a native-profile + artifact source + +If you need CI automation today: + +- use BrowserStack for timing and resource metrics +- archive local profile sessions as CI artifacts when you need flamegraph + regression triage +- compare archived sessions with `cargo mobench profile diff` + +Recommended CI artifact set for profile-aware regression work: + +- `profile.json` +- `artifacts/processed/stacks.folded` +- `artifacts/processed/benchmark.focused.folded` +- `artifacts/processed/frame-locations.json` when present + +That deliberately defines a separate baseline model from `ci run` summary +comparison: benchmark timing regressions continue to use summary baselines, +while flamegraph regressions compare profile sessions directly. + +## iOS boundary and recommendation + +The current iOS backend stays on simulator-host `sample` because it is the +lowest-friction local capture path and produces folded stacks directly from the +launched benchmark process. + +There is a viable higher-fidelity future path: + +- Apple’s `xctrace export` can export `.trace` data to XML for post-processing +- inferno already documents an `inferno-collapse-xctrace` path from exported + Time Profiler data into folded stacks + +Recommendation: + +- keep `sample` as the default mobench iOS profiling backend for local smoke and + regression workflows +- treat Instruments import/export as a future opt-in path, not a replacement + for the current default +- do not promise uniform simulator/device source-link fidelity until an + explicit xctrace import surface exists in mobench + +## Recommended workflow + +1. Run a normal mobench benchmark to confirm the regression or hotspot. +2. Re-run the same benchmark with `cargo mobench profile run`. +3. Open `artifacts/processed/flamegraph.html`. +4. Compare the full-process and benchmark-focused views. +5. If Android source links are present, drill into selected frames from the + viewer sidebar. +6. If phase data exists, correlate the flamegraph with + `artifacts/semantic/phases.json`. + +## Related docs + +- [build.md](build.md) +- [testing.md](testing.md) +- [browserstack-metrics.md](browserstack-metrics.md) diff --git a/BENCH_SDK_INTEGRATION.md b/docs/guides/sdk-integration.md similarity index 99% rename from BENCH_SDK_INTEGRATION.md rename to docs/guides/sdk-integration.md index 0bfdb7a..956ee02 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/docs/guides/sdk-integration.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.22" +mobench-sdk = "0.1.27" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.22" +mobench-sdk = "0.1.27" ``` ## 3) Annotate benchmark functions diff --git a/TESTING.md b/docs/guides/testing.md similarity index 92% rename from TESTING.md rename to docs/guides/testing.md index bc1d1f0..fc63526 100644 --- a/TESTING.md +++ b/docs/guides/testing.md @@ -2,11 +2,11 @@ This document provides comprehensive testing instructions for mobile-bench-rs. -> **For SDK Integrators**: If you're importing `mobench-sdk` into your project, use: +> **For SDK integrators**: if you're importing `mobench-sdk` into your project, use: > - `cargo mobench build --target ` for builds > - Scripts shown below are legacy tooling for this repository -> - See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide -> **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see **[BUILD.md](BUILD.md)**. This document focuses on testing scenarios and troubleshooting. +> - See [sdk-integration.md](sdk-integration.md) for the integration guide +> **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see [build.md](build.md). This document focuses on testing scenarios and troubleshooting. Build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. @@ -282,8 +282,8 @@ xcodebuild -project target/mobench/ios/BenchRunner/BenchRunner.xcodeproj \ -destination 'platform=iOS Simulator,name=iPhone 15' \ -derivedDataPath target/mobench/ios/build -# Launch with arguments -xcrun simctl launch booted dev.world.bench.BenchRunner \ +# Launch with arguments (replace the bundle id if your project uses a custom one) +xcrun simctl launch booted dev.world.bench \ --bench-function=sample_fns::checksum \ --bench-iterations=30 \ --bench-warmup=5 @@ -418,23 +418,9 @@ xcodegen generate **Problem**: "Cannot find type 'RustBuffer' in scope" or FFI type errors ```bash -# Solution: Ensure the bridging header is configured -# Check that BenchRunner-Bridging-Header.h exists at: -# target/mobench/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h - -# If missing, create it with: -cat > target/mobench/ios/BenchRunner/BenchRunner/BenchRunner-Bridging-Header.h << 'EOF' -// -// BenchRunner-Bridging-Header.h -// BenchRunner -// -// Bridge to import C FFI from Rust (UniFFI-generated) -// - -#import "sample_fnsFFI.h" -EOF - -# Then regenerate the Xcode project: +# Solution: regenerate the generated project instead of hand-authoring the header +rm -rf target/mobench/ios +cargo mobench build --target ios cd target/mobench/ios/BenchRunner xcodegen generate ``` @@ -526,9 +512,9 @@ cargo test --all ## Advanced Testing -### BrowserStack Integration Testing +### BrowserStack integration testing -See the main [README.md](README.md) for BrowserStack testing instructions. +See [browserstack-ci.md](browserstack-ci.md) for the BrowserStack benchmark flow. #### Device Validation @@ -714,25 +700,21 @@ fn test_my_new_function() { } ``` -## Continuous Integration +## Continuous integration -The project includes a GitHub Actions workflow (`.github/workflows/mobile-bench.yml`) that: -- Runs host tests on every push -- Builds Android APK (optional) -- Builds iOS xcframework (optional) -- Uploads artifacts +Current workflow families: +- `.github/workflows/mobile-bench.yml`: fixture benchmark workflow for the checked-in example crate +- `.github/workflows/mobile-bench-plot-fixtures.yml`: plot rendering verification +- `.github/workflows/mobile-bench-selftest.yml`: sample benchmark self-test +- `.github/workflows/mobile-bench-profile-selftest.yml`: local profiling self-test -To trigger manually: -1. Go to GitHub Actions tab -2. Select "mobile-bench-rs CI" -3. Click "Run workflow" -4. Select platform(s) to build +Prefer the workflow docs in [../codebase/TESTING.md](../codebase/TESTING.md) when you need an internal map of which workflow owns which validation surface. -## Additional Resources +## Additional resources - [UniFFI Documentation](https://mozilla.github.io/uniffi-rs/) - [Android NDK Documentation](https://developer.android.com/ndk) - [Rust Cross-Compilation Guide](https://rust-lang.github.io/rustup/cross-compilation.html) -- [docs/codebase/ARCHITECTURE.md](docs/codebase/ARCHITECTURE.md) - Current architecture reference -- [RELEASE_NOTES.md](RELEASE_NOTES.md) - Published release history and support status -- [CLAUDE.md](CLAUDE.md) - Developer guide for this codebase +- [../codebase/ARCHITECTURE.md](../codebase/ARCHITECTURE.md) - current architecture reference +- [../../RELEASE_NOTES.md](../../RELEASE_NOTES.md) - published release history and support status +- [../../CLAUDE.md](../../CLAUDE.md) - developer guide for this codebase diff --git a/mobench-dx-spec.md b/docs/specs/dx-improvement-spec.md similarity index 92% rename from mobench-dx-spec.md rename to docs/specs/dx-improvement-spec.md index 2e04084..80fef2a 100644 --- a/mobench-dx-spec.md +++ b/docs/specs/dx-improvement-spec.md @@ -1,5 +1,12 @@ # mobench DX Improvement Spec +This is a historical design document. It is kept for context, not as the +current source of truth. For shipped behavior, use: + +- [../guides/README.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/README.md) +- [../codebase/README.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/README.md) +- [../../RELEASE_NOTES.md](/Users/dcbuilder/Code/world/mobile-bench-rs/RELEASE_NOTES.md) + ## Goals Primary goals From 2b8c660f7df3a91d324fa3d50f93f374c4f787ba Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Sun, 12 Apr 2026 15:33:30 +0200 Subject: [PATCH 162/196] Add measured resource metrics to mobench outputs (#23) * Add measured resource metrics to mobench outputs * Fix CPU summary rendering and memory baseline * Render sub-second CPU in milliseconds * Update low-spec benchmark devices --- Cargo.lock | 1 + README.md | 9 + crates/mobench-sdk/Cargo.toml | 1 + crates/mobench-sdk/src/ffi.rs | 30 +- crates/mobench-sdk/src/lib.rs | 3 +- crates/mobench-sdk/src/timing.rs | 692 +++++++++++++++++- crates/mobench-sdk/src/uniffi_types.rs | 18 +- .../src/main/java/MainActivity.kt.template | 56 +- .../BenchRunner/BenchRunnerFFI.swift.template | 38 +- crates/mobench/README.md | 9 + crates/mobench/src/lib.rs | 650 +++++++++++++--- crates/mobench/src/summarize.rs | 36 +- crates/sample-fns/src/lib.rs | 4 + docs/MIGRATION_GUIDE.md | 9 + docs/guides/browserstack-ci.md | 5 + docs/guides/browserstack-metrics.md | 7 + docs/schemas/summary-v1.schema.json | 1 + examples/ffi-benchmark/src/lib.rs | 4 + 18 files changed, 1405 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8b6ae0..20ff955 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1085,6 +1085,7 @@ dependencies = [ "anyhow", "include_dir", "inventory", + "libc", "mobench-macros", "serde", "serde_json", diff --git a/README.md b/README.md index d0e95b1..08789b1 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,15 @@ reuses the same resolution model as `devices resolve`: - `--profile high-spec` - `--profile high-spec --device-matrix device-matrix.yaml` +`summary.md` uses unit-neutral headers (`Mean`, `Median`, `P95`, `Min`, `Max`) and renders the default `CPU` column from measured-iteration `cpu_median_ms` in milliseconds below one second and total seconds otherwise (for example `482ms`, `1.482s`). + +`results.csv` includes benchmark-scoped resource columns directly: +- `cpu_total_ms` +- `cpu_median_ms` +- `peak_memory_kb` + +Missing resource metrics are emitted as blank CSV fields. + ## Configuration mobench supports a `mobench.toml` configuration file for project settings: diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 194cb4a..4f57d22 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -40,6 +40,7 @@ inventory = { workspace = true, optional = true } # Serialization (always needed for BenchSpec/BenchReport) serde.workspace = true serde_json.workspace = true +libc = "0.2" # Error handling thiserror.workspace = true diff --git a/crates/mobench-sdk/src/ffi.rs b/crates/mobench-sdk/src/ffi.rs index 3792893..2940eb6 100644 --- a/crates/mobench-sdk/src/ffi.rs +++ b/crates/mobench-sdk/src/ffi.rs @@ -75,12 +75,18 @@ impl From for crate::BenchSpec { pub struct BenchSampleFfi { /// Duration of the iteration in nanoseconds. pub duration_ns: u64, + /// CPU time consumed by the measured iteration in milliseconds. + pub cpu_time_ms: Option, + /// Peak memory growth during the measured iteration in kilobytes. + pub peak_memory_kb: Option, } impl From for BenchSampleFfi { fn from(sample: crate::BenchSample) -> Self { Self { duration_ns: sample.duration_ns, + cpu_time_ms: sample.cpu_time_ms, + peak_memory_kb: sample.peak_memory_kb, } } } @@ -89,6 +95,8 @@ impl From for crate::BenchSample { fn from(sample: BenchSampleFfi) -> Self { Self { duration_ns: sample.duration_ns, + cpu_time_ms: sample.cpu_time_ms, + peak_memory_kb: sample.peak_memory_kb, } } } @@ -262,9 +270,15 @@ mod tests { #[test] fn test_bench_sample_ffi_conversion() { - let sdk_sample = crate::BenchSample { duration_ns: 12345 }; + let sdk_sample = crate::BenchSample { + duration_ns: 12345, + cpu_time_ms: Some(12), + peak_memory_kb: Some(48), + }; let ffi: BenchSampleFfi = sdk_sample.into(); assert_eq!(ffi.duration_ns, 12345); + assert_eq!(ffi.cpu_time_ms, Some(12)); + assert_eq!(ffi.peak_memory_kb, Some(48)); } #[test] @@ -276,8 +290,16 @@ mod tests { warmup: 1, }, samples: vec![ - crate::BenchSample { duration_ns: 100 }, - crate::BenchSample { duration_ns: 200 }, + crate::BenchSample { + duration_ns: 100, + cpu_time_ms: Some(3), + peak_memory_kb: Some(8), + }, + crate::BenchSample { + duration_ns: 200, + cpu_time_ms: Some(5), + peak_memory_kb: Some(13), + }, ], phases: vec![crate::SemanticPhase { name: "prove".to_string(), @@ -295,6 +317,8 @@ mod tests { assert_eq!(ffi.spec.name, "test"); assert_eq!(ffi.samples.len(), 2); assert_eq!(ffi.samples[0].duration_ns, 100); + assert_eq!(ffi.samples[0].cpu_time_ms, Some(3)); + assert_eq!(ffi.samples[0].peak_memory_kb, Some(8)); assert_eq!(ffi.phases.len(), 1); assert_eq!(ffi.phases[0].name, "prove"); assert_eq!(ffi.timeline.len(), 1); diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 14dbf43..0191a64 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -274,7 +274,8 @@ //! //! - Android NDK (set `ANDROID_NDK_HOME` environment variable) //! - `cargo-ndk` (`cargo install cargo-ndk`) -//! - Rust targets: `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` +//! - Rust targets: `rustup target add aarch64-linux-android` +//! - Optional extra ABI targets only when configured explicitly //! //! ### iOS //! diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index b82e923..aa02b79 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -64,6 +64,12 @@ use serde::{Deserialize, Serialize}; use std::cell::RefCell; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, +}; +use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; use thiserror::Error; @@ -173,25 +179,43 @@ impl BenchSpec { /// ``` /// use mobench_sdk::timing::BenchSample; /// -/// let sample = BenchSample { duration_ns: 1_500_000 }; +/// let sample = BenchSample { +/// duration_ns: 1_500_000, +/// ..Default::default() +/// }; /// /// // Convert to milliseconds /// let ms = sample.duration_ns as f64 / 1_000_000.0; /// assert_eq!(ms, 1.5); /// ``` -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct BenchSample { /// Duration of the iteration in nanoseconds. /// /// Measured using [`std::time::Instant`] for monotonic, high-resolution timing. pub duration_ns: u64, + + /// CPU time consumed by the measured iteration in milliseconds. + /// + /// This is captured around the measured benchmark closure only and excludes + /// warmup, setup, teardown, and report generation overhead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu_time_ms: Option, + + /// Peak memory growth during the measured iteration in kilobytes. + /// + /// Values are baseline-adjusted immediately before the measured closure + /// enters so harness footprint is not counted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub peak_memory_kb: Option, } impl BenchSample { - /// Creates a sample from a [`Duration`]. - fn from_duration(duration: Duration) -> Self { + fn from_measurement(duration: Duration, resources: IterationResourceUsage) -> Self { Self { duration_ns: duration.as_nanos() as u64, + cpu_time_ms: resources.cpu_time_ms, + peak_memory_kb: resources.peak_memory_kb, } } } @@ -328,6 +352,56 @@ impl BenchReport { .unwrap_or(0) } + /// Returns the total measured CPU time in milliseconds across all iterations. + #[must_use] + pub fn cpu_total_ms(&self) -> Option { + let values = self + .samples + .iter() + .filter_map(|sample| sample.cpu_time_ms) + .collect::>(); + if values.is_empty() { + return None; + } + + let total = values + .iter() + .fold(0_u128, |sum, value| sum.saturating_add(u128::from(*value))); + Some(total.min(u128::from(u64::MAX)) as u64) + } + + /// Returns the median measured CPU time in milliseconds across all iterations. + #[must_use] + pub fn cpu_median_ms(&self) -> Option { + let mut values = self + .samples + .iter() + .filter_map(|sample| sample.cpu_time_ms) + .collect::>(); + if values.is_empty() { + return None; + } + + values.sort_unstable(); + let len = values.len(); + Some(if len % 2 == 0 { + let lower = u128::from(values[(len / 2) - 1]); + let upper = u128::from(values[len / 2]); + ((lower + upper) / 2) as u64 + } else { + values[len / 2] + }) + } + + /// Returns the maximum baseline-adjusted peak memory growth in kilobytes. + #[must_use] + pub fn peak_memory_kb(&self) -> Option { + self.samples + .iter() + .filter_map(|sample| sample.peak_memory_kb) + .max() + } + /// Returns a statistical summary of the benchmark results. #[must_use] pub fn summary(&self) -> BenchSummary { @@ -346,6 +420,12 @@ impl BenchReport { } } +#[derive(Clone, Debug, Default)] +struct IterationResourceUsage { + cpu_time_ms: Option, + peak_memory_kb: Option, +} + fn instant_offset_ns(origin: Instant, instant: Instant) -> u64 { instant .duration_since(origin) @@ -491,6 +571,239 @@ fn finish_semantic_phase_collection() -> Vec { SEMANTIC_PHASE_COLLECTOR.with(|collector| collector.borrow_mut().finish()) } +trait ResourceMonitor { + type Token; + + fn start(&mut self) -> Self::Token; + + fn finish(&mut self, token: Self::Token) -> IterationResourceUsage; +} + +#[derive(Default)] +struct DefaultResourceMonitor; + +struct DefaultResourceToken { + cpu_time_start_ns: Option, + memory_sampler: Option, +} + +impl ResourceMonitor for DefaultResourceMonitor { + type Token = DefaultResourceToken; + + fn start(&mut self) -> Self::Token { + Self::Token { + cpu_time_start_ns: current_thread_cpu_time_ns(), + memory_sampler: MemoryPeakSampler::start(), + } + } + + fn finish(&mut self, token: Self::Token) -> IterationResourceUsage { + let cpu_time_ms = match (token.cpu_time_start_ns, current_thread_cpu_time_ns()) { + (Some(start_ns), Some(end_ns)) if end_ns >= start_ns => { + Some(round_ns_to_ms(end_ns - start_ns)) + } + _ => None, + }; + + IterationResourceUsage { + cpu_time_ms, + peak_memory_kb: token + .memory_sampler + .and_then(MemoryPeakSampler::stop) + .filter(|value| *value > 0), + } + } +} + +fn round_ns_to_ms(ns: u64) -> u64 { + ((u128::from(ns) + 500_000) / 1_000_000) as u64 +} + +#[cfg(unix)] +fn current_thread_cpu_time_ns() -> Option { + let mut ts = std::mem::MaybeUninit::::uninit(); + let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, ts.as_mut_ptr()) }; + if rc != 0 { + return None; + } + + let ts = unsafe { ts.assume_init() }; + let secs = u64::try_from(ts.tv_sec).ok()?; + let nanos = u64::try_from(ts.tv_nsec).ok()?; + Some(secs.saturating_mul(1_000_000_000).saturating_add(nanos)) +} + +#[cfg(not(unix))] +fn current_thread_cpu_time_ns() -> Option { + None +} + +const MEMORY_SAMPLER_INTERVAL: Duration = Duration::from_millis(1); +type MemoryReader = Arc Option + Send + Sync + 'static>; + +struct MemoryPeakSampler { + baseline_kb: u64, + stop_flag: Arc, + peak_kb: Arc, + handle: JoinHandle<()>, +} + +impl MemoryPeakSampler { + fn start() -> Option { + Self::start_with_reader(Arc::new(|| current_process_memory_kb())) + } + + fn start_with_reader(reader: MemoryReader) -> Option { + let stop_flag = Arc::new(AtomicBool::new(false)); + let peak_kb = Arc::new(AtomicU64::new(0)); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let (baseline_tx, baseline_rx) = mpsc::sync_channel(1); + let sampler_stop = Arc::clone(&stop_flag); + let sampler_peak = Arc::clone(&peak_kb); + let sampler_reader = Arc::clone(&reader); + + let handle = thread::Builder::new() + .name("mobench-memory-sampler".to_string()) + .spawn(move || { + // Touch the sampler thread's own stack and runtime state before the + // benchmark baseline is captured so its overhead is not reported as + // measured benchmark memory. + let _ = sampler_reader(); + let _ = ready_tx.send(()); + + let Some(baseline_kb) = baseline_rx.recv().ok().flatten() else { + return; + }; + sampler_peak.store(baseline_kb, Ordering::Release); + + while !sampler_stop.load(Ordering::Acquire) { + if let Some(current_kb) = sampler_reader() { + update_atomic_max(&sampler_peak, current_kb); + } + thread::sleep(MEMORY_SAMPLER_INTERVAL); + } + + if let Some(current_kb) = sampler_reader() { + update_atomic_max(&sampler_peak, current_kb); + } + }) + .ok()?; + + if ready_rx.recv().is_err() { + stop_flag.store(true, Ordering::Release); + let _ = handle.join(); + return None; + } + + let baseline_kb = match reader() { + Some(value) => value, + None => { + let _ = baseline_tx.send(None); + stop_flag.store(true, Ordering::Release); + let _ = handle.join(); + return None; + } + }; + if baseline_tx.send(Some(baseline_kb)).is_err() { + stop_flag.store(true, Ordering::Release); + let _ = handle.join(); + return None; + } + + Some(Self { + baseline_kb, + stop_flag, + peak_kb, + handle, + }) + } + + fn stop(self) -> Option { + self.stop_flag.store(true, Ordering::Release); + let _ = self.handle.join(); + let peak_kb = self.peak_kb.load(Ordering::Acquire); + Some(peak_kb.saturating_sub(self.baseline_kb)) + } +} + +fn update_atomic_max(target: &AtomicU64, value: u64) { + let mut current = target.load(Ordering::Relaxed); + while value > current { + match target.compare_exchange_weak(current, value, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => break, + Err(observed) => current = observed, + } + } +} + +#[cfg(any(target_os = "android", target_os = "linux"))] +fn current_process_memory_kb() -> Option { + let statm = std::fs::read_to_string("/proc/self/statm").ok()?; + let resident_pages = statm + .split_whitespace() + .nth(1) + .and_then(|value| value.parse::().ok())?; + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + if page_size <= 0 { + return None; + } + let page_size = u64::try_from(page_size).ok()?; + Some(resident_pages.saturating_mul(page_size) / 1024) +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +fn current_process_memory_kb() -> Option { + let mut info = std::mem::MaybeUninit::::uninit(); + let mut count = libc::MACH_TASK_BASIC_INFO_COUNT; + #[allow(deprecated)] + let rc = unsafe { + libc::task_info( + libc::mach_task_self(), + libc::MACH_TASK_BASIC_INFO, + info.as_mut_ptr().cast::(), + &mut count, + ) + }; + if rc != libc::KERN_SUCCESS { + return None; + } + + let info = unsafe { info.assume_init() }; + Some((info.resident_size / 1024) as u64) +} + +#[cfg(not(any( + target_os = "android", + target_os = "linux", + target_os = "ios", + target_os = "macos" +)))] +fn current_process_memory_kb() -> Option { + None +} + +fn measure_iteration( + monitor: &mut M, + f: F, +) -> Result<(BenchSample, Instant, Instant), TimingError> +where + M: ResourceMonitor, + F: FnOnce() -> Result<(), TimingError>, +{ + let token = monitor.start(); + let started_at = Instant::now(); + let result = f(); + let ended_at = Instant::now(); + let resources = monitor.finish(token); + result.map(|_| { + ( + BenchSample::from_measurement(ended_at.duration_since(started_at), resources), + started_at, + ended_at, + ) + }) +} + /// Records a flat semantic phase when called inside an active benchmark measurement loop. /// /// Phases are aggregated across measured iterations and ignored during warmup/setup. @@ -611,6 +924,19 @@ pub enum TimingError { pub fn run_closure(spec: BenchSpec, mut f: F) -> Result where F: FnMut() -> Result<(), TimingError>, +{ + let mut monitor = DefaultResourceMonitor; + run_closure_with_monitor(spec, &mut monitor, move || f()) +} + +fn run_closure_with_monitor( + spec: BenchSpec, + monitor: &mut M, + mut f: F, +) -> Result +where + F: FnMut() -> Result<(), TimingError>, + M: ResourceMonitor, { if spec.iterations == 0 { return Err(TimingError::NoIterations { @@ -640,13 +966,14 @@ where begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); for iteration in 0..spec.iterations { - let start = Instant::now(); - if let Err(err) = f() { - let _ = finish_semantic_phase_collection(); - return Err(err); - } - let end = Instant::now(); - samples.push(BenchSample::from_duration(end.duration_since(start))); + let (sample, start, end) = match measure_iteration(monitor, || f()) { + Ok(measurement) => measurement, + Err(err) => { + let _ = finish_semantic_phase_collection(); + return Err(err); + } + }; + samples.push(sample); push_timeline_span( &mut timeline, harness_origin, @@ -701,6 +1028,21 @@ pub fn run_closure_with_setup( where S: FnOnce() -> T, F: FnMut(&T) -> Result<(), TimingError>, +{ + let mut monitor = DefaultResourceMonitor; + run_closure_with_setup_with_monitor(spec, &mut monitor, setup, move |input| f(input)) +} + +fn run_closure_with_setup_with_monitor( + spec: BenchSpec, + monitor: &mut M, + setup: S, + mut f: F, +) -> Result +where + S: FnOnce() -> T, + F: FnMut(&T) -> Result<(), TimingError>, + M: ResourceMonitor, { if spec.iterations == 0 { return Err(TimingError::NoIterations { @@ -742,13 +1084,14 @@ where begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); for iteration in 0..spec.iterations { - let start = Instant::now(); - if let Err(err) = f(&input) { - let _ = finish_semantic_phase_collection(); - return Err(err); - } - let end = Instant::now(); - samples.push(BenchSample::from_duration(end.duration_since(start))); + let (sample, start, end) = match measure_iteration(monitor, || f(&input)) { + Ok(measurement) => measurement, + Err(err) => { + let _ = finish_semantic_phase_collection(); + return Err(err); + } + }; + samples.push(sample); push_timeline_span( &mut timeline, harness_origin, @@ -804,6 +1147,26 @@ pub fn run_closure_with_setup_per_iter( where S: FnMut() -> T, F: FnMut(T) -> Result<(), TimingError>, +{ + let mut monitor = DefaultResourceMonitor; + run_closure_with_setup_per_iter_with_monitor( + spec, + &mut monitor, + move || setup(), + move |input| f(input), + ) +} + +fn run_closure_with_setup_per_iter_with_monitor( + spec: BenchSpec, + monitor: &mut M, + mut setup: S, + mut f: F, +) -> Result +where + S: FnMut() -> T, + F: FnMut(T) -> Result<(), TimingError>, + M: ResourceMonitor, { if spec.iterations == 0 { return Err(TimingError::NoIterations { @@ -854,13 +1217,14 @@ where Some(iteration), ); - let start = Instant::now(); - if let Err(err) = f(input) { - let _ = finish_semantic_phase_collection(); - return Err(err); - } - let end = Instant::now(); - samples.push(BenchSample::from_duration(end.duration_since(start))); + let (sample, start, end) = match measure_iteration(monitor, || f(input)) { + Ok(measurement) => measurement, + Err(err) => { + let _ = finish_semantic_phase_collection(); + return Err(err); + } + }; + samples.push(sample); push_timeline_span( &mut timeline, harness_origin, @@ -918,6 +1282,29 @@ where S: FnOnce() -> T, F: FnMut(&T) -> Result<(), TimingError>, D: FnOnce(T), +{ + let mut monitor = DefaultResourceMonitor; + run_closure_with_setup_teardown_with_monitor( + spec, + &mut monitor, + setup, + move |input| f(input), + teardown, + ) +} + +fn run_closure_with_setup_teardown_with_monitor( + spec: BenchSpec, + monitor: &mut M, + setup: S, + mut f: F, + teardown: D, +) -> Result +where + S: FnOnce() -> T, + F: FnMut(&T) -> Result<(), TimingError>, + D: FnOnce(T), + M: ResourceMonitor, { if spec.iterations == 0 { return Err(TimingError::NoIterations { @@ -959,13 +1346,14 @@ where begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); for iteration in 0..spec.iterations { - let start = Instant::now(); - if let Err(err) = f(&input) { - let _ = finish_semantic_phase_collection(); - return Err(err); - } - let end = Instant::now(); - samples.push(BenchSample::from_duration(end.duration_since(start))); + let (sample, start, end) = match measure_iteration(monitor, || f(&input)) { + Ok(measurement) => measurement, + Err(err) => { + let _ = finish_semantic_phase_collection(); + return Err(err); + } + }; + samples.push(sample); push_timeline_span( &mut timeline, harness_origin, @@ -1001,6 +1389,45 @@ where mod tests { use super::*; + #[derive(Default)] + struct FakeResourceMonitor { + samples: Vec, + started: usize, + finished: usize, + } + + impl FakeResourceMonitor { + fn new(samples: Vec) -> Self { + Self { + samples, + started: 0, + finished: 0, + } + } + } + + impl ResourceMonitor for FakeResourceMonitor { + type Token = usize; + + fn start(&mut self) -> Self::Token { + let token = self.started; + self.started += 1; + assert!( + token < self.samples.len(), + "resource capture should only run for measured iterations" + ); + token + } + + fn finish(&mut self, token: Self::Token) -> IterationResourceUsage { + self.finished += 1; + self.samples + .get(token) + .cloned() + .expect("resource usage for measured iteration") + } + } + #[test] fn runs_benchmark_collects_requested_samples() { let spec = BenchSpec::new("noop", 3, 1).unwrap(); @@ -1031,18 +1458,32 @@ mod tests { #[test] fn serializes_to_json() { - let spec = BenchSpec::new("test", 10, 2).unwrap(); - let report = run_closure(spec, || { - profile_phase("prove", || std::thread::sleep(Duration::from_millis(1))); - Ok(()) - }) - .unwrap(); + let report = BenchReport { + spec: BenchSpec::new("test", 10, 2).unwrap(), + samples: vec![BenchSample { + duration_ns: 1_000_000, + cpu_time_ms: Some(42), + peak_memory_kb: Some(512), + }], + phases: vec![SemanticPhase { + name: "prove".to_string(), + duration_ns: 1_000_000, + }], + timeline: vec![HarnessTimelineSpan { + phase: "measured-benchmark".to_string(), + start_offset_ns: 0, + end_offset_ns: 1_000_000, + iteration: Some(0), + }], + }; let json = serde_json::to_string(&report).unwrap(); let restored: BenchReport = serde_json::from_str(&json).unwrap(); assert_eq!(restored.spec.name, "test"); - assert_eq!(restored.samples.len(), 10); + assert_eq!(restored.samples.len(), 1); + assert_eq!(restored.samples[0].cpu_time_ms, Some(42)); + assert_eq!(restored.samples[0].peak_memory_kb, Some(512)); assert_eq!(restored.phases.len(), 1); assert_eq!(restored.phases[0].name, "prove"); assert!(restored.phases[0].duration_ns > 0); @@ -1098,6 +1539,179 @@ mod tests { ); } + #[test] + fn measured_cpu_excludes_warmup_iterations() { + let spec = BenchSpec::new("cpu", 2, 1).unwrap(); + let mut monitor = FakeResourceMonitor::new(vec![ + IterationResourceUsage { + cpu_time_ms: Some(11), + peak_memory_kb: Some(32), + }, + IterationResourceUsage { + cpu_time_ms: Some(17), + peak_memory_kb: Some(64), + }, + ]); + let mut calls = 0_u32; + + let report = run_closure_with_monitor(spec, &mut monitor, || { + calls += 1; + Ok(()) + }) + .unwrap(); + + assert_eq!(calls, 3); + assert_eq!(monitor.started, 2); + assert_eq!(monitor.finished, 2); + assert_eq!( + report + .samples + .iter() + .map(|sample| sample.cpu_time_ms) + .collect::>(), + vec![Some(11), Some(17)] + ); + assert_eq!(report.cpu_total_ms(), Some(28)); + } + + #[test] + fn measured_cpu_excludes_outer_harness_and_report_overhead() { + let spec = BenchSpec::new("cpu-harness", 2, 1).unwrap(); + let mut monitor = FakeResourceMonitor::new(vec![ + IterationResourceUsage { + cpu_time_ms: Some(5), + peak_memory_kb: Some(12), + }, + IterationResourceUsage { + cpu_time_ms: Some(7), + peak_memory_kb: Some(18), + }, + ]); + + let mut setup_calls = 0_u32; + let mut teardown_calls = 0_u32; + let report = run_closure_with_setup_teardown_with_monitor( + spec, + &mut monitor, + || { + setup_calls += 1; + vec![1_u8, 2, 3] + }, + |_fixture| Ok(()), + |_fixture| { + teardown_calls += 1; + }, + ) + .unwrap(); + + let _serialized = serde_json::to_string(&report).unwrap(); + + assert_eq!(setup_calls, 1); + assert_eq!(teardown_calls, 1); + assert_eq!(monitor.started, 2); + assert_eq!(report.cpu_total_ms(), Some(12)); + assert_eq!(report.cpu_median_ms(), Some(6)); + } + + #[test] + fn single_iteration_cpu_median_matches_the_measured_iteration() { + let spec = BenchSpec::new("single", 1, 0).unwrap(); + let mut monitor = FakeResourceMonitor::new(vec![IterationResourceUsage { + cpu_time_ms: Some(42), + peak_memory_kb: Some(24), + }]); + + let report = run_closure_with_monitor(spec, &mut monitor, || Ok(())).unwrap(); + + assert_eq!(report.samples[0].cpu_time_ms, Some(42)); + assert_eq!(report.cpu_total_ms(), Some(42)); + assert_eq!(report.cpu_median_ms(), Some(42)); + } + + #[test] + fn multiple_iterations_export_the_median_cpu_sample() { + let spec = BenchSpec::new("median", 3, 0).unwrap(); + let mut monitor = FakeResourceMonitor::new(vec![ + IterationResourceUsage { + cpu_time_ms: Some(19), + peak_memory_kb: Some(10), + }, + IterationResourceUsage { + cpu_time_ms: Some(7), + peak_memory_kb: Some(30), + }, + IterationResourceUsage { + cpu_time_ms: Some(11), + peak_memory_kb: Some(20), + }, + ]); + + let report = run_closure_with_monitor(spec, &mut monitor, || Ok(())).unwrap(); + + assert_eq!(report.cpu_median_ms(), Some(11)); + assert_eq!(report.cpu_total_ms(), Some(37)); + } + + #[test] + fn peak_memory_excludes_harness_baseline_overhead() { + let spec = BenchSpec::new("memory", 2, 1).unwrap(); + let mut monitor = FakeResourceMonitor::new(vec![ + IterationResourceUsage { + cpu_time_ms: Some(3), + peak_memory_kb: Some(48), + }, + IterationResourceUsage { + cpu_time_ms: Some(4), + peak_memory_kb: Some(96), + }, + ]); + + let report = run_closure_with_setup_teardown_with_monitor( + spec, + &mut monitor, + || vec![0_u8; 1024], + |_fixture| Ok(()), + |_fixture| {}, + ) + .unwrap(); + + assert_eq!( + report + .samples + .iter() + .map(|sample| sample.peak_memory_kb) + .collect::>(), + vec![Some(48), Some(96)] + ); + assert_eq!(report.peak_memory_kb(), Some(96)); + } + + #[test] + fn memory_peak_sampler_uses_the_first_post_startup_sample_as_its_baseline() { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + let samples = Arc::new(Mutex::new(VecDeque::from([ + Some(80_u64), + Some(100_u64), + Some(140_u64), + Some(120_u64), + ]))); + let reader_samples = Arc::clone(&samples); + let reader = Arc::new(move || { + reader_samples + .lock() + .expect("sample queue") + .pop_front() + .unwrap_or(Some(120)) + }); + + let sampler = MemoryPeakSampler::start_with_reader(reader).expect("sampler"); + let peak_kb = sampler.stop().expect("peak memory"); + + assert_eq!(peak_kb, 40); + } + #[test] fn run_with_setup_calls_setup_once() { use std::sync::atomic::{AtomicU32, Ordering}; diff --git a/crates/mobench-sdk/src/uniffi_types.rs b/crates/mobench-sdk/src/uniffi_types.rs index 8f08b78..009ab31 100644 --- a/crates/mobench-sdk/src/uniffi_types.rs +++ b/crates/mobench-sdk/src/uniffi_types.rs @@ -38,6 +38,8 @@ //! #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] //! pub struct BenchSample { //! pub duration_ns: u64, +//! pub cpu_time_ms: Option, +//! pub peak_memory_kb: Option, //! } //! //! #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] @@ -181,12 +183,18 @@ impl From for crate::BenchSpec { pub struct BenchSampleTemplate { /// Duration of the iteration in nanoseconds. pub duration_ns: u64, + /// CPU time consumed by the measured iteration in milliseconds. + pub cpu_time_ms: Option, + /// Peak memory growth during the measured iteration in kilobytes. + pub peak_memory_kb: Option, } impl From for BenchSampleTemplate { fn from(sample: crate::BenchSample) -> Self { Self { duration_ns: sample.duration_ns, + cpu_time_ms: sample.cpu_time_ms, + peak_memory_kb: sample.peak_memory_kb, } } } @@ -195,6 +203,8 @@ impl From for crate::BenchSample { fn from(sample: BenchSampleTemplate) -> Self { Self { duration_ns: sample.duration_ns, + cpu_time_ms: sample.cpu_time_ms, + peak_memory_kb: sample.peak_memory_kb, } } } @@ -337,9 +347,15 @@ mod tests { #[test] fn test_bench_sample_template_conversion() { - let sdk_sample = crate::BenchSample { duration_ns: 12345 }; + let sdk_sample = crate::BenchSample { + duration_ns: 12345, + cpu_time_ms: Some(12), + peak_memory_kb: Some(48), + }; let template: BenchSampleTemplate = sdk_sample.into(); assert_eq!(template.duration_ns, 12345); + assert_eq!(template.cpu_time_ms, Some(12)); + assert_eq!(template.peak_memory_kb, Some(48)); } #[test] diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index 08905b6..854a8d2 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -2,8 +2,6 @@ package {{PACKAGE_NAME}} import android.os.Bundle import android.os.Debug -import android.os.Process -import android.os.SystemClock import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import org.json.JSONArray @@ -71,6 +69,19 @@ class MainActivity : AppCompatActivity() { android.util.Log.i("BenchRunner", "Display hold complete") } + private fun median(values: List): Long { + val sorted = values.sorted() + if (sorted.isEmpty()) { + return 0L + } + val middle = sorted.size / 2 + return if (sorted.size % 2 == 0) { + (sorted[middle - 1] + sorted[middle]) / 2 + } else { + sorted[middle] + } + } + /** * Formats a duration in nanoseconds to a human-readable string. * Uses milliseconds (ms) by default, switches to seconds (s) if >= 1000ms. @@ -118,10 +129,20 @@ class MainActivity : AppCompatActivity() { spec.put("warmup", report.spec.warmup.toInt()) json.put("spec", spec) - val samples = report.samples.map { it.durationNs.toLong() } - val sampleArray = JSONArray() - samples.forEach { sampleArray.put(it) } - json.put("samples_ns", sampleArray) + val durationSamplesNs = report.samples.map { it.durationNs.toLong() } + val samplesNs = JSONArray() + durationSamplesNs.forEach { samplesNs.put(it) } + json.put("samples_ns", samplesNs) + + val samples = JSONArray() + report.samples.forEach { sample -> + val sampleJson = JSONObject() + sampleJson.put("duration_ns", sample.durationNs.toLong()) + sample.cpuTimeMs?.let { sampleJson.put("cpu_time_ms", it.toLong()) } + sample.peakMemoryKb?.let { sampleJson.put("peak_memory_kb", it.toLong()) } + samples.put(sampleJson) + } + json.put("samples", samples) val phases = JSONArray() report.phases.forEach { phase -> @@ -132,10 +153,10 @@ class MainActivity : AppCompatActivity() { } json.put("phases", phases) - if (samples.isNotEmpty()) { - val min = samples.minOrNull() ?: 0L - val max = samples.maxOrNull() ?: 0L - val avg = samples.sum().toDouble() / samples.size.toDouble() + if (durationSamplesNs.isNotEmpty()) { + val min = durationSamplesNs.minOrNull() ?: 0L + val max = durationSamplesNs.maxOrNull() ?: 0L + val avg = durationSamplesNs.sum().toDouble() / durationSamplesNs.size.toDouble() val stats = JSONObject() stats.put("min_ns", min) stats.put("max_ns", max) @@ -143,11 +164,22 @@ class MainActivity : AppCompatActivity() { json.put("stats", stats) } + val cpuSamplesMs = report.samples.mapNotNull { it.cpuTimeMs?.toLong() } + val peakSamplesKb = report.samples.mapNotNull { it.peakMemoryKb?.toLong() } val memInfo = Debug.MemoryInfo() Debug.getMemoryInfo(memInfo) val resources = JSONObject() - resources.put("elapsed_cpu_ms", Process.getElapsedCpuTime()) - resources.put("uptime_ms", SystemClock.elapsedRealtime()) + resources.put("platform", "android") + resources.put("timestamp_ms", System.currentTimeMillis()) + if (cpuSamplesMs.isNotEmpty()) { + val cpuTotalMs = cpuSamplesMs.sum() + resources.put("cpu_total_ms", cpuTotalMs) + resources.put("cpu_median_ms", median(cpuSamplesMs)) + resources.put("elapsed_cpu_ms", cpuTotalMs) + } + if (peakSamplesKb.isNotEmpty()) { + resources.put("peak_memory_kb", peakSamplesKb.maxOrNull() ?: 0L) + } resources.put("total_pss_kb", memInfo.totalPss) resources.put("private_dirty_kb", memInfo.totalPrivateDirty) resources.put("native_heap_kb", Debug.getNativeHeapAllocatedSize() / 1024) diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index cbd2bed..8f8e0c0 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -116,7 +116,7 @@ struct BenchmarkResult { } enum {{PROJECT_NAME_PASCAL}}FFI { - static func runCurrentBenchmark() async -> BenchmarkResult { + static func runCurrentBenchmark() -> BenchmarkResult { let params = BenchParams.resolved() return run(params: params) } @@ -167,7 +167,16 @@ enum {{PROJECT_NAME_PASCAL}}FFI { json["samples_ns"] = samplesNs // Also include samples in object format for compatibility - let samplesArray = report.samples.map { ["duration_ns": $0.durationNs] } + let samplesArray = report.samples.map { sample -> [String: Any] in + var sampleJson: [String: Any] = ["duration_ns": sample.durationNs] + if let cpuTimeMs = sample.cpuTimeMs { + sampleJson["cpu_time_ms"] = cpuTimeMs + } + if let peakMemoryKb = sample.peakMemoryKb { + sampleJson["peak_memory_kb"] = peakMemoryKb + } + return sampleJson + } json["samples"] = samplesArray let phases = report.phases.map { phase in @@ -210,11 +219,32 @@ enum {{PROJECT_NAME_PASCAL}}FFI { json["max_ns"] = maxNs } - // Resource metrics (iOS-specific) - let resources: [String: Any] = [ + let cpuSamplesMs = report.samples.compactMap(\.cpuTimeMs) + let peakSamplesKb = report.samples.compactMap(\.peakMemoryKb) + + func median(_ values: [UInt64]) -> UInt64 { + let sorted = values.sorted() + if sorted.count % 2 == 0 { + return (sorted[sorted.count / 2 - 1] + sorted[sorted.count / 2]) / 2 + } else { + return sorted[sorted.count / 2] + } + } + + // Resource metrics derived from the measured loop itself. + var resources: [String: Any] = [ "platform": "ios", "timestamp_ms": Int64(Date().timeIntervalSince1970 * 1000) ] + if !cpuSamplesMs.isEmpty { + let cpuTotalMs = cpuSamplesMs.reduce(0, +) + resources["cpu_total_ms"] = cpuTotalMs + resources["cpu_median_ms"] = median(cpuSamplesMs) + resources["elapsed_cpu_ms"] = cpuTotalMs + } + if let peakMemoryKb = peakSamplesKb.max() { + resources["peak_memory_kb"] = peakMemoryKb + } json["resources"] = resources // Serialize to JSON string diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 1a78d23..016543d 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -263,6 +263,15 @@ cargo mobench ci run --target --function [OPTIONS] - `summary.md` - `results.csv` +`summary.md` uses unit-neutral timing headers and renders `CPU` from measured-iteration `cpu_median_ms` in milliseconds below one second and total seconds otherwise. + +`results.csv` includes: +- `cpu_total_ms` +- `cpu_median_ms` +- `peak_memory_kb` + +Blank fields indicate that a resource metric was not available for that benchmark/device row. + `summary.json` includes a `ci` section with metadata fields: - `requested_by` - `pr_number` diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 5b2cccb..b7eca73 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -1094,6 +1094,8 @@ struct BenchmarkResourceUsage { #[serde(skip_serializing_if = "Option::is_none")] cpu_total_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] + cpu_median_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] peak_memory_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] total_pss_kb: Option, @@ -4169,6 +4171,15 @@ fn build_summary(run_summary: &RunSummary) -> Result { .to_string(); let samples = extract_samples(entry); let stats = compute_sample_stats(&samples); + let sample_count = if samples.is_empty() { + entry + .get("samples") + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(0) + } else { + samples.len() + }; let mean_ns = stats .as_ref() .map(|s| s.mean_ns) @@ -4176,12 +4187,24 @@ fn build_summary(run_summary: &RunSummary) -> Result { benchmarks.push(BenchmarkStats { function, - samples: samples.len(), + samples: sample_count, mean_ns, - median_ns: stats.as_ref().map(|s| s.median_ns), - p95_ns: stats.as_ref().map(|s| s.p95_ns), - min_ns: stats.as_ref().map(|s| s.min_ns), - max_ns: stats.as_ref().map(|s| s.max_ns), + median_ns: stats + .as_ref() + .map(|s| s.median_ns) + .or_else(|| entry.get("median_ns").and_then(|value| value.as_u64())), + p95_ns: stats + .as_ref() + .map(|s| s.p95_ns) + .or_else(|| entry.get("p95_ns").and_then(|value| value.as_u64())), + min_ns: stats + .as_ref() + .map(|s| s.min_ns) + .or_else(|| entry.get("min_ns").and_then(|value| value.as_u64())), + max_ns: stats + .as_ref() + .map(|s| s.max_ns) + .or_else(|| entry.get("max_ns").and_then(|value| value.as_u64())), resource_usage: extract_benchmark_resource_usage(entry, perf_metrics), }); } @@ -4784,7 +4807,7 @@ fn render_compare_markdown(report: &CompareReport) -> String { let _ = writeln!(output); let _ = writeln!( output, - "| Device | Function | Median (base ms) | Median (cand ms) | Median Δ% | Median Label | P95 (base ms) | P95 (cand ms) | P95 Δ% | P95 Label |" + "| Device | Function | Median base | Median cand | Median Δ% | Median Label | P95 base | P95 cand | P95 Δ% | P95 Label |" ); let _ = writeln!( output, @@ -5121,7 +5144,7 @@ fn summarize_local_report(run_summary: &RunSummary) -> Option { p95_ns: Some(stats.p95_ns), min_ns: Some(stats.min_ns), max_ns: Some(stats.max_ns), - resource_usage: None, + resource_usage: extract_benchmark_resource_usage(&run_summary.local_report, None), }], }) } @@ -5129,6 +5152,7 @@ fn summarize_local_report(run_summary: &RunSummary) -> Option { impl BenchmarkResourceUsage { fn is_empty(&self) -> bool { self.cpu_total_ms.is_none() + && self.cpu_median_ms.is_none() && self.peak_memory_kb.is_none() && self.total_pss_kb.is_none() && self.private_dirty_kb.is_none() @@ -5137,74 +5161,6 @@ impl BenchmarkResourceUsage { } } -fn json_value_to_u64(value: &Value) -> Option { - value - .as_u64() - .or_else(|| value.as_i64().and_then(|v| u64::try_from(v).ok())) - .or_else(|| { - value - .as_f64() - .filter(|v| v.is_finite() && *v >= 0.0) - .map(|v| v.round() as u64) - }) -} - -fn raw_peak_memory_kb( - total_pss_kb: Option, - private_dirty_kb: Option, - native_heap_kb: Option, - java_heap_kb: Option, -) -> Option { - total_pss_kb - .or(private_dirty_kb) - .or_else(|| match (native_heap_kb, java_heap_kb) { - (Some(native), Some(java)) => Some(native + java), - (Some(native), None) => Some(native), - (None, Some(java)) => Some(java), - (None, None) => None, - }) -} - -fn extract_benchmark_resource_usage( - entry: &Value, - perf_metrics: Option<&browserstack::PerformanceMetrics>, -) -> Option { - let resources = entry.get("resources"); - let cpu_total_ms = resources - .and_then(|res| res.get("elapsed_cpu_ms")) - .and_then(json_value_to_u64); - let total_pss_kb = resources - .and_then(|res| res.get("total_pss_kb")) - .and_then(json_value_to_u64); - let private_dirty_kb = resources - .and_then(|res| res.get("private_dirty_kb")) - .and_then(json_value_to_u64); - let native_heap_kb = resources - .and_then(|res| res.get("native_heap_kb")) - .and_then(json_value_to_u64); - let java_heap_kb = resources - .and_then(|res| res.get("java_heap_kb")) - .and_then(json_value_to_u64); - - let peak_memory_kb = perf_metrics - .and_then(|metrics| metrics.memory.as_ref()) - .map(|memory| (memory.peak_mb * 1024.0).round() as u64) - .or_else(|| { - raw_peak_memory_kb(total_pss_kb, private_dirty_kb, native_heap_kb, java_heap_kb) - }); - - let resource_usage = BenchmarkResourceUsage { - cpu_total_ms, - peak_memory_kb, - total_pss_kb, - private_dirty_kb, - native_heap_kb, - java_heap_kb, - }; - - (!resource_usage.is_empty()).then_some(resource_usage) -} - #[derive(Clone, Debug)] struct SampleStats { mean_ns: u64, @@ -5272,6 +5228,142 @@ fn extract_samples(value: &Value) -> Vec { durations } +fn extract_sample_metric_u64(value: &Value, key: &str) -> Vec { + value + .get("samples") + .and_then(|samples| samples.as_array()) + .map(|samples| { + samples + .iter() + .filter_map(|sample| sample.get(key)) + .filter_map(json_value_to_u64) + .collect() + }) + .unwrap_or_default() +} + +fn json_value_to_u64(value: &Value) -> Option { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok())) + .or_else(|| { + value + .as_f64() + .filter(|value| value.is_finite() && *value >= 0.0) + .map(|value| value.round() as u64) + }) +} + +fn median_u64(values: &[u64]) -> Option { + if values.is_empty() { + return None; + } + + let mut sorted = values.to_vec(); + sorted.sort_unstable(); + let len = sorted.len(); + Some(if len % 2 == 0 { + let lower = u128::from(sorted[(len / 2) - 1]); + let upper = u128::from(sorted[len / 2]); + ((lower + upper) / 2) as u64 + } else { + sorted[len / 2] + }) +} + +fn raw_peak_memory_kb( + total_pss_kb: Option, + private_dirty_kb: Option, + native_heap_kb: Option, + java_heap_kb: Option, +) -> Option { + total_pss_kb + .or(private_dirty_kb) + .or_else(|| match (native_heap_kb, java_heap_kb) { + (Some(native), Some(java)) => Some(native + java), + (Some(native), None) => Some(native), + (None, Some(java)) => Some(java), + (None, None) => None, + }) +} + +fn extract_benchmark_resource_usage( + entry: &Value, + perf_metrics: Option<&browserstack::PerformanceMetrics>, +) -> Option { + let resources = entry + .get("resource_usage") + .or_else(|| entry.get("resources")) + .or(Some(entry)); + let sample_cpu_ms = extract_sample_metric_u64(entry, "cpu_time_ms"); + let sample_peak_memory_kb = extract_sample_metric_u64(entry, "peak_memory_kb"); + + let cpu_total_ms = resources + .and_then(|res| res.get("cpu_total_ms")) + .and_then(json_value_to_u64) + .or_else(|| { + resources + .and_then(|res| res.get("elapsed_cpu_ms")) + .and_then(json_value_to_u64) + }) + .or_else(|| { + (!sample_cpu_ms.is_empty()).then(|| { + sample_cpu_ms + .iter() + .fold(0_u128, |sum, value| sum.saturating_add(u128::from(*value))) + .min(u128::from(u64::MAX)) as u64 + }) + }); + let cpu_median_ms = resources + .and_then(|res| res.get("cpu_median_ms")) + .and_then(json_value_to_u64) + .or_else(|| median_u64(&sample_cpu_ms)); + let total_pss_kb = resources + .and_then(|res| res.get("total_pss_kb")) + .and_then(json_value_to_u64); + let private_dirty_kb = resources + .and_then(|res| res.get("private_dirty_kb")) + .and_then(json_value_to_u64); + let native_heap_kb = resources + .and_then(|res| res.get("native_heap_kb")) + .and_then(json_value_to_u64); + let java_heap_kb = resources + .and_then(|res| res.get("java_heap_kb")) + .and_then(json_value_to_u64); + let reported_peak_memory_kb = resources + .and_then(|res| res.get("peak_memory_kb")) + .and_then(json_value_to_u64) + .or_else(|| { + resources + .and_then(|res| res.get("ram_peak_mb")) + .and_then(|value| value.as_f64()) + .map(|value| (value * 1024.0).round() as u64) + }); + + let peak_memory_kb = reported_peak_memory_kb + .or_else(|| sample_peak_memory_kb.iter().copied().max()) + .or_else(|| { + perf_metrics + .and_then(|metrics| metrics.memory.as_ref()) + .map(|memory| (memory.peak_mb * 1024.0).round() as u64) + }) + .or_else(|| { + raw_peak_memory_kb(total_pss_kb, private_dirty_kb, native_heap_kb, java_heap_kb) + }); + + let resource_usage = BenchmarkResourceUsage { + cpu_total_ms, + cpu_median_ms, + peak_memory_kb, + total_pss_kb, + private_dirty_kb, + native_heap_kb, + java_heap_kb, + }; + + (!resource_usage.is_empty()).then_some(resource_usage) +} + fn render_markdown_summary(summary: &SummaryReport) -> String { let mut output = String::new(); let devices = if summary.devices.is_empty() { @@ -5301,23 +5393,59 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { for device in &summary.device_summaries { let _ = writeln!(output, "### Device: {}", device.device); let _ = writeln!(output); - let _ = writeln!( - output, - "| Function | Samples | Mean (ms) | Median (ms) | P95 (ms) | Min (ms) | Max (ms) |" - ); - let _ = writeln!(output, "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"); - for bench in &device.benchmarks { + let has_resource_usage = device + .benchmarks + .iter() + .any(|bench| bench.resource_usage.is_some()); + if has_resource_usage { let _ = writeln!( output, - "| {} | {} | {} | {} | {} | {} | {} |", - bench.function, - bench.samples, - format_ms(bench.mean_ns), - format_ms(bench.median_ns), - format_ms(bench.p95_ns), - format_ms(bench.min_ns), - format_ms(bench.max_ns) + "| Function | Samples | Mean | Median | P95 | Min | Max | CPU | Peak memory |" + ); + let _ = writeln!( + output, + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" + ); + } else { + let _ = writeln!( + output, + "| Function | Samples | Mean | Median | P95 | Min | Max |" ); + let _ = writeln!(output, "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"); + } + for bench in &device.benchmarks { + if has_resource_usage { + let _ = writeln!( + output, + "| {} | {} | {} | {} | {} | {} | {} | {} | {} |", + bench.function, + bench.samples, + format_ms(bench.mean_ns), + format_ms(bench.median_ns), + format_ms(bench.p95_ns), + format_ms(bench.min_ns), + format_ms(bench.max_ns), + format_cpu_ms(bench.resource_usage.as_ref()), + format_peak_memory( + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.peak_memory_kb) + ) + ); + } else { + let _ = writeln!( + output, + "| {} | {} | {} | {} | {} | {} | {} |", + bench.function, + bench.samples, + format_ms(bench.mean_ns), + format_ms(bench.median_ns), + format_ms(bench.p95_ns), + format_ms(bench.min_ns), + format_ms(bench.max_ns) + ); + } } let _ = writeln!(output); } @@ -5329,13 +5457,13 @@ fn render_csv_summary(summary: &SummaryReport) -> String { let mut output = String::new(); let _ = writeln!( output, - "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns" + "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb" ); for device in &summary.device_summaries { for bench in &device.benchmarks { let _ = writeln!( output, - "{},{},{},{},{},{},{},{}", + "{},{},{},{},{},{},{},{},{},{},{}", device.device, bench.function, bench.samples, @@ -5343,7 +5471,22 @@ fn render_csv_summary(summary: &SummaryReport) -> String { bench.median_ns.map_or(String::from(""), |v| v.to_string()), bench.p95_ns.map_or(String::from(""), |v| v.to_string()), bench.min_ns.map_or(String::from(""), |v| v.to_string()), - bench.max_ns.map_or(String::from(""), |v| v.to_string()) + bench.max_ns.map_or(String::from(""), |v| v.to_string()), + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.cpu_total_ms) + .map_or(String::new(), |v| v.to_string()), + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.cpu_median_ms) + .map_or(String::new(), |v| v.to_string()), + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.peak_memory_kb) + .map_or(String::new(), |v| v.to_string()) ); } } @@ -5377,6 +5520,27 @@ fn format_ms(value: Option) -> String { .unwrap_or_else(|| "-".to_string()) } +fn format_cpu_ms(value: Option<&BenchmarkResourceUsage>) -> String { + value + .and_then(|usage| usage.cpu_median_ms.or(usage.cpu_total_ms)) + .map(format_cpu_total_duration_ms) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_cpu_total_duration_ms(ms: u64) -> String { + if ms < 1_000 { + format!("{ms}ms") + } else { + format!("{:.3}s", ms as f64 / 1_000.0) + } +} + +fn format_peak_memory(value_kb: Option) -> String { + value_kb + .map(|value| format!("{:.2} MB", value as f64 / 1024.0)) + .unwrap_or_else(|| "-".to_string()) +} + pub(crate) fn run_android_build( layout: &ResolvedProjectLayout, _ndk_home: &str, @@ -6759,10 +6923,10 @@ fn builtin_device_for_profile( profile: &str, ) -> Option { let (name, os, os_version) = match (platform, profile) { - (DevicePlatform::Ios, "low-spec") => ("iPhone 13", "ios", "15"), + (DevicePlatform::Ios, "low-spec") => ("iPhone SE 2020", "ios", "16"), (DevicePlatform::Ios, "mid-spec") => ("iPhone 14", "ios", "16"), (DevicePlatform::Ios, "high-spec") => ("iPhone 16 Pro", "ios", "18"), - (DevicePlatform::Android, "low-spec") => ("Google Pixel 6", "android", "12.0"), + (DevicePlatform::Android, "low-spec") => ("Motorola Moto G9 Play", "android", "10.0"), (DevicePlatform::Android, "mid-spec") => ("Google Pixel 7", "android", "13.0"), (DevicePlatform::Android, "high-spec") => ("Samsung Galaxy S24", "android", "14.0"), _ => return None, @@ -8925,13 +9089,23 @@ project = "proj" } #[test] - fn builtin_ios_low_spec_profile_matches_ci_deployment_target() { + fn builtin_ios_low_spec_profile_uses_iphone_se_2020() { let resolved = builtin_device_for_profile(DevicePlatform::Ios, "low-spec") .expect("built-in low-spec iOS profile"); - assert_eq!(resolved.name, "iPhone 13"); - assert_eq!(resolved.os_version, "15"); - assert_eq!(resolved.identifier, "iPhone 13-15"); + assert_eq!(resolved.name, "iPhone SE 2020"); + assert_eq!(resolved.os_version, "16"); + assert_eq!(resolved.identifier, "iPhone SE 2020-16"); + } + + #[test] + fn builtin_android_low_spec_profile_uses_moto_g9_play() { + let resolved = builtin_device_for_profile(DevicePlatform::Android, "low-spec") + .expect("built-in low-spec Android profile"); + + assert_eq!(resolved.name, "Motorola Moto G9 Play"); + assert_eq!(resolved.os_version, "10.0"); + assert_eq!(resolved.identifier, "Motorola Moto G9 Play-10.0"); } #[test] @@ -8986,12 +9160,290 @@ project = "proj" }; let markdown = render_compare_markdown(&report); assert!(markdown.starts_with("### Benchmark Comparison\n")); + assert!(markdown.contains("Median base")); + assert!(markdown.contains("Median cand")); + assert!(markdown.contains("P95 base")); + assert!(markdown.contains("P95 cand")); + assert!(!markdown.contains("Median (base ms)")); + assert!(!markdown.contains("Median (cand ms)")); + assert!(!markdown.contains("P95 (base ms)")); + assert!(!markdown.contains("P95 (cand ms)")); assert!(markdown.contains("Median Label")); assert!(markdown.contains("P95 Label")); assert!(markdown.contains("regressed")); assert!(markdown.contains("improved")); } + #[test] + fn render_markdown_summary_includes_resource_usage_columns_when_present() { + let markdown = render_markdown_summary(&SummaryReport { + generated_at: "2026-04-12T00:00:00Z".to_string(), + generated_at_unix: 1_744_416_000, + target: MobileTarget::Android, + function: "sample_fns::fibonacci".to_string(), + iterations: 5, + warmup: 1, + devices: vec!["Google Pixel 8-14.0".to_string()], + device_summaries: vec![DeviceSummary { + device: "Google Pixel 8-14.0".to_string(), + benchmarks: vec![BenchmarkStats { + function: "sample_fns::fibonacci".to_string(), + samples: 5, + mean_ns: Some(1_250_000_000), + median_ns: Some(1_200_000_000), + p95_ns: Some(1_300_000_000), + min_ns: Some(1_100_000_000), + max_ns: Some(1_350_000_000), + resource_usage: Some(BenchmarkResourceUsage { + cpu_total_ms: Some(482), + cpu_median_ms: Some(241), + peak_memory_kb: Some(249_416), + total_pss_kb: None, + private_dirty_kb: None, + native_heap_kb: None, + java_heap_kb: None, + }), + }], + }], + }); + + assert!(markdown.contains( + "| Function | Samples | Mean | Median | P95 | Min | Max | CPU | Peak memory |" + )); + assert!(!markdown.contains("(ms)")); + assert!(!markdown.contains("CPU total")); + assert!(markdown.contains("241ms")); + assert!(markdown.contains("243.57 MB")); + } + + #[test] + fn render_csv_summary_includes_resource_usage_columns() { + let csv = render_csv_summary(&SummaryReport { + generated_at: "2026-04-12T00:00:00Z".to_string(), + generated_at_unix: 1_744_416_000, + target: MobileTarget::Android, + function: "sample_fns::fibonacci".to_string(), + iterations: 5, + warmup: 1, + devices: vec!["Google Pixel 8-14.0".to_string()], + device_summaries: vec![DeviceSummary { + device: "Google Pixel 8-14.0".to_string(), + benchmarks: vec![BenchmarkStats { + function: "sample_fns::fibonacci".to_string(), + samples: 5, + mean_ns: Some(1_250_000_000), + median_ns: Some(1_200_000_000), + p95_ns: Some(1_300_000_000), + min_ns: Some(1_100_000_000), + max_ns: Some(1_350_000_000), + resource_usage: Some(BenchmarkResourceUsage { + cpu_total_ms: Some(482), + cpu_median_ms: Some(241), + peak_memory_kb: Some(249_416), + total_pss_kb: None, + private_dirty_kb: None, + native_heap_kb: None, + java_heap_kb: None, + }), + }], + }], + }); + + assert!( + csv.starts_with( + "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb\n" + ) + ); + assert!(csv.contains(",482,241,249416\n")); + } + + #[test] + fn test_render_markdown_uses_cpu_total_and_peak_memory_columns() { + let markdown = render_markdown_summary(&SummaryReport { + generated_at: "2026-04-12T00:00:00Z".to_string(), + generated_at_unix: 1_744_416_000, + target: MobileTarget::Android, + function: "sample_fns::fibonacci".to_string(), + iterations: 5, + warmup: 1, + devices: vec!["Google Pixel 8-14.0".to_string()], + device_summaries: vec![DeviceSummary { + device: "Google Pixel 8-14.0".to_string(), + benchmarks: vec![BenchmarkStats { + function: "sample_fns::fibonacci".to_string(), + samples: 5, + mean_ns: Some(1_250_000_000), + median_ns: Some(1_200_000_000), + p95_ns: Some(1_300_000_000), + min_ns: Some(1_100_000_000), + max_ns: Some(1_350_000_000), + resource_usage: Some(BenchmarkResourceUsage { + cpu_total_ms: Some(482), + cpu_median_ms: Some(241), + peak_memory_kb: Some(654_321), + total_pss_kb: Some(654_321), + private_dirty_kb: None, + native_heap_kb: None, + java_heap_kb: None, + }), + }], + }], + }); + + assert!(markdown.contains("CPU")); + assert!(markdown.contains("Peak memory")); + assert!(!markdown.contains("(ms)")); + assert!(!markdown.contains("CPU total")); + assert!(markdown.contains("0.241s")); + assert!(markdown.contains("638.99 MB")); + } + + #[test] + fn test_render_table_uses_cpu_total_and_peak_memory_columns() { + let markdown = render_markdown_summary(&SummaryReport { + generated_at: "2026-04-12T00:00:00Z".to_string(), + generated_at_unix: 1_744_416_000, + target: MobileTarget::Ios, + function: "sample_fns::fibonacci".to_string(), + iterations: 5, + warmup: 1, + devices: vec!["iPhone 15-17.0".to_string()], + device_summaries: vec![DeviceSummary { + device: "iPhone 15-17.0".to_string(), + benchmarks: vec![BenchmarkStats { + function: "sample_fns::fibonacci".to_string(), + samples: 5, + mean_ns: Some(1_250_000_000), + median_ns: Some(1_200_000_000), + p95_ns: Some(1_300_000_000), + min_ns: Some(1_100_000_000), + max_ns: Some(1_350_000_000), + resource_usage: Some(BenchmarkResourceUsage { + cpu_total_ms: Some(482), + cpu_median_ms: Some(241), + peak_memory_kb: Some(654_321), + total_pss_kb: None, + private_dirty_kb: None, + native_heap_kb: None, + java_heap_kb: None, + }), + }], + }], + }); + + assert!(markdown.contains("CPU")); + assert!(markdown.contains("Peak memory")); + assert!(!markdown.contains("(ms)")); + assert!(!markdown.contains("CPU total")); + assert!(markdown.contains("0.241s")); + assert!(markdown.contains("638.99 MB")); + } + + #[test] + fn build_summary_preserves_resource_usage_from_benchmark_results() { + let spec = RunSpec { + target: MobileTarget::Android, + function: "sample_fns::fibonacci".into(), + iterations: 3, + warmup: 1, + devices: vec!["Google Pixel 8-14.0".into()], + browserstack: None, + ios_xcuitest: None, + }; + let run_summary = RunSummary { + spec: spec.clone(), + artifacts: None, + local_report: json!({}), + remote_run: None, + summary: empty_summary(&spec), + benchmark_results: Some(BTreeMap::from([( + "Google Pixel 8-14.0".to_string(), + vec![json!({ + "function": "sample_fns::fibonacci", + "samples": [ + { "duration_ns": 1000, "cpu_time_ms": 19, "peak_memory_kb": 48 }, + { "duration_ns": 2000, "cpu_time_ms": 7, "peak_memory_kb": 96 }, + { "duration_ns": 3000, "cpu_time_ms": 11, "peak_memory_kb": 64 } + ] + })], + )])), + performance_metrics: None, + }; + + let summary = build_summary(&run_summary).expect("build summary"); + let usage = summary.device_summaries[0].benchmarks[0] + .resource_usage + .as_ref() + .expect("resource usage"); + + assert_eq!(usage.cpu_total_ms, Some(37)); + assert_eq!(usage.cpu_median_ms, Some(11)); + assert_eq!(usage.peak_memory_kb, Some(96)); + } + + #[test] + fn build_summary_prefers_measured_peak_memory_over_browserstack_perf_memory() { + let spec = RunSpec { + target: MobileTarget::Android, + function: "sample_fns::fibonacci".into(), + iterations: 2, + warmup: 1, + devices: vec!["Google Pixel 8-14.0".into()], + browserstack: None, + ios_xcuitest: None, + }; + let run_summary = RunSummary { + spec: spec.clone(), + artifacts: None, + local_report: json!({}), + remote_run: None, + summary: empty_summary(&spec), + benchmark_results: Some(BTreeMap::from([( + "Google Pixel 8-14.0".to_string(), + vec![json!({ + "function": "sample_fns::fibonacci", + "samples": [ + { "duration_ns": 1000, "cpu_time_ms": 10, "peak_memory_kb": 64 }, + { "duration_ns": 2000, "cpu_time_ms": 12, "peak_memory_kb": 72 } + ] + })], + )])), + performance_metrics: Some(BTreeMap::from([( + "Google Pixel 8-14.0".to_string(), + browserstack::PerformanceMetrics { + memory: Some(browserstack::AggregateMemoryMetrics { + peak_mb: 999.0, + average_mb: 900.0, + min_mb: 800.0, + }), + cpu: None, + sample_count: 1, + snapshots: vec![], + }, + )])), + }; + + let summary = build_summary(&run_summary).expect("build summary"); + let usage = summary.device_summaries[0].benchmarks[0] + .resource_usage + .as_ref() + .expect("resource usage"); + + assert_eq!(usage.peak_memory_kb, Some(72)); + } + + #[test] + fn format_cpu_total_duration_ms_uses_milliseconds_below_one_second() { + assert_eq!(format_cpu_total_duration_ms(482), "482ms"); + } + + #[test] + fn format_cpu_total_duration_ms_uses_total_seconds_at_or_above_one_second() { + assert_eq!(format_cpu_total_duration_ms(1_000), "1.000s"); + assert_eq!(format_cpu_total_duration_ms(114_248), "114.248s"); + assert_eq!(format_cpu_total_duration_ms(515_822), "515.822s"); + } + #[test] fn parse_pr_number_from_github_ref_extracts_pull_number() { assert_eq!( @@ -9885,6 +10337,7 @@ mod resource_usage_tests { fn test_resource_usage_json_round_trip() { let usage = BenchmarkResourceUsage { cpu_total_ms: Some(250), + cpu_median_ms: Some(125), peak_memory_kb: Some(8192), total_pss_kb: Some(4096), private_dirty_kb: Some(2048), @@ -9896,6 +10349,7 @@ mod resource_usage_tests { let deserialized: BenchmarkResourceUsage = serde_json::from_str(&json_str).unwrap(); assert_eq!(deserialized.cpu_total_ms, Some(250)); + assert_eq!(deserialized.cpu_median_ms, Some(125)); assert_eq!(deserialized.peak_memory_kb, Some(8192)); assert_eq!(deserialized.total_pss_kb, Some(4096)); assert_eq!(deserialized.private_dirty_kb, Some(2048)); diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index 36a41bc..d7ca7ce 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -710,7 +710,13 @@ fn raw_peak_memory_kb( fn format_cpu_total_ms(value: Option) -> String { value - .map(|value| value.to_string()) + .map(|value| { + if value >= 1_000 { + format!("{:.3}s", value as f64 / 1_000.0) + } else { + format!("{value}ms") + } + }) .unwrap_or_else(|| "—".to_string()) } @@ -766,7 +772,7 @@ fn render_platform_table(platform: &PlatformReport) -> String { let mut headers = vec!["Benchmark", "Avg ms", "Best", "Worst", "Median", "P95"]; if has_resource_usage { - headers.extend(["CPU total (ms)", "Peak memory"]); + headers.extend(["CPU total", "Peak memory"]); } table.set_header( headers @@ -838,10 +844,10 @@ pub fn render_markdown(report: &SummarizeReport) -> String { if has_ru { output.push_str( - "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU total (ms) | Peak memory |\n", + "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU total | Peak memory |\n", ); output.push_str( - "|-----------|--------|------|-------|--------|-----|----------------|-------------|\n", + "|-----------|--------|------|-------|--------|-----|-----------|-------------|\n", ); } else { output.push_str("| Benchmark | Avg ms | Best | Worst | Median | P95 |\n"); @@ -1352,7 +1358,7 @@ mod tests { "min_ns": 1180200000_u64, "max_ns": 1298100000_u64, "resource_usage": { - "cpu_total_ms": 482, + "cpu_total_ms": 1482, "peak_memory_kb": 654321, "total_pss_kb": 654321 } @@ -1364,9 +1370,10 @@ mod tests { let output = render_markdown(&report); - assert!(output.contains("CPU total (ms)")); + assert!(output.contains("CPU total")); + assert!(!output.contains("CPU total (ms)")); assert!(output.contains("Peak memory")); - assert!(output.contains("| 482 |")); + assert!(output.contains("| 1.482s |")); assert!(output.contains("638.99 MB")); assert!(!output.contains("CPU %")); assert!(!output.contains("RAM MB")); @@ -1393,7 +1400,7 @@ mod tests { "min_ns": 1180200000_u64, "max_ns": 1298100000_u64, "resource_usage": { - "cpu_total_ms": 482, + "cpu_total_ms": 1482, "peak_memory_kb": 654321, "total_pss_kb": 654321 } @@ -1405,14 +1412,23 @@ mod tests { let output = render_table(&report); - assert!(output.contains("CPU total (ms)")); + assert!(output.contains("CPU total")); + assert!(!output.contains("CPU total (ms)")); assert!(output.contains("Peak memory")); - assert!(output.contains("482")); + assert!(output.contains("1.482s")); assert!(output.contains("638.99 MB")); assert!(!output.contains("CPU %")); assert!(!output.contains("RAM MB")); } + #[test] + fn test_format_cpu_total_ms_uses_seconds_without_switching_to_minutes() { + assert_eq!(format_cpu_total_ms(Some(482)), "482ms"); + assert_eq!(format_cpu_total_ms(Some(1_482)), "1.482s"); + assert_eq!(format_cpu_total_ms(Some(125_000)), "125.000s"); + assert_eq!(format_cpu_total_ms(None), "—"); + } + #[test] fn test_render_json_output() { let report = SummarizeReport { diff --git a/crates/sample-fns/src/lib.rs b/crates/sample-fns/src/lib.rs index c2ce5e5..7d9cd30 100644 --- a/crates/sample-fns/src/lib.rs +++ b/crates/sample-fns/src/lib.rs @@ -16,6 +16,8 @@ pub struct BenchSpec { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct BenchSample { pub duration_ns: u64, + pub cpu_time_ms: Option, + pub peak_memory_kb: Option, } /// Flat semantic phase timing captured during measured iterations. @@ -75,6 +77,8 @@ impl From for BenchSample { fn from(sample: mobench_sdk::timing::BenchSample) -> Self { Self { duration_ns: sample.duration_ns, + cpu_time_ms: sample.cpu_time_ms, + peak_memory_kb: sample.peak_memory_kb, } } } diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index 1a86641..06e550c 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -92,3 +92,12 @@ jobs: Any change to required output files or metadata keys requires updating the versioned schemas and documenting the compatibility impact in `RELEASE_NOTES.md`. +### Summary/CSV contract updates + +- `summary.md` now uses unit-neutral timing headers +- the default human-readable CPU column is `CPU`, rendered from measured-iteration `cpu_median_ms` in milliseconds below one second and total seconds otherwise +- `results.csv` now includes `cpu_total_ms`, `cpu_median_ms`, and `peak_memory_kb` +- missing resource values are left blank in CSV output + +Any change to required output files or metadata keys requires updating the +versioned schemas and documenting the compatibility impact in `RELEASE_NOTES.md`. diff --git a/docs/guides/browserstack-ci.md b/docs/guides/browserstack-ci.md index 30f4b51..1fbcf7d 100644 --- a/docs/guides/browserstack-ci.md +++ b/docs/guides/browserstack-ci.md @@ -88,6 +88,11 @@ cargo mobench verify --target android --check-artifacts --function my_benchmark cargo mobench report summarize --summary target/mobench/ci/summary.json ``` +Standard CI outputs now carry benchmark-scoped resource metrics directly: +- `summary.md` shows `CPU` (measured-iteration `cpu_median_ms`) and `Peak memory` with unit-neutral headers +- `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, and `peak_memory_kb` +- missing resource data is emitted as blank CSV fields rather than placeholder strings + ## Quick Example ```rust diff --git a/docs/guides/browserstack-metrics.md b/docs/guides/browserstack-metrics.md index e97d86d..cfaffa2 100644 --- a/docs/guides/browserstack-metrics.md +++ b/docs/guides/browserstack-metrics.md @@ -46,6 +46,13 @@ We recursively download ALL URLs from session JSON, which typically includes: - Normalized into run summaries and CI summaries when using `--fetch` - Surfaced as summary resource fields such as `cpu_total_ms` and `peak_memory_kb` +**Benchmark-scoped resource metrics (current default):** +- Each measured iteration can emit `cpu_time_ms` and `peak_memory_kb` +- `mobench` derives `cpu_median_ms` from measured iterations only +- `summary.md` renders the default `CPU` column from `cpu_median_ms` in milliseconds below one second and total seconds otherwise +- `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, and `peak_memory_kb` +- BrowserStack aggregate memory is only used as a fallback when benchmark-scoped peak memory is absent + ### ⚠️ What We do not currently capture Based on [BrowserStack App Automate API documentation](https://www.browserstack.com/docs/app-automate/api-reference): diff --git a/docs/schemas/summary-v1.schema.json b/docs/schemas/summary-v1.schema.json index a1ba4d7..09659f0 100644 --- a/docs/schemas/summary-v1.schema.json +++ b/docs/schemas/summary-v1.schema.json @@ -52,6 +52,7 @@ "type": ["object", "null"], "properties": { "cpu_total_ms": { "type": ["integer", "null"] }, + "cpu_median_ms": { "type": ["integer", "null"] }, "peak_memory_kb": { "type": ["integer", "null"] }, "total_pss_kb": { "type": ["integer", "null"] }, "private_dirty_kb": { "type": ["integer", "null"] }, diff --git a/examples/ffi-benchmark/src/lib.rs b/examples/ffi-benchmark/src/lib.rs index 3842dec..029b87b 100644 --- a/examples/ffi-benchmark/src/lib.rs +++ b/examples/ffi-benchmark/src/lib.rs @@ -46,6 +46,8 @@ pub struct BenchSpec { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct BenchSample { pub duration_ns: u64, + pub cpu_time_ms: Option, + pub peak_memory_kb: Option, } /// A semantic phase emitted by the benchmark runner. @@ -105,6 +107,8 @@ impl From for BenchSample { fn from(sample: mobench_sdk::BenchSample) -> Self { Self { duration_ns: sample.duration_ns, + cpu_time_ms: sample.cpu_time_ms, + peak_memory_kb: sample.peak_memory_kb, } } } From 5210e11bae01a71fd28391d21e28fcd4ce1e2f93 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Sun, 12 Apr 2026 22:41:53 +0900 Subject: [PATCH 163/196] Release v0.1.31 --- Cargo.lock | 8 +-- Cargo.toml | 2 +- RELEASE_NOTES.md | 108 ++++++++++++++++++++++++++++++--- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- crates/mobench/src/lib.rs | 4 +- docs/guides/sdk-integration.md | 4 +- 7 files changed, 112 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 20ff955..9451636 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.27" +version = "0.1.31" dependencies = [ "anyhow", "clap", @@ -1071,7 +1071,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.27" +version = "0.1.31" dependencies = [ "proc-macro2", "quote", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.27" +version = "0.1.31" dependencies = [ "anyhow", "include_dir", @@ -1583,7 +1583,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.27" +version = "0.1.31" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 6795aac..544498d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.27" +version = "0.1.31" [workspace.dependencies] anyhow = "1" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7d5ede6..f2a354e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -13,9 +13,9 @@ Crates.io release history: ## Support Policy -- `v0.1.27` is the current supported release. -- `v0.1.26` is the immediately previous supported release, superseded by - `v0.1.27`. +- `v0.1.31` is the current supported release. +- `v0.1.30` is the immediately previous supported release, superseded by + `v0.1.31`. - Every earlier published version is a historical test build and should not be used. - Yanked versions are explicitly called out below. @@ -24,8 +24,12 @@ Crates.io release history: | Version | Published | Published crates | Status | |---------|-----------|------------------|--------| -| `v0.1.27` | 2026-04-01 | `mobench 0.1.27`, `mobench-sdk 0.1.27`, `mobench-macros 0.1.27` | Current supported release | -| `v0.1.26` | 2026-03-28 | `mobench 0.1.26`, `mobench-sdk 0.1.26`, `mobench-macros 0.1.26` | Superseded by `v0.1.27` | +| `v0.1.31` | 2026-04-12 | `mobench 0.1.31`, `mobench-sdk 0.1.31`, `mobench-macros 0.1.31` | Current supported release | +| `v0.1.30` | 2026-04-12 | `mobench 0.1.30`, `mobench-sdk 0.1.30`, `mobench-macros 0.1.30` | Superseded by `v0.1.31` | +| `v0.1.29` | 2026-04-04 | `mobench 0.1.29`, `mobench-sdk 0.1.29`, `mobench-macros 0.1.29` | Superseded by `v0.1.30` | +| `v0.1.28` | 2026-04-02 | `mobench 0.1.28`, `mobench-sdk 0.1.28`, `mobench-macros 0.1.28` | Superseded by `v0.1.29` | +| `v0.1.27` | 2026-04-01 | `mobench 0.1.27`, `mobench-sdk 0.1.27`, `mobench-macros 0.1.27` | Test build. Do not use. | +| `v0.1.26` | 2026-03-28 | `mobench 0.1.26`, `mobench-sdk 0.1.26`, `mobench-macros 0.1.26` | Test build. Do not use. | | `v0.1.25` | 2026-03-26 | `mobench 0.1.25`, `mobench-sdk 0.1.25`, `mobench-macros 0.1.25` | Test build. Do not use. | | `v0.1.24` | 2026-03-26 | `mobench 0.1.24`, `mobench-sdk 0.1.24`, `mobench-macros 0.1.24` | Test build. Do not use. | | `v0.1.23` | 2026-03-26 | `mobench 0.1.23`, `mobench-sdk 0.1.23`, `mobench-macros 0.1.23` | Test build. Do not use. | @@ -54,10 +58,100 @@ Crates.io release history: | `v0.1.1` | 2026-01-13 | `mobench 0.1.1`, `mobench-sdk 0.1.1` | Yanked test build. Do not use. | | `v0.1.0` | 2026-01-13 | `mobench 0.1.0`, `mobench-sdk 0.1.0`, `mobench-macros 0.1.0` | Yanked test build. Do not use. | -## v0.1.27 +## v0.1.31 Status: current supported release. +- Switched human-readable summary markdown to unit-neutral timing/resource + headers in both the CI summary path and compare markdown path, while keeping + unit-aware cell values. +- Added `cpu_total_ms`, `cpu_median_ms`, and `peak_memory_kb` directly to + standardized `results.csv`, with blank fields when resource data is absent. +- Promoted measured-iteration CPU accounting to the default human-readable + `CPU` column, computed inside the measured closure and exported as + `cpu_median_ms`. +- Rendered `CPU` in milliseconds below one second and total seconds otherwise, + without switching to decimal minutes or hours. +- Scoped peak memory to measured work by taking the memory sampler baseline + after sampler startup so harness overhead is excluded from `peak_memory_kb`. +- Updated the built-in low-spec benchmark profiles to `iPhone SE 2020` on iOS + and `Motorola Moto G9 Play` on Android, matching the validated CI device + matrix. +- Validated the updated sticky benchmark comment and Sina plots with successful + Mobile Bench workflow run `24307665713`. + +## v0.1.30 + +Status: superseded by `v0.1.31`. + +- Preserve `BenchRunner/Resources` when regenerating iOS projects so checked-in + `bench_spec.json` and `bench_meta.json` survive scaffold refreshes. +- Add configurable iOS benchmark completion timeouts through + `--ios-completion-timeout-secs` and `[browserstack].ios_completion_timeout_secs`, + and thread that value into generated XCUITest harnesses. +- Emit raw iOS benchmark resource metrics directly in the generated runner JSON, + including `resources.elapsed_cpu_ms` and `resources.peak_memory_kb`. +- Prefer explicit raw `resources.peak_memory_kb` when building summaries, so + CI outputs still carry peak memory even when BrowserStack profiling data is + absent. +- Fail BrowserStack fetch flows when timeout/error recovery produces no + benchmark payloads, instead of silently generating empty summaries. +- Resolve config-relative `device_matrix` paths from the config file directory, + matching the checked-in config template contract. +- Add regression coverage for iOS resource preservation, timeout templating, + raw iOS resource emission, config timeout parsing, config-relative device + matrices, and raw peak-memory summary extraction. + +## v0.1.29 + +Status: superseded by `v0.1.30`. + +- Preserve `app/src/main/assets` when regenerating Android projects so checked-in + benchmark assets such as `bench_spec.json` and `bench_meta.json` are not + dropped during scaffold refreshes. +- Default Android builds to `arm64-v8a` / `aarch64-linux-android` so BrowserStack + real-device CI no longer requires installing unused `armeabi-v7a` and + `x86_64` Rust/NDK targets. +- Add configurable Android ABI selection via `[android].abis` in `mobench.toml` + for projects that still need multi-ABI builds. +- Recover benchmark summaries from fetched BrowserStack artifacts after timeout + or partial completion by rehydrating results from `build.json` and + `session-*/bench-report.json` before generating summary outputs. +- Surface `CPU total (ms)` and `Peak memory` in sticky PR comments whenever the + CI summary includes resource usage, matching the data already carried in the + standardized benchmark outputs. +- Add regression coverage for preserved Android assets, configurable ABI + defaults, artifact-based summary recovery, and resource-aware PR comment + rendering. + +## v0.1.28 + +Status: superseded by `v0.1.29`. + +- Added resource-aware markdown rendering to `report summarize`, so the + sticky-comment/report path now shows benchmark resource columns instead of + dropping them. +- Switched summary CPU reporting to `CPU median (ms)` based on per-measured + iteration process CPU deltas instead of aggregate benchmark-invocation CPU. +- Added native iOS benchmark resource collection and threaded it through the + existing CI summary/report model. +- Scoped measured peak memory to the benchmark body by excluding warmup, + one-time setup/teardown, and per-iteration setup from the tracked baseline. +- Fixed Android CI reuse of stale generated UniFFI bindings and stale generated + scaffolding from cached targets, which previously caused missing results or + stale resource fields. +- Added direct Android process-memory sampling so Android emits measured + `peak_memory_kb` in raw benchmark reports instead of relying on summary-time + PSS/heap fallbacks. +- Preserved measured `peak_memory_kb = 0` as a real result so summaries no + longer replace zero-delta runs with coarse fallback memory totals. +- Validated the end-to-end CI path with successful both-platform runs + `23705449560` and `23706060742`. + +## v0.1.27 + +Status: test build. Do not use. + - Promoted `cargo mobench profile diff` from internal/demo-only code into the shipped CLI surface, including normalized diff manifests, summaries, SVGs, and an interactive viewer bundle. @@ -78,7 +172,7 @@ Status: current supported release. ## v0.1.26 -Status: superseded by `v0.1.27`. +Status: test build. Do not use. - Published a synchronized `mobench`, `mobench-sdk`, and `mobench-macros` release so the registry dependency graph matches the current profiling and diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 4f57d22..0ee5361 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.27", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.31", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 2dbd285..c5973f7 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -37,7 +37,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.27", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.31", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index b7eca73..e5173d9 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -9294,7 +9294,7 @@ project = "proj" assert!(markdown.contains("Peak memory")); assert!(!markdown.contains("(ms)")); assert!(!markdown.contains("CPU total")); - assert!(markdown.contains("0.241s")); + assert!(markdown.contains("241ms")); assert!(markdown.contains("638.99 MB")); } @@ -9335,7 +9335,7 @@ project = "proj" assert!(markdown.contains("Peak memory")); assert!(!markdown.contains("(ms)")); assert!(!markdown.contains("CPU total")); - assert!(markdown.contains("0.241s")); + assert!(markdown.contains("241ms")); assert!(markdown.contains("638.99 MB")); } diff --git a/docs/guides/sdk-integration.md b/docs/guides/sdk-integration.md index 956ee02..acc78b4 100644 --- a/docs/guides/sdk-integration.md +++ b/docs/guides/sdk-integration.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.27" +mobench-sdk = "0.1.31" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.27" +mobench-sdk = "0.1.31" ``` ## 3) Annotate benchmark functions From 712d4213bdbb53eab77ffae562099f83053c30fd Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 17 Apr 2026 03:44:51 +0200 Subject: [PATCH 164/196] Fix 0.1.31 CI compatibility regressions (#24) * fix: restore 0.1.31 ci compatibility * fix: run iOS harness off the main actor * fix: honor iOS benchmark timeout in XCUITest harness * fix: thread layout iOS timeout into builds * fix: bundle iOS bench spec from target dir * fix: prepare 0.1.32 ci compatibility release * fix: measure process cpu time across all threads * report: clarify CI summary wall and CPU columns * ci: allow full mobile bench runs to pin mobench ref --- .github/workflows/mobile-bench-selftest.yml | 10 + .github/workflows/mobile-bench.yml | 12 + .github/workflows/reusable-bench.yml | 38 +- Cargo.lock | 8 +- Cargo.toml | 2 +- RELEASE_NOTES.md | 33 +- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench-sdk/src/builders/android.rs | 111 +++- crates/mobench-sdk/src/codegen.rs | 238 +++++++++ crates/mobench-sdk/src/timing.rs | 120 ++++- crates/mobench-sdk/src/types.rs | 4 + .../BenchRunner/ContentView.swift.template | 10 +- .../BenchRunnerUITests.swift.template | 14 +- crates/mobench/Cargo.toml | 2 +- crates/mobench/src/config.rs | 30 +- crates/mobench/src/lib.rs | 494 +++++++++++++++--- crates/mobench/src/profile.rs | 4 +- docs/guides/sdk-integration.md | 4 +- ios/BenchRunner/BenchRunner/ContentView.swift | 10 +- .../BenchRunnerUITests.swift | 14 +- .../BenchRunner/ContentView.swift.template | 10 +- .../BenchRunnerUITests.swift.template | 14 +- 22 files changed, 1025 insertions(+), 159 deletions(-) diff --git a/.github/workflows/mobile-bench-selftest.yml b/.github/workflows/mobile-bench-selftest.yml index ea37f8c..0e14ee1 100644 --- a/.github/workflows/mobile-bench-selftest.yml +++ b/.github/workflows/mobile-bench-selftest.yml @@ -19,6 +19,14 @@ on: description: "Number of warmup iterations" type: string default: "1" + mobench_version: + description: "Mobench version to install from crates.io when mobench_ref is unset" + type: string + default: "" + mobench_ref: + description: "Git ref for mobile-bench-rs to install instead of the in-repo path" + type: string + default: "" permissions: actions: read @@ -36,6 +44,8 @@ jobs: warmup: ${{ inputs.warmup }} device_profile: low-spec build_release: true + mobench_version: ${{ inputs.mobench_version }} + mobench_ref: ${{ inputs.mobench_ref }} secrets: BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index 7760f3a..1b7a6b1 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -37,6 +37,16 @@ on: required: false type: string default: "5" + mobench_version: + description: "Mobench version to install from crates.io when mobench_ref is unset" + required: false + type: string + default: "" + mobench_ref: + description: "Git ref for mobile-bench-rs to install instead of the caller path or crates.io version" + required: false + type: string + default: "" pr_number: description: "PR number for sticky comment publishing" required: false @@ -77,6 +87,8 @@ jobs: device_profile: ${{ inputs.device_profile }} iterations: ${{ inputs.iterations }} warmup: ${{ inputs.warmup }} + mobench_version: ${{ inputs.mobench_version }} + mobench_ref: ${{ inputs.mobench_ref }} build_release: true pr_number: ${{ inputs.pr_number }} requested_by: ${{ inputs.requested_by }} diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 5f6bdff..20c678d 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -66,7 +66,7 @@ on: description: "Mobench version to install" required: false type: string - default: "0.1.21" + default: "0.1.32" mobench_ref: description: "Git ref for mobile-bench-rs (overrides mobench_version when set)" required: false @@ -176,23 +176,29 @@ jobs: MOBENCH_REF: ${{ inputs.mobench_ref }} run: | set -euo pipefail - if [ -f caller/crates/mobench/Cargo.toml ] && grep -Eq '^name\s*=\s*"mobench"' caller/crates/mobench/Cargo.toml; then - cargo install --path caller/crates/mobench --locked --force - elif [ -n "$MOBENCH_REF" ] && printf '%s' "$MOBENCH_REF" | grep -Eq '^[0-9a-fA-F]{40}$'; then + if [ -n "$MOBENCH_REF" ] && printf '%s' "$MOBENCH_REF" | grep -Eq '^[0-9a-fA-F]{40}$'; then + echo "Installing mobench from git revision ${MOBENCH_REF}" cargo install mobench \ --git https://github.com/worldcoin/mobile-bench-rs \ --rev "$MOBENCH_REF" \ --locked elif [ -n "$MOBENCH_REF" ]; then + echo "Installing mobench from git branch ${MOBENCH_REF}" cargo install mobench \ --git https://github.com/worldcoin/mobile-bench-rs \ --branch "$MOBENCH_REF" \ --locked elif [ -n "$MOBENCH_VERSION" ]; then + echo "Installing mobench ${MOBENCH_VERSION} from crates.io" cargo install mobench --version "$MOBENCH_VERSION" --locked + elif [ -f caller/crates/mobench/Cargo.toml ] && grep -Eq '^name\s*=\s*"mobench"' caller/crates/mobench/Cargo.toml; then + echo "Installing mobench from caller/crates/mobench" + cargo install --path caller/crates/mobench --locked --force else + echo "Installing latest mobench from crates.io" cargo install mobench --locked fi + mobench --version - name: Install uniffi-bindgen shell: bash @@ -426,23 +432,29 @@ jobs: MOBENCH_REF: ${{ inputs.mobench_ref }} run: | set -euo pipefail - if [ -f caller/crates/mobench/Cargo.toml ] && grep -Eq '^name\s*=\s*"mobench"' caller/crates/mobench/Cargo.toml; then - cargo install --path caller/crates/mobench --locked --force - elif [ -n "$MOBENCH_REF" ] && printf '%s' "$MOBENCH_REF" | grep -Eq '^[0-9a-fA-F]{40}$'; then + if [ -n "$MOBENCH_REF" ] && printf '%s' "$MOBENCH_REF" | grep -Eq '^[0-9a-fA-F]{40}$'; then + echo "Installing mobench from git revision ${MOBENCH_REF}" cargo install mobench \ --git https://github.com/worldcoin/mobile-bench-rs \ --rev "$MOBENCH_REF" \ --locked elif [ -n "$MOBENCH_REF" ]; then + echo "Installing mobench from git branch ${MOBENCH_REF}" cargo install mobench \ --git https://github.com/worldcoin/mobile-bench-rs \ --branch "$MOBENCH_REF" \ --locked elif [ -n "$MOBENCH_VERSION" ]; then + echo "Installing mobench ${MOBENCH_VERSION} from crates.io" cargo install mobench --version "$MOBENCH_VERSION" --locked + elif [ -f caller/crates/mobench/Cargo.toml ] && grep -Eq '^name\s*=\s*"mobench"' caller/crates/mobench/Cargo.toml; then + echo "Installing mobench from caller/crates/mobench" + cargo install --path caller/crates/mobench --locked --force else + echo "Installing latest mobench from crates.io" cargo install mobench --locked fi + mobench --version - name: Install uniffi-bindgen shell: bash @@ -578,23 +590,29 @@ jobs: MOBENCH_REF: ${{ inputs.mobench_ref }} run: | set -euo pipefail - if [ -f caller/crates/mobench/Cargo.toml ] && grep -Eq '^name\s*=\s*"mobench"' caller/crates/mobench/Cargo.toml; then - cargo install --path caller/crates/mobench --locked --force - elif [ -n "$MOBENCH_REF" ] && printf '%s' "$MOBENCH_REF" | grep -Eq '^[0-9a-fA-F]{40}$'; then + if [ -n "$MOBENCH_REF" ] && printf '%s' "$MOBENCH_REF" | grep -Eq '^[0-9a-fA-F]{40}$'; then + echo "Installing mobench from git revision ${MOBENCH_REF}" cargo install mobench \ --git https://github.com/worldcoin/mobile-bench-rs \ --rev "$MOBENCH_REF" \ --locked elif [ -n "$MOBENCH_REF" ]; then + echo "Installing mobench from git branch ${MOBENCH_REF}" cargo install mobench \ --git https://github.com/worldcoin/mobile-bench-rs \ --branch "$MOBENCH_REF" \ --locked elif [ -n "$MOBENCH_VERSION" ]; then + echo "Installing mobench ${MOBENCH_VERSION} from crates.io" cargo install mobench --version "$MOBENCH_VERSION" --locked + elif [ -f caller/crates/mobench/Cargo.toml ] && grep -Eq '^name\s*=\s*"mobench"' caller/crates/mobench/Cargo.toml; then + echo "Installing mobench from caller/crates/mobench" + cargo install --path caller/crates/mobench --locked --force else + echo "Installing latest mobench from crates.io" cargo install mobench --locked fi + mobench --version - name: Download iOS results if: needs.ios.result == 'success' diff --git a/Cargo.lock b/Cargo.lock index 9451636..b17c5a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.31" +version = "0.1.32" dependencies = [ "anyhow", "clap", @@ -1071,7 +1071,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.31" +version = "0.1.32" dependencies = [ "proc-macro2", "quote", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.31" +version = "0.1.32" dependencies = [ "anyhow", "include_dir", @@ -1583,7 +1583,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.31" +version = "0.1.32" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 544498d..41ef61f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.31" +version = "0.1.32" [workspace.dependencies] anyhow = "1" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f2a354e..e5de085 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -13,9 +13,9 @@ Crates.io release history: ## Support Policy -- `v0.1.31` is the current supported release. -- `v0.1.30` is the immediately previous supported release, superseded by - `v0.1.31`. +- `v0.1.32` is the current supported release. +- `v0.1.31` is the immediately previous supported release, superseded by + `v0.1.32`. - Every earlier published version is a historical test build and should not be used. - Yanked versions are explicitly called out below. @@ -24,7 +24,8 @@ Crates.io release history: | Version | Published | Published crates | Status | |---------|-----------|------------------|--------| -| `v0.1.31` | 2026-04-12 | `mobench 0.1.31`, `mobench-sdk 0.1.31`, `mobench-macros 0.1.31` | Current supported release | +| `v0.1.32` | 2026-04-14 | `mobench 0.1.32`, `mobench-sdk 0.1.32`, `mobench-macros 0.1.32` | Current supported release | +| `v0.1.31` | 2026-04-12 | `mobench 0.1.31`, `mobench-sdk 0.1.31`, `mobench-macros 0.1.31` | Superseded by `v0.1.32` | | `v0.1.30` | 2026-04-12 | `mobench 0.1.30`, `mobench-sdk 0.1.30`, `mobench-macros 0.1.30` | Superseded by `v0.1.31` | | `v0.1.29` | 2026-04-04 | `mobench 0.1.29`, `mobench-sdk 0.1.29`, `mobench-macros 0.1.29` | Superseded by `v0.1.30` | | `v0.1.28` | 2026-04-02 | `mobench 0.1.28`, `mobench-sdk 0.1.28`, `mobench-macros 0.1.28` | Superseded by `v0.1.29` | @@ -58,10 +59,32 @@ Crates.io release history: | `v0.1.1` | 2026-01-13 | `mobench 0.1.1`, `mobench-sdk 0.1.1` | Yanked test build. Do not use. | | `v0.1.0` | 2026-01-13 | `mobench 0.1.0`, `mobench-sdk 0.1.0`, `mobench-macros 0.1.0` | Yanked test build. Do not use. | -## v0.1.31 +## v0.1.32 Status: current supported release. +- Restored Android ABI selection end to end after the `0.1.31` regression, so + `[android].abis` now drives dry-run output, native builds, JNI library copy, + and validation instead of silently rebuilding unsupported defaults. +- Restored `--ios-completion-timeout-secs` as a deprecated compatibility flag + and threaded `[browserstack].ios_completion_timeout_secs` from config into + generated iOS builds and the XCUITest harness wait logic. +- Moved generated iOS benchmark execution off the main actor while keeping UI + updates on `MainActor`, preventing the runner from stalling during longer + benchmark sessions. +- Preserved `BenchRunner/Resources` across generated iOS project refreshes so + `bench_spec.json` and companion metadata survive scaffold regeneration and + downstream CI runs execute the requested benchmark spec instead of falling + back to scaffold defaults. +- Made reusable workflow installs prefer explicit `mobench_ref` or + `mobench_version` overrides before the repo-local path, and exposed those + knobs in the self-test workflow so release-candidate validation matches + downstream installation behavior. + +## v0.1.31 + +Status: superseded by `v0.1.32`. + - Switched human-readable summary markdown to unit-neutral timing/resource headers in both the CI summary path and compare markdown path, while keeping unit-aware cell values. diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 0ee5361..a87f53c 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.31", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.32", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index 3b23a23..57263a0 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -18,7 +18,8 @@ //! //! - Android NDK (set `ANDROID_NDK_HOME` environment variable) //! - `cargo-ndk` (`cargo install cargo-ndk`) -//! - Rust targets: `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` +//! - Rust targets: `aarch64-linux-android` by default +//! - Optional extra targets can be enabled via `BuildConfig::android_abis` //! - Java JDK (for Gradle) //! //! ## Example @@ -35,6 +36,7 @@ //! target: Target::Android, //! profile: BuildProfile::Release, //! incremental: true, +//! android_abis: None, //! }; //! //! let result = builder.build(&config)?; @@ -84,6 +86,7 @@ use std::process::Command; /// target: Target::Android, /// profile: BuildProfile::Release, /// incremental: true, +/// android_abis: None, /// }; /// /// let result = builder.build(&config)?; @@ -104,6 +107,8 @@ pub struct AndroidBuilder { dry_run: bool, } +const DEFAULT_ANDROID_ABIS: &[&str] = &["arm64-v8a"]; + impl AndroidBuilder { /// Creates a new Android builder /// @@ -185,6 +190,7 @@ impl AndroidBuilder { BuildProfile::Debug => "debug", BuildProfile::Release => "release", }; + let android_abis = self.resolve_android_abis(config)?; if self.dry_run { println!("\n[dry-run] Android build plan:"); @@ -194,7 +200,8 @@ impl AndroidBuilder { ); println!(" Step 0.5: Ensure Gradle wrapper exists (run 'gradle wrapper' if needed)"); println!( - " Step 1: Build Rust libraries for Android ABIs (arm64-v8a, armeabi-v7a, x86_64)" + " Step 1: Build Rust libraries for Android ABIs ({})", + android_abis.join(", ") ); println!( " Command: cargo ndk --target --platform 24 build {}", @@ -326,7 +333,7 @@ impl AndroidBuilder { // Check that at least one native library exists in jniLibs let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs"); let lib_name = format!("lib{}.so", self.crate_name.replace("-", "_")); - let required_abis = ["arm64-v8a", "armeabi-v7a", "x86_64"]; + let required_abis = self.resolve_android_abis(config)?; let mut found_libs = 0; for abi in &required_abis { let lib_path = jni_libs_dir.join(abi).join(&lib_name); @@ -371,6 +378,34 @@ impl AndroidBuilder { Ok(()) } + fn resolve_android_abis(&self, config: &BuildConfig) -> Result, BenchError> { + let requested = config + .android_abis + .as_ref() + .filter(|abis| !abis.is_empty()) + .cloned() + .unwrap_or_else(|| { + DEFAULT_ANDROID_ABIS + .iter() + .map(|abi| (*abi).to_string()) + .collect() + }); + + let mut resolved = Vec::new(); + for abi in requested { + if android_abi_to_rust_target(&abi).is_none() { + return Err(BenchError::Build(format!( + "Unsupported Android ABI '{abi}'. Supported values: arm64-v8a, armeabi-v7a, x86_64" + ))); + } + if !resolved.contains(&abi) { + resolved.push(abi); + } + } + + Ok(resolved) + } + /// Finds the benchmark crate directory. /// /// Search order: @@ -457,8 +492,7 @@ impl AndroidBuilder { // Check if cargo-ndk is installed self.check_cargo_ndk()?; - // Android ABIs to build for - let abis = vec!["arm64-v8a", "armeabi-v7a", "x86_64"]; + let abis = self.resolve_android_abis(config)?; let release_flag = if matches!(config.profile, BuildProfile::Release) { "--release" } else { @@ -473,7 +507,7 @@ impl AndroidBuilder { let mut cmd = Command::new("cargo"); cmd.arg("ndk") .arg("--target") - .arg(abi) + .arg(&abi) .arg("--platform") .arg("24") // minSdk .arg("build"); @@ -519,12 +553,7 @@ impl AndroidBuilder { } else { "debug" }; - let rust_target = match abi { - "arm64-v8a" => "aarch64-linux-android", - "armeabi-v7a" => "armv7-linux-androideabi", - "x86_64" => "x86_64-linux-android", - _ => abi, - }; + let rust_target = android_abi_to_rust_target(&abi).unwrap_or(abi.as_str()); return Err(BenchError::Build(format!( "cargo-ndk build failed for {} ({} profile).\n\n\ Command: {}\n\ @@ -704,22 +733,21 @@ impl AndroidBuilder { )) })?; - // Map cargo-ndk ABIs to Android jniLibs ABIs - let abi_mappings = vec![ - ("aarch64-linux-android", "arm64-v8a"), - ("armv7-linux-androideabi", "armeabi-v7a"), - ("x86_64-linux-android", "x86_64"), - ]; let mut native_libraries = Vec::new(); - for (rust_target, android_abi) in abi_mappings { + for android_abi in self.resolve_android_abis(config)? { + let rust_target = android_abi_to_rust_target(&android_abi).ok_or_else(|| { + BenchError::Build(format!( + "Unsupported Android ABI '{android_abi}'. Supported values: arm64-v8a, armeabi-v7a, x86_64" + )) + })?; let library_name = format!("lib{}.so", self.crate_name.replace("-", "_")); let src = target_dir .join(rust_target) .join(profile_dir) .join(&library_name); - let dest_dir = jni_libs_dir.join(android_abi); + let dest_dir = jni_libs_dir.join(&android_abi); std::fs::create_dir_all(&dest_dir).map_err(|e| { BenchError::Build(format!( "Failed to create ABI directory {} at {}: {}. Check output directory permissions.", @@ -747,7 +775,7 @@ impl AndroidBuilder { } native_libraries.push(NativeLibraryArtifact { - abi: android_abi.to_string(), + abi: android_abi.clone(), library_name: library_name.clone(), unstripped_path: src, packaged_path: dest, @@ -1262,6 +1290,15 @@ impl AndroidBuilder { } } +fn android_abi_to_rust_target(abi: &str) -> Option<&'static str> { + match abi { + "arm64-v8a" => Some("aarch64-linux-android"), + "armeabi-v7a" => Some("armv7-linux-androideabi"), + "x86_64" => Some("x86_64-linux-android"), + _ => None, + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AndroidStackSymbolization { pub line: String, @@ -1484,6 +1521,38 @@ mod tests { assert_eq!(result, None); } + #[test] + fn test_android_builder_defaults_to_arm64_only() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + let config = BuildConfig { + target: Target::Android, + profile: BuildProfile::Debug, + incremental: true, + android_abis: None, + }; + + let abis = builder + .resolve_android_abis(&config) + .expect("resolve default ABIs"); + assert_eq!(abis, vec!["arm64-v8a".to_string()]); + } + + #[test] + fn test_android_builder_uses_explicit_abis_when_configured() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + let config = BuildConfig { + target: Target::Android, + profile: BuildProfile::Release, + incremental: true, + android_abis: Some(vec!["arm64-v8a".to_string(), "x86_64".to_string()]), + }; + + let abis = builder + .resolve_android_abis(&config) + .expect("resolve configured ABIs"); + assert_eq!(abis, vec!["arm64-v8a".to_string(), "x86_64".to_string()]); + } + #[test] fn android_native_offsets_are_symbolized_into_rust_frames() { let input = "dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1"; diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index f142f50..19f8120 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -12,6 +12,7 @@ use include_dir::{Dir, DirEntry, include_dir}; const ANDROID_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/android"); const IOS_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/ios"); +const DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300; /// Template variable that can be replaced in template files #[derive(Debug, Clone)] @@ -385,6 +386,68 @@ pub fn generate_android_project( Ok(()) } +fn collect_preserved_files( + root: &Path, + current: &Path, + preserved: &mut Vec<(PathBuf, Vec)>, +) -> Result<(), BenchError> { + let mut entries = fs::read_dir(current)? + .collect::, _>>() + .map_err(BenchError::Io)?; + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { + let path = entry.path(); + if path.is_dir() { + collect_preserved_files(root, &path, preserved)?; + continue; + } + + let relative = path.strip_prefix(root).map_err(|e| { + BenchError::Build(format!( + "Failed to preserve generated resource {:?}: {}", + path, e + )) + })?; + preserved.push((relative.to_path_buf(), fs::read(&path)?)); + } + + Ok(()) +} + +fn collect_preserved_ios_resources( + target_dir: &Path, +) -> Result)>, BenchError> { + let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources"); + let mut preserved = Vec::new(); + + if resources_dir.exists() { + collect_preserved_files(&resources_dir, &resources_dir, &mut preserved)?; + } + + Ok(preserved) +} + +fn restore_preserved_ios_resources( + target_dir: &Path, + preserved_resources: &[(PathBuf, Vec)], +) -> Result<(), BenchError> { + if preserved_resources.is_empty() { + return Ok(()); + } + + let resources_dir = target_dir.join("BenchRunner/BenchRunner/Resources"); + for (relative, contents) in preserved_resources { + let resource_path = resources_dir.join(relative); + if let Some(parent) = resource_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(resource_path, contents)?; + } + + Ok(()) +} + fn reset_generated_project_dir(target_dir: &Path) -> Result<(), BenchError> { if target_dir.exists() { fs::remove_dir_all(target_dir).map_err(|e| { @@ -480,8 +543,32 @@ pub fn generate_ios_project( project_pascal: &str, bundle_prefix: &str, default_function: &str, +) -> Result<(), BenchError> { + let ios_benchmark_timeout_secs = resolve_ios_benchmark_timeout_secs( + std::env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS") + .ok() + .as_deref(), + ); + generate_ios_project_with_timeout( + output_dir, + project_slug, + project_pascal, + bundle_prefix, + default_function, + ios_benchmark_timeout_secs, + ) +} + +fn generate_ios_project_with_timeout( + output_dir: &Path, + project_slug: &str, + project_pascal: &str, + bundle_prefix: &str, + default_function: &str, + ios_benchmark_timeout_secs: u64, ) -> Result<(), BenchError> { let target_dir = output_dir.join("ios"); + let preserved_resources = collect_preserved_ios_resources(&target_dir)?; reset_generated_project_dir(&target_dir)?; // Sanitize bundle ID components to ensure they only contain alphanumeric characters // iOS bundle identifiers should not contain hyphens or underscores @@ -517,11 +604,23 @@ pub fn generate_ios_project( name: "LIBRARY_NAME", value: project_slug.replace('-', "_"), }, + TemplateVar { + name: "IOS_BENCHMARK_TIMEOUT_SECS", + value: ios_benchmark_timeout_secs.to_string(), + }, ]; render_dir(&IOS_TEMPLATES, &target_dir, &vars)?; + restore_preserved_ios_resources(&target_dir, &preserved_resources)?; Ok(()) } +fn resolve_ios_benchmark_timeout_secs(value: Option<&str>) -> u64 { + value + .and_then(|raw| raw.parse::().ok()) + .filter(|secs| *secs > 0) + .unwrap_or(DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS) +} + /// Generates bench-config.toml configuration file fn generate_config_file(output_dir: &Path, config: &InitConfig) -> Result<(), BenchError> { let config_target = match config.target { @@ -1530,6 +1629,60 @@ pub fn public_bench() { fs::remove_dir_all(&temp_dir).ok(); } + #[test] + fn test_generate_ios_project_preserves_existing_resources_on_regeneration() { + let temp_dir = env::temp_dir().join("mobench-sdk-ios-resources-regenerate-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + generate_ios_project( + &temp_dir, + "bench_mobile", + "BenchRunner", + "dev.world.benchmobile", + "bench_mobile::bench_prepare", + ) + .unwrap(); + + let resources_dir = temp_dir.join("ios/BenchRunner/BenchRunner/Resources"); + fs::create_dir_all(resources_dir.join("nested")).unwrap(); + fs::write( + resources_dir.join("bench_spec.json"), + r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"#, + ) + .unwrap(); + fs::write( + resources_dir.join("bench_meta.json"), + r#"{"build_id":"build-123"}"#, + ) + .unwrap(); + fs::write(resources_dir.join("nested/custom.txt"), "keep me").unwrap(); + + generate_ios_project( + &temp_dir, + "bench_mobile", + "BenchRunner", + "dev.world.benchmobile", + "bench_mobile::bench_prepare", + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(resources_dir.join("bench_spec.json")).unwrap(), + r#"{"function":"bench_mobile::bench_prove","iterations":2,"warmup":1}"# + ); + assert_eq!( + fs::read_to_string(resources_dir.join("bench_meta.json")).unwrap(), + r#"{"build_id":"build-123"}"# + ); + assert_eq!( + fs::read_to_string(resources_dir.join("nested/custom.txt")).unwrap(), + "keep me" + ); + + fs::remove_dir_all(&temp_dir).ok(); + } + #[test] fn test_ensure_ios_project_refreshes_existing_content_view_template() { let temp_dir = env::temp_dir().join("mobench-sdk-ios-refresh-test"); @@ -1558,10 +1711,95 @@ pub fn public_bench() { "refreshed ContentView.swift should contain repeat-until profiling support, got:\n{}", refreshed ); + assert!( + refreshed.contains("Task.detached(priority: .userInitiated)"), + "refreshed ContentView.swift should run benchmarks off the main actor, got:\n{}", + refreshed + ); + assert!( + refreshed.contains("await MainActor.run"), + "refreshed ContentView.swift should apply UI updates on the main actor, got:\n{}", + refreshed + ); + + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_ensure_ios_project_refreshes_existing_ui_test_timeout_template() { + let temp_dir = env::temp_dir().join("mobench-sdk-ios-uitest-refresh-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None) + .expect("initial iOS project generation should succeed"); + + let ui_test_path = + temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift"); + assert!( + ui_test_path.exists(), + "BenchRunnerUITests.swift should exist" + ); + + fs::write(&ui_test_path, "stale generated content").unwrap(); + + ensure_ios_project_with_options(&temp_dir, "sample-fns", None, None) + .expect("refreshing existing iOS project should succeed"); + + let refreshed = fs::read_to_string(&ui_test_path).unwrap(); + assert!( + refreshed.contains("private let defaultBenchmarkTimeout: TimeInterval = 300.0"), + "refreshed BenchRunnerUITests.swift should include the default timeout, got:\n{}", + refreshed + ); + assert!( + refreshed.contains( + "ProcessInfo.processInfo.environment[\"MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS\"]" + ), + "refreshed BenchRunnerUITests.swift should honor runtime timeout overrides, got:\n{}", + refreshed + ); + + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_generate_ios_project_uses_configured_benchmark_timeout() { + let temp_dir = env::temp_dir().join("mobench-sdk-ios-timeout-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let result = generate_ios_project_with_timeout( + &temp_dir, + "sample_fns", + "BenchRunner", + "dev.world.samplefns", + "sample_fns::example_benchmark", + 1200, + ); + + assert!(result.is_ok(), "generate_ios_project should succeed"); + + let ui_test_path = + temp_dir.join("ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift"); + let contents = fs::read_to_string(&ui_test_path).unwrap(); + assert!( + contents.contains("private let defaultBenchmarkTimeout: TimeInterval = 1200.0"), + "generated BenchRunnerUITests.swift should embed the configured timeout, got:\n{}", + contents + ); fs::remove_dir_all(&temp_dir).ok(); } + #[test] + fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() { + assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300); + assert_eq!(resolve_ios_benchmark_timeout_secs(Some("900")), 900); + assert_eq!(resolve_ios_benchmark_timeout_secs(Some("0")), 300); + assert_eq!(resolve_ios_benchmark_timeout_secs(Some("bogus")), 300); + } + #[test] fn test_cross_platform_naming_consistency() { // Test that Android and iOS use the same naming convention for package/bundle IDs diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index aa02b79..176ed8a 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -582,8 +582,28 @@ trait ResourceMonitor { #[derive(Default)] struct DefaultResourceMonitor; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ProcessCpuTimeSnapshot { + user_ns: u64, + system_ns: u64, +} + +impl ProcessCpuTimeSnapshot { + #[cfg(unix)] + fn from_rusage_timevals(user: libc::timeval, system: libc::timeval) -> Option { + Some(Self { + user_ns: timeval_to_ns(user)?, + system_ns: timeval_to_ns(system)?, + }) + } + + fn total_ns(self) -> u64 { + self.user_ns.saturating_add(self.system_ns) + } +} + struct DefaultResourceToken { - cpu_time_start_ns: Option, + cpu_time_start: Option, memory_sampler: Option, } @@ -592,18 +612,16 @@ impl ResourceMonitor for DefaultResourceMonitor { fn start(&mut self) -> Self::Token { Self::Token { - cpu_time_start_ns: current_thread_cpu_time_ns(), + cpu_time_start: current_process_cpu_time(), memory_sampler: MemoryPeakSampler::start(), } } fn finish(&mut self, token: Self::Token) -> IterationResourceUsage { - let cpu_time_ms = match (token.cpu_time_start_ns, current_thread_cpu_time_ns()) { - (Some(start_ns), Some(end_ns)) if end_ns >= start_ns => { - Some(round_ns_to_ms(end_ns - start_ns)) - } - _ => None, - }; + let cpu_time_ms = token + .cpu_time_start + .zip(current_process_cpu_time()) + .and_then(|(start, end)| process_cpu_delta_ms(start, end)); IterationResourceUsage { cpu_time_ms, @@ -620,21 +638,44 @@ fn round_ns_to_ms(ns: u64) -> u64 { } #[cfg(unix)] -fn current_thread_cpu_time_ns() -> Option { - let mut ts = std::mem::MaybeUninit::::uninit(); - let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, ts.as_mut_ptr()) }; +fn process_cpu_delta_ms(start: ProcessCpuTimeSnapshot, end: ProcessCpuTimeSnapshot) -> Option { + Some(round_ns_to_ms( + end.total_ns().checked_sub(start.total_ns())?, + )) +} + +#[cfg(not(unix))] +fn process_cpu_delta_ms( + _start: ProcessCpuTimeSnapshot, + _end: ProcessCpuTimeSnapshot, +) -> Option { + None +} + +#[cfg(unix)] +fn timeval_to_ns(value: libc::timeval) -> Option { + let secs = u64::try_from(value.tv_sec).ok()?; + let micros = u64::try_from(value.tv_usec).ok()?; + Some( + secs.saturating_mul(1_000_000_000) + .saturating_add(micros.saturating_mul(1_000)), + ) +} + +#[cfg(unix)] +fn current_process_cpu_time() -> Option { + let mut usage = std::mem::MaybeUninit::::uninit(); + let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; if rc != 0 { return None; } - let ts = unsafe { ts.assume_init() }; - let secs = u64::try_from(ts.tv_sec).ok()?; - let nanos = u64::try_from(ts.tv_nsec).ok()?; - Some(secs.saturating_mul(1_000_000_000).saturating_add(nanos)) + let usage = unsafe { usage.assume_init() }; + ProcessCpuTimeSnapshot::from_rusage_timevals(usage.ru_utime, usage.ru_stime) } #[cfg(not(unix))] -fn current_thread_cpu_time_ns() -> Option { +fn current_process_cpu_time() -> Option { None } @@ -1428,6 +1469,53 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn process_cpu_time_snapshot_sums_user_and_kernel_time() { + let snapshot = ProcessCpuTimeSnapshot::from_rusage_timevals( + libc::timeval { + tv_sec: 1, + tv_usec: 250_000, + }, + libc::timeval { + tv_sec: 0, + tv_usec: 750_000, + }, + ) + .expect("valid snapshot"); + + assert_eq!(snapshot.total_ns(), 2_000_000_000); + } + + #[cfg(unix)] + #[test] + fn process_cpu_time_delta_ms_uses_user_and_kernel_time() { + let start = ProcessCpuTimeSnapshot::from_rusage_timevals( + libc::timeval { + tv_sec: 1, + tv_usec: 250_000, + }, + libc::timeval { + tv_sec: 0, + tv_usec: 750_000, + }, + ) + .expect("valid start snapshot"); + let end = ProcessCpuTimeSnapshot::from_rusage_timevals( + libc::timeval { + tv_sec: 1, + tv_usec: 900_000, + }, + libc::timeval { + tv_sec: 1, + tv_usec: 400_600, + }, + ) + .expect("valid end snapshot"); + + assert_eq!(process_cpu_delta_ms(start, end), Some(1_301)); + } + #[test] fn runs_benchmark_collects_requested_samples() { let spec = BenchSpec::new("noop", 3, 1).unwrap(); diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs index 29b5812..2346c5d 100644 --- a/crates/mobench-sdk/src/types.rs +++ b/crates/mobench-sdk/src/types.rs @@ -193,6 +193,7 @@ pub struct InitConfig { /// target: Target::Android, /// profile: BuildProfile::Release, /// incremental: true, +/// android_abis: None, /// }; /// /// // Debug build for iOS @@ -200,6 +201,7 @@ pub struct InitConfig { /// target: Target::Ios, /// profile: BuildProfile::Debug, /// incremental: false, // Force rebuild +/// android_abis: None, /// }; /// ``` #[derive(Debug, Clone)] @@ -210,6 +212,8 @@ pub struct BuildConfig { pub profile: BuildProfile, /// If `true`, skip rebuilding if artifacts already exist. pub incremental: bool, + /// Optional Android ABIs to build/package. Defaults to `["arm64-v8a"]`. + pub android_abis: Option>, } /// Build profile controlling optimization and debug info. diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template index cbdb8de..fda37ac 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template @@ -89,7 +89,7 @@ struct ContentView: View { } } .onAppear { - Task { + Task.detached(priority: .userInitiated) { let options = ProfileLaunchOptions.resolved() if options.benchDelayMs > 0 { try? await Task.sleep(nanoseconds: options.benchDelayMs * 1_000_000) @@ -103,9 +103,11 @@ struct ContentView: View { result = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() repeatedRuns += 1 } - report = result.displayText - reportJSON = result.jsonReport - isCompleted = true + await MainActor.run { + report = result.displayText + reportJSON = result.jsonReport + isCompleted = true + } // Log the JSON report with stable markers so local profiling tools, // XCUITest, and BrowserStack fetch paths can all recover the payload. diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template index fc0e8df..f8ca532 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template @@ -3,7 +3,19 @@ import XCTest final class {{PROJECT_NAME_PASCAL}}UITests: XCTestCase { /// Maximum time to wait for benchmark completion (5 minutes for long benchmarks) - private let benchmarkTimeout: TimeInterval = 300.0 + private let defaultBenchmarkTimeout: TimeInterval = {{IOS_BENCHMARK_TIMEOUT_SECS}}.0 + + private var benchmarkTimeout: TimeInterval { + if let configuredTimeout = + ProcessInfo.processInfo.environment["MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS"], + let parsedTimeout = TimeInterval(configuredTimeout), + parsedTimeout > 0 + { + return parsedTimeout + } + + return defaultBenchmarkTimeout + } func testLaunchAndCaptureBenchmarkReport() { let app = XCUIApplication() diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index c5973f7..21209f2 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -37,7 +37,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.31", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.32", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/src/config.rs b/crates/mobench/src/config.rs index 9901c36..910deda 100644 --- a/crates/mobench/src/config.rs +++ b/crates/mobench/src/config.rs @@ -56,6 +56,9 @@ pub struct MobenchConfig { /// Benchmark execution defaults. pub benchmarks: BenchmarksConfig, + + /// Optional BrowserStack-specific configuration. + pub browserstack: BrowserStackConfig, } /// Project-level configuration. @@ -102,7 +105,7 @@ pub struct AndroidConfig { /// Android ABIs to build for. /// - /// Defaults to ["arm64-v8a", "armeabi-v7a", "x86_64"]. + /// Defaults to ["arm64-v8a"]. pub abis: Option>, } @@ -177,6 +180,14 @@ impl Default for BenchmarksConfig { } } +/// BrowserStack-specific configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct BrowserStackConfig { + /// Timeout in seconds for the generated iOS XCUITest harness to wait for benchmark completion. + pub ios_completion_timeout_secs: Option, +} + impl MobenchConfig { /// Creates a new configuration with default values. pub fn new() -> Self { @@ -315,6 +326,7 @@ impl MobenchConfig { default_iterations: 100, default_warmup: 10, }, + browserstack: BrowserStackConfig::default(), } } @@ -358,8 +370,8 @@ min_sdk = 24 # Target Android SDK version (default: 34 / Android 14) target_sdk = 34 -# Android ABIs to build for (optional, defaults to all supported ABIs) -# abis = ["arm64-v8a", "armeabi-v7a", "x86_64"] +# Android ABIs to build for (optional, defaults to arm64-v8a) +# abis = ["arm64-v8a"] [ios] # iOS bundle identifier @@ -380,6 +392,10 @@ default_iterations = 100 # Default number of warmup iterations (can be overridden with --warmup) default_warmup = 10 + +[browserstack] +# Timeout in seconds for the generated iOS XCUITest harness to wait for completion +# ios_completion_timeout_secs = 1200 "#, crate_name = crate_name, library_name = library_name, @@ -511,6 +527,7 @@ mod tests { assert_eq!(config.ios.deployment_target, "15.0"); assert_eq!(config.benchmarks.default_iterations, 100); assert_eq!(config.benchmarks.default_warmup, 10); + assert_eq!(config.browserstack.ios_completion_timeout_secs, None); } #[test] @@ -520,6 +537,7 @@ mod tests { assert_eq!(config.project.library_name, Some("my_bench".to_string())); assert_eq!(config.android.package, "dev.world.mybench"); assert_eq!(config.ios.bundle_id, "dev.world.mybench"); + assert_eq!(config.browserstack.ios_completion_timeout_secs, None); } #[test] @@ -545,6 +563,9 @@ deployment_target = "14.0" default_function = "test_bench::test_fn" default_iterations = 50 default_warmup = 5 + +[browserstack] +ios_completion_timeout_secs = 1200 "#; let mut file = std::fs::File::create(&config_path).unwrap(); @@ -565,6 +586,7 @@ default_warmup = 5 ); assert_eq!(config.benchmarks.default_iterations, 50); assert_eq!(config.benchmarks.default_warmup, 5); + assert_eq!(config.browserstack.ios_completion_timeout_secs, Some(1200)); } #[test] @@ -627,5 +649,7 @@ crate = "discovered-bench" assert!(toml.contains("deployment_target = \"15.0\"")); assert!(toml.contains("default_iterations = 100")); assert!(toml.contains("default_warmup = 10")); + assert!(toml.contains("[browserstack]")); + assert!(toml.contains("ios_completion_timeout_secs")); } } diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index e5173d9..fd02df3 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -247,6 +247,12 @@ enum Command { ios_app: Option, #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] ios_test_suite: Option, + #[arg( + long, + hide = true, + help = "Deprecated compatibility flag for generated XCUITest harness timeout" + )] + ios_completion_timeout_secs: Option, #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] fetch: bool, #[arg(long, default_value = "target/browserstack")] @@ -347,6 +353,12 @@ enum Command { target: SdkTarget, #[arg(long, help = "Build in release mode")] release: bool, + #[arg( + long, + hide = true, + help = "Deprecated compatibility flag for generated XCUITest harness timeout" + )] + ios_completion_timeout_secs: Option, #[arg( long, help = "Project root containing mobench.toml or the Cargo workspace" @@ -735,6 +747,12 @@ struct CiRunArgs { ios_app: Option, #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] ios_test_suite: Option, + #[arg( + long, + hide = true, + help = "Deprecated compatibility flag for generated XCUITest harness timeout" + )] + ios_completion_timeout_secs: Option, #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] fetch: bool, #[arg(long, default_value = "target/browserstack")] @@ -950,6 +968,8 @@ struct BrowserStackConfig { app_automate_username: String, app_automate_access_key: String, project: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + ios_completion_timeout_secs: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -992,6 +1012,8 @@ pub(crate) struct RunSpec { pub(crate) iterations: u32, pub(crate) warmup: u32, pub(crate) devices: Vec, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub(crate) ios_completion_timeout_secs: Option, #[serde(skip_serializing, skip_deserializing, default)] pub(crate) browserstack: Option, #[serde(skip_serializing_if = "Option::is_none", default)] @@ -1033,6 +1055,8 @@ pub(crate) struct ResolvedProjectLayout { pub(crate) crate_dir: PathBuf, pub(crate) crate_name: String, pub(crate) library_name: String, + pub(crate) android_abis: Option>, + pub(crate) ios_completion_timeout_secs: Option, pub(crate) config_path: Option, pub(crate) output_dir: PathBuf, pub(crate) default_function: Option, @@ -1148,6 +1172,7 @@ pub fn run() -> Result<()> { release, ios_app, ios_test_suite, + ios_completion_timeout_secs, fetch, fetch_output_dir, fetch_poll_interval_secs, @@ -1173,6 +1198,7 @@ pub fn run() -> Result<()> { device_tags, ios_app, ios_test_suite, + ios_completion_timeout_secs, local_only, release, cli.dry_run, @@ -1365,7 +1391,12 @@ pub fn run() -> Result<()> { println!("Building for iOS..."); println!(" Building Rust library for iOS targets..."); } - let (xcframework, header) = run_ios_build(&layout, release, cli.dry_run)?; + let (xcframework, header) = run_ios_build( + &layout, + release, + cli.dry_run, + spec.ios_completion_timeout_secs, + )?; if !progress { println!("\u{2713} Built iOS xcframework at {:?}", xcframework); } @@ -1386,7 +1417,11 @@ pub fn run() -> Result<()> { println!( "📦 Packaging iOS BrowserStack artifacts with current bench_spec..." ); - let packaged = package_ios_xcuitest_artifacts(&layout, release)?; + let packaged = package_ios_xcuitest_artifacts( + &layout, + release, + spec.ios_completion_timeout_secs, + )?; println!(" ✓ IPA: {}", packaged.app.display()); println!(" ✓ XCUITest: {}", packaged.test_suite.display()); ios_xcuitest = Some(packaged); @@ -1738,6 +1773,7 @@ pub fn run() -> Result<()> { Command::Build { target, release, + ios_completion_timeout_secs, project_root, output_dir, crate_path, @@ -1746,6 +1782,7 @@ pub fn run() -> Result<()> { cmd_build( target, release, + ios_completion_timeout_secs, project_root, output_dir, crate_path, @@ -2128,6 +2165,10 @@ pub(crate) fn resolve_project_layout( .as_ref() .and_then(|cfg| cfg.library_name()) .unwrap_or_else(|| crate_name.replace('-', "_")); + let android_abis = config.as_ref().and_then(|cfg| cfg.android.abis.clone()); + let ios_completion_timeout_secs = config + .as_ref() + .and_then(|cfg| cfg.browserstack.ios_completion_timeout_secs); let output_dir = config .as_ref() .and_then(|cfg| cfg.project.output_dir.clone()) @@ -2148,6 +2189,8 @@ pub(crate) fn resolve_project_layout( crate_dir, crate_name, library_name, + android_abis, + ios_completion_timeout_secs, config_path, output_dir, default_function, @@ -2174,6 +2217,22 @@ fn ensure_verify_smoke_test_supported(layout: &ResolvedProjectLayout) -> Result< ) } +fn configured_android_abis(layout: &ResolvedProjectLayout) -> Vec { + layout + .android_abis + .as_ref() + .filter(|abis| !abis.is_empty()) + .cloned() + .unwrap_or_else(|| vec!["arm64-v8a".to_string()]) +} + +fn configured_ios_completion_timeout_secs( + layout: &ResolvedProjectLayout, + ios_completion_timeout_secs: Option, +) -> Option { + ios_completion_timeout_secs.or(layout.ios_completion_timeout_secs) +} + fn write_config_template(path: &Path, target: MobileTarget, overwrite: bool) -> Result<()> { ensure_can_write(path, overwrite)?; @@ -2197,6 +2256,7 @@ fn write_config_template(path: &Path, target: MobileTarget, overwrite: bool) -> app_automate_username: "${BROWSERSTACK_USERNAME}".into(), app_automate_access_key: "${BROWSERSTACK_ACCESS_KEY}".into(), project: Some("mobile-bench-rs".into()), + ios_completion_timeout_secs: None, }, ios_xcuitest, }; @@ -2286,6 +2346,8 @@ pub struct RunRequest { pub ios_app: Option, /// Optional iOS XCUITest suite package for BrowserStack. pub ios_test_suite: Option, + /// Deprecated compatibility timeout for generated iOS benchmark harnesses. + pub ios_completion_timeout_secs: Option, /// Fetch BrowserStack artifacts after completion. pub fetch: bool, /// Output directory for fetched BrowserStack artifacts. @@ -2398,6 +2460,10 @@ pub fn run_request(request: &RunRequest) -> Result { if let Some(path) = &request.ios_test_suite { cmd.arg("--ios-test-suite").arg(path); } + if let Some(timeout_secs) = request.ios_completion_timeout_secs { + cmd.arg("--ios-completion-timeout-secs") + .arg(timeout_secs.to_string()); + } if let Some(path) = &request.crate_path { cmd.arg("--crate-path").arg(path); } @@ -3041,6 +3107,7 @@ fn cmd_ci_run_single( release: args.release, ios_app: args.ios_app.clone(), ios_test_suite: args.ios_test_suite.clone(), + ios_completion_timeout_secs: args.ios_completion_timeout_secs, fetch: args.fetch, fetch_output_dir: args.fetch_output_dir.clone(), fetch_poll_interval_secs: args.fetch_poll_interval_secs, @@ -3374,14 +3441,23 @@ fn resolve_run_spec( device_tags: Vec, ios_app: Option, ios_test_suite: Option, + ios_completion_timeout_secs: Option, local_only: bool, _release: bool, dry_run: bool, ) -> Result { if let Some(cfg_path) = config { let cfg = load_config(cfg_path)?; - let matrix_path = device_matrix.unwrap_or(cfg.device_matrix.as_path()); - let matrix = load_device_matrix(matrix_path)?; + let configured_ios_completion_timeout_secs = ios_completion_timeout_secs + .or(cfg.browserstack.ios_completion_timeout_secs) + .or(layout.ios_completion_timeout_secs); + let matrix_path = device_matrix.map(Path::to_path_buf).unwrap_or_else(|| { + resolve_project_relative_path( + cfg_path.parent().unwrap_or_else(|| Path::new(".")), + cfg.device_matrix.as_path(), + ) + }); + let matrix = load_device_matrix(&matrix_path)?; let resolved_tags = if !device_tags.is_empty() { Some(device_tags) } else { @@ -3397,6 +3473,7 @@ fn resolve_run_spec( iterations: cfg.iterations, warmup: cfg.warmup, devices: device_names, + ios_completion_timeout_secs: configured_ios_completion_timeout_secs, browserstack: Some(cfg.browserstack), ios_xcuitest: cfg.ios_xcuitest, }); @@ -3455,6 +3532,10 @@ fn resolve_run_spec( iterations, warmup, devices: resolved_devices, + ios_completion_timeout_secs: configured_ios_completion_timeout_secs( + layout, + ios_completion_timeout_secs, + ), browserstack: None, ios_xcuitest, }) @@ -3520,11 +3601,42 @@ fn filter_devices_by_tags(devices: Vec, tags: &[String]) -> Result< Ok(matched) } +fn with_ios_benchmark_timeout_env( + timeout_secs: Option, + f: impl FnOnce() -> Result, +) -> Result { + let Some(timeout_secs) = timeout_secs else { + return f(); + }; + + let previous = env::var_os("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS"); + println!("Using iOS benchmark completion timeout: {timeout_secs}s"); + + unsafe { + env::set_var( + "MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS", + timeout_secs.to_string(), + ) + }; + + let result = f(); + + match previous { + Some(value) => unsafe { env::set_var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS", value) }, + None => unsafe { env::remove_var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS") }, + } + + result +} + pub(crate) fn run_ios_build( layout: &ResolvedProjectLayout, release: bool, dry_run: bool, + ios_completion_timeout_secs: Option, ) -> Result<(PathBuf, PathBuf)> { + let ios_completion_timeout_secs = + configured_ios_completion_timeout_secs(layout, ios_completion_timeout_secs); let builder = mobench_sdk::builders::IosBuilder::new(&layout.project_root, layout.crate_name.clone()) .verbose(true) @@ -3540,8 +3652,10 @@ pub(crate) fn run_ios_build( target: mobench_sdk::Target::Ios, profile, incremental: true, + android_abis: None, }; - let result = builder.build(&cfg)?; + let result = + with_ios_benchmark_timeout_env(ios_completion_timeout_secs, || Ok(builder.build(&cfg)?))?; let header = layout .output_dir .join("ios/include") @@ -3552,7 +3666,10 @@ pub(crate) fn run_ios_build( fn package_ios_xcuitest_artifacts( layout: &ResolvedProjectLayout, release: bool, + ios_completion_timeout_secs: Option, ) -> Result { + let ios_completion_timeout_secs = + configured_ios_completion_timeout_secs(layout, ios_completion_timeout_secs); let builder = mobench_sdk::builders::IosBuilder::new(&layout.project_root, layout.crate_name.clone()) .verbose(true) @@ -3567,9 +3684,9 @@ fn package_ios_xcuitest_artifacts( target: mobench_sdk::Target::Ios, profile, incremental: true, + android_abis: None, }; - builder - .build(&cfg) + with_ios_benchmark_timeout_env(ios_completion_timeout_secs, || Ok(builder.build(&cfg)?)) .context("Failed to build iOS xcframework before packaging")?; let app = builder .package_ipa("BenchRunner", mobench_sdk::builders::SigningMethod::AdHoc) @@ -5390,65 +5507,38 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { return output; } + let _ = writeln!( + output, + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak memory |" + ); + let _ = writeln!( + output, + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" + ); for device in &summary.device_summaries { - let _ = writeln!(output, "### Device: {}", device.device); - let _ = writeln!(output); - let has_resource_usage = device - .benchmarks - .iter() - .any(|bench| bench.resource_usage.is_some()); - if has_resource_usage { - let _ = writeln!( - output, - "| Function | Samples | Mean | Median | P95 | Min | Max | CPU | Peak memory |" - ); - let _ = writeln!( - output, - "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" - ); - } else { + for bench in &device.benchmarks { let _ = writeln!( output, - "| Function | Samples | Mean | Median | P95 | Min | Max |" + "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", + device.device, + bench.function, + bench.samples, + summary.warmup, + format_ms(bench.mean_ns), + format_wall_total(bench.mean_ns, bench.samples), + format_cpu_median_ms(bench.resource_usage.as_ref()), + format_cpu_total_ms(bench.resource_usage.as_ref()), + format_cpu_wall_ratio(bench.mean_ns, bench.samples, bench.resource_usage.as_ref()), + format_peak_memory( + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.peak_memory_kb) + ) ); - let _ = writeln!(output, "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"); - } - for bench in &device.benchmarks { - if has_resource_usage { - let _ = writeln!( - output, - "| {} | {} | {} | {} | {} | {} | {} | {} | {} |", - bench.function, - bench.samples, - format_ms(bench.mean_ns), - format_ms(bench.median_ns), - format_ms(bench.p95_ns), - format_ms(bench.min_ns), - format_ms(bench.max_ns), - format_cpu_ms(bench.resource_usage.as_ref()), - format_peak_memory( - bench - .resource_usage - .as_ref() - .and_then(|usage| usage.peak_memory_kb) - ) - ); - } else { - let _ = writeln!( - output, - "| {} | {} | {} | {} | {} | {} | {} |", - bench.function, - bench.samples, - format_ms(bench.mean_ns), - format_ms(bench.median_ns), - format_ms(bench.p95_ns), - format_ms(bench.min_ns), - format_ms(bench.max_ns) - ); - } } - let _ = writeln!(output); } + let _ = writeln!(output); output } @@ -5520,13 +5610,47 @@ fn format_ms(value: Option) -> String { .unwrap_or_else(|| "-".to_string()) } -fn format_cpu_ms(value: Option<&BenchmarkResourceUsage>) -> String { +fn wall_total_ns(mean_ns: Option, samples: usize) -> Option { + let mean_ns = u128::from(mean_ns?); + let samples = u128::try_from(samples).ok()?; + Some(mean_ns.saturating_mul(samples).min(u128::from(u64::MAX)) as u64) +} + +fn format_wall_total(mean_ns: Option, samples: usize) -> String { + wall_total_ns(mean_ns, samples) + .map(format_duration_smart) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_cpu_median_ms(value: Option<&BenchmarkResourceUsage>) -> String { value - .and_then(|usage| usage.cpu_median_ms.or(usage.cpu_total_ms)) + .and_then(|usage| usage.cpu_median_ms) .map(format_cpu_total_duration_ms) .unwrap_or_else(|| "-".to_string()) } +fn format_cpu_total_ms(value: Option<&BenchmarkResourceUsage>) -> String { + value + .and_then(|usage| usage.cpu_total_ms) + .map(format_cpu_total_duration_ms) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_cpu_wall_ratio( + mean_ns: Option, + samples: usize, + value: Option<&BenchmarkResourceUsage>, +) -> String { + let cpu_total_ms = value.and_then(|usage| usage.cpu_total_ms); + match (wall_total_ns(mean_ns, samples), cpu_total_ms) { + (Some(wall_total_ns), Some(cpu_total_ms)) if wall_total_ns > 0 => { + let ratio = (cpu_total_ms as f64) / (wall_total_ns as f64 / 1_000_000.0) * 100.0; + format!("{ratio:.1}%") + } + _ => "-".to_string(), + } +} + fn format_cpu_total_duration_ms(ms: u64) -> String { if ms < 1_000 { format!("{ms}ms") @@ -5557,6 +5681,7 @@ pub(crate) fn run_android_build( target: mobench_sdk::Target::Android, profile, incremental: true, + android_abis: layout.android_abis.clone(), }; let builder = mobench_sdk::builders::AndroidBuilder::new(&layout.project_root, layout.crate_name.clone()) @@ -5712,6 +5837,7 @@ fn cmd_init_sdk( fn cmd_build( target: SdkTarget, release: bool, + ios_completion_timeout_secs: Option, project_root: Option, output_dir: Option, crate_path: Option, @@ -5726,6 +5852,8 @@ fn cmd_build( config_path: None, })?; let effective_output_dir = output_dir.unwrap_or_else(|| layout.output_dir.clone()); + let ios_completion_timeout_secs = + configured_ios_completion_timeout_secs(&layout, ios_completion_timeout_secs); // Progress mode: simplified output if progress { @@ -5737,6 +5865,7 @@ fn cmd_build( mobench_sdk::BuildProfile::Debug }, incremental: true, + android_abis: layout.android_abis.clone(), }; match target { @@ -5768,7 +5897,9 @@ fn cmd_build( .output_dir(&effective_output_dir) .crate_dir(&layout.crate_dir); println!("[2/3] Building iOS xcframework..."); - let result = builder.build(&build_config)?; + let result = with_ios_benchmark_timeout_env(ios_completion_timeout_secs, || { + Ok(builder.build(&build_config)?) + })?; println!("[3/3] Done!"); if !dry_run { println!("\n\u{2713} Framework: {:?}", result.app_path); @@ -5797,7 +5928,10 @@ fn cmd_build( .output_dir(&effective_output_dir) .crate_dir(&layout.crate_dir); println!("[4/5] Building iOS xcframework..."); - let ios_result = ios_builder.build(&build_config)?; + let ios_result = + with_ios_benchmark_timeout_env(ios_completion_timeout_secs, || { + Ok(ios_builder.build(&build_config)?) + })?; println!("[5/5] Done!"); if !dry_run { @@ -5836,6 +5970,7 @@ fn cmd_build( mobench_sdk::BuildProfile::Debug }, incremental: true, + android_abis: layout.android_abis.clone(), }; match target { @@ -5850,7 +5985,9 @@ fn cmd_build( .dry_run(dry_run) .output_dir(&effective_output_dir) .crate_dir(&layout.crate_dir); - let result = builder.build(&build_config)?; + let result = with_ios_benchmark_timeout_env(ios_completion_timeout_secs, || { + Ok(builder.build(&build_config)?) + })?; if !dry_run { println!("\u{2713} Built Android APK"); println!("\n[checkmark] Android build completed!"); @@ -5868,7 +6005,9 @@ fn cmd_build( .dry_run(dry_run) .output_dir(&effective_output_dir) .crate_dir(&layout.crate_dir); - let result = builder.build(&build_config)?; + let result = with_ios_benchmark_timeout_env(ios_completion_timeout_secs, || { + Ok(builder.build(&build_config)?) + })?; if !dry_run { println!("\u{2713} Built iOS xcframework"); println!("\n[checkmark] iOS build completed!"); @@ -5905,7 +6044,9 @@ fn cmd_build( .dry_run(dry_run) .output_dir(&effective_output_dir) .crate_dir(&layout.crate_dir); - let ios_result = ios_builder.build(&build_config)?; + let ios_result = with_ios_benchmark_timeout_env(ios_completion_timeout_secs, || { + Ok(ios_builder.build(&build_config)?) + })?; if !dry_run { println!("\u{2713} Built iOS xcframework"); println!("\n[checkmark] iOS build completed!"); @@ -6196,10 +6337,10 @@ fn cmd_verify( // Check JNI libs let jni_base = effective_output_dir.join("android/app/src/main/jniLibs"); - let abis = ["arm64-v8a", "armeabi-v7a", "x86_64"]; + let abis = configured_android_abis(&layout); for abi in abis { let lib_path = jni_base - .join(abi) + .join(&abi) .join(format!("lib{}.so", layout.library_name)); if lib_path.exists() { artifact_details.push(format!("JNI lib ({}): OK", abi)); @@ -7172,6 +7313,7 @@ fn cmd_fixture_build( SdkTarget::Android, release, None, + None, output_dir, crate_path, false, @@ -7183,6 +7325,7 @@ fn cmd_fixture_build( SdkTarget::Ios, release, None, + None, output_dir.clone(), crate_path, false, @@ -7203,6 +7346,7 @@ fn cmd_fixture_build( SdkTarget::Android, release, None, + None, output_dir.clone(), crate_path.clone(), false, @@ -7213,6 +7357,7 @@ fn cmd_fixture_build( SdkTarget::Ios, release, None, + None, output_dir.clone(), crate_path, false, @@ -8182,8 +8327,14 @@ resolver = "2" crate = "zk-mobile-bench" library_name = "zk_mobile_bench" +[android] +abis = ["arm64-v8a", "x86_64"] + [benchmarks] default_function = "zk_mobile_bench::bench_query_proof_generation" + +[browserstack] +ios_completion_timeout_secs = 900 "#, ) .expect("write mobench config"); @@ -8239,6 +8390,7 @@ pub fn bench_query_proof_generation() {} Vec::new(), None, None, + None, false, false, // release false, @@ -8313,6 +8465,7 @@ project = "proj" Vec::new(), None, None, + None, false, false, false, @@ -8419,6 +8572,11 @@ project = "proj" assert_eq!(layout.crate_dir, crate_dir); assert_eq!(layout.crate_name, "zk-mobile-bench"); assert_eq!(layout.library_name, "zk_mobile_bench"); + assert_eq!( + layout.android_abis, + Some(vec!["arm64-v8a".to_string(), "x86_64".to_string()]) + ); + assert_eq!(layout.ios_completion_timeout_secs, Some(900)); assert_eq!( layout.default_function.as_deref(), Some("zk_mobile_bench::bench_query_proof_generation") @@ -8478,6 +8636,7 @@ project = "proj" cmd_build( SdkTarget::Ios, false, + None, Some(project_root), None, None, @@ -8530,6 +8689,7 @@ project = "proj" Vec::new(), None, None, + None, false, false, true, @@ -8539,6 +8699,7 @@ project = "proj" let ios_xcuitest = spec .ios_xcuitest .expect("dry-run should prepare placeholder iOS artifacts"); + assert_eq!(spec.ios_completion_timeout_secs, Some(900)); assert!( ios_xcuitest.app.starts_with(&project_root), "app path should stay inside project root: {}", @@ -8586,6 +8747,7 @@ project = "proj" iterations: 3, warmup: 1, devices: vec![], + ios_completion_timeout_secs: None, browserstack: None, ios_xcuitest: None, }; @@ -8617,6 +8779,7 @@ project = "proj" Vec::new(), None, None, + None, false, false, // release false, @@ -8760,6 +8923,120 @@ project = "proj" } } + #[test] + fn ci_run_parses_ios_completion_timeout_secs() { + let cli = Cli::parse_from([ + "mobench", + "ci", + "run", + "--target", + "ios", + "--function", + "sample_fns::fibonacci", + "--ios-completion-timeout-secs", + "900", + ]); + + match cli.command { + Command::Ci { + command: CiCommand::Run(args), + } => { + assert_eq!(args.target, CiTarget::Ios); + assert_eq!(args.ios_completion_timeout_secs, Some(900)); + } + _ => panic!("expected ci run command"), + } + } + + #[test] + fn build_parses_ios_completion_timeout_secs() { + let cli = Cli::parse_from([ + "mobench", + "build", + "--target", + "ios", + "--ios-completion-timeout-secs", + "750", + ]); + + match cli.command { + Command::Build { + ios_completion_timeout_secs, + .. + } => { + assert_eq!(ios_completion_timeout_secs, Some(750)); + } + _ => panic!("expected build command"), + } + } + + #[test] + fn resolve_run_spec_reads_ios_completion_timeout_from_config() { + let temp_dir = TempDir::new().expect("temp dir"); + let config_path = temp_dir.path().join("bench-config.toml"); + + let config_toml = r#"target = "ios" +function = "sample_fns::fibonacci" +iterations = 10 +warmup = 2 +device_matrix = "device-matrix.yaml" + +[browserstack] +app_automate_username = "user" +app_automate_access_key = "key" +project = "proj" +ios_completion_timeout_secs = 900 + +[ios_xcuitest] +app = "target/ios/BenchRunner.ipa" +test_suite = "target/ios/BenchRunnerUITests.zip" +"#; + write_file(&config_path, config_toml.as_bytes()).expect("write config"); + write_file( + &temp_dir.path().join("device-matrix.yaml"), + br#"devices: + - name: iPhone 16 Pro + os: ios + os_version: "18" +"#, + ) + .expect("write matrix"); + + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: None, + config_path: None, + }) + .unwrap(); + let spec = resolve_run_spec( + MobileTarget::Ios, + "ignored::value".into(), + 1, + 0, + Vec::new(), + &layout, + Some(config_path.as_path()), + None, + Vec::new(), + None, + None, + Some(600), + false, + false, + false, + ) + .expect("resolve spec"); + + assert_eq!(spec.ios_completion_timeout_secs, Some(600)); + assert_eq!( + spec.browserstack + .as_ref() + .and_then(|cfg| cfg.ios_completion_timeout_secs), + Some(900) + ); + } + #[test] fn devices_resolve_parses() { let cli = Cli::parse_from([ @@ -9208,14 +9485,58 @@ project = "proj" }); assert!(markdown.contains( - "| Function | Samples | Mean | Median | P95 | Min | Max | CPU | Peak memory |" + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak memory |" )); - assert!(!markdown.contains("(ms)")); - assert!(!markdown.contains("CPU total")); + assert!(markdown.contains("1.250s")); + assert!(markdown.contains("6.250s")); assert!(markdown.contains("241ms")); + assert!(markdown.contains("482ms")); + assert!(markdown.contains("7.7%")); assert!(markdown.contains("243.57 MB")); } + #[test] + fn render_markdown_summary_uses_explicit_wall_and_cpu_columns() { + let markdown = render_markdown_summary(&SummaryReport { + generated_at: "2026-04-12T00:00:00Z".to_string(), + generated_at_unix: 1_744_416_000, + target: MobileTarget::Android, + function: "sample_fns::fibonacci".to_string(), + iterations: 4, + warmup: 1, + devices: vec!["Google Pixel 8-14.0".to_string()], + device_summaries: vec![DeviceSummary { + device: "Google Pixel 8-14.0".to_string(), + benchmarks: vec![BenchmarkStats { + function: "sample_fns::fibonacci".to_string(), + samples: 4, + mean_ns: Some(1_000_000_000), + median_ns: Some(950_000_000), + p95_ns: Some(1_100_000_000), + min_ns: Some(900_000_000), + max_ns: Some(1_200_000_000), + resource_usage: Some(BenchmarkResourceUsage { + cpu_total_ms: Some(800), + cpu_median_ms: Some(200), + peak_memory_kb: Some(1_024), + total_pss_kb: None, + private_dirty_kb: None, + native_heap_kb: None, + java_heap_kb: None, + }), + }], + }], + }); + + assert!(markdown.contains( + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak memory |" + )); + assert!(markdown.contains( + "| Google Pixel 8-14.0 | sample_fns::fibonacci | 4 | 1 | 1.000s | 4.000s | 200ms | 800ms | 20.0% | 1.00 MB |" + )); + assert!(!markdown.contains("### Device:")); + } + #[test] fn render_csv_summary_includes_resource_usage_columns() { let csv = render_csv_summary(&SummaryReport { @@ -9290,11 +9611,13 @@ project = "proj" }], }); - assert!(markdown.contains("CPU")); + assert!(markdown.contains("CPU median / iter")); + assert!(markdown.contains("CPU total")); + assert!(markdown.contains("CPU / wall")); assert!(markdown.contains("Peak memory")); - assert!(!markdown.contains("(ms)")); - assert!(!markdown.contains("CPU total")); assert!(markdown.contains("241ms")); + assert!(markdown.contains("482ms")); + assert!(markdown.contains("7.7%")); assert!(markdown.contains("638.99 MB")); } @@ -9331,11 +9654,16 @@ project = "proj" }], }); - assert!(markdown.contains("CPU")); + assert!(markdown.contains("Device")); + assert!(markdown.contains("Wall mean / iter")); + assert!(markdown.contains("Wall total")); + assert!(markdown.contains("CPU median / iter")); + assert!(markdown.contains("CPU total")); + assert!(markdown.contains("CPU / wall")); assert!(markdown.contains("Peak memory")); - assert!(!markdown.contains("(ms)")); - assert!(!markdown.contains("CPU total")); assert!(markdown.contains("241ms")); + assert!(markdown.contains("482ms")); + assert!(markdown.contains("7.7%")); assert!(markdown.contains("638.99 MB")); } @@ -9349,6 +9677,7 @@ project = "proj" devices: vec!["Google Pixel 8-14.0".into()], browserstack: None, ios_xcuitest: None, + ios_completion_timeout_secs: None, }; let run_summary = RunSummary { spec: spec.clone(), @@ -9391,6 +9720,7 @@ project = "proj" devices: vec!["Google Pixel 8-14.0".into()], browserstack: None, ios_xcuitest: None, + ios_completion_timeout_secs: None, }; let run_summary = RunSummary { spec: spec.clone(), @@ -9493,6 +9823,7 @@ project = "proj" iterations: 3, warmup: 1, devices: vec![], + ios_completion_timeout_secs: None, browserstack: None, ios_xcuitest: None, }; @@ -9508,6 +9839,7 @@ project = "proj" iterations: 3, warmup: 1, devices: vec![], + ios_completion_timeout_secs: None, browserstack: None, ios_xcuitest: None, }), @@ -9897,7 +10229,9 @@ mod ci_merge_tests { assert!(markdown.starts_with("### Benchmark Summary\n")); assert!(markdown.contains("- Target: iOS")); - assert!(markdown.contains("### Device: iPhone 13")); + assert!(markdown.contains("| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak memory |")); + assert!(markdown.contains("| iPhone 13 | ffi_benchmark::bench_fibonacci | 5 | 1 | 0.017ms | 0.085ms | - | - | - | - |")); + assert!(!markdown.contains("### Device:")); } #[cfg(unix)] @@ -10110,6 +10444,7 @@ mod ci_merge_tests { devices: vec!["Google Pixel 8-14.0".into()], browserstack: None, ios_xcuitest: None, + ios_completion_timeout_secs: None, }; let local_report = json!({}); let run_summary = RunSummary { @@ -10161,6 +10496,7 @@ mod ci_merge_tests { devices: vec!["iPhone 15-17.0".into()], browserstack: None, ios_xcuitest: None, + ios_completion_timeout_secs: None, }; let run_summary = RunSummary { spec: spec.clone(), diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 98e58d3..dd807a4 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -2628,6 +2628,7 @@ fn execute_local_android_capture( iterations: DEFAULT_PROFILE_ITERATIONS, warmup: DEFAULT_PROFILE_WARMUP, devices: Vec::new(), + ios_completion_timeout_secs: None, browserstack: None, ios_xcuitest: None, }; @@ -2897,6 +2898,7 @@ fn execute_local_ios_capture(args: &ProfileRunArgs, manifest: &mut ProfileManife iterations: DEFAULT_PROFILE_ITERATIONS, warmup: DEFAULT_PROFILE_WARMUP, devices: Vec::new(), + ios_completion_timeout_secs: None, browserstack: None, ios_xcuitest: None, }; @@ -2907,7 +2909,7 @@ fn execute_local_ios_capture(args: &ProfileRunArgs, manifest: &mut ProfileManife ensure_local_ios_simulator_booted(&simulator)?; manifest.capture_metadata.device = Some(simulator.identifier()); - run_ios_build(&layout, false, false)?; + run_ios_build(&layout, false, false, None)?; let app_path = build_local_ios_simulator_app(&layout, &simulator)?; install_local_ios_app(&simulator, &app_path)?; diff --git a/docs/guides/sdk-integration.md b/docs/guides/sdk-integration.md index acc78b4..991564d 100644 --- a/docs/guides/sdk-integration.md +++ b/docs/guides/sdk-integration.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.31" +mobench-sdk = "0.1.32" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.31" +mobench-sdk = "0.1.32" ``` ## 3) Annotate benchmark functions diff --git a/ios/BenchRunner/BenchRunner/ContentView.swift b/ios/BenchRunner/BenchRunner/ContentView.swift index d2a2d26..81734b5 100644 --- a/ios/BenchRunner/BenchRunner/ContentView.swift +++ b/ios/BenchRunner/BenchRunner/ContentView.swift @@ -35,11 +35,13 @@ struct ContentView: View { } } .onAppear { - Task { + Task.detached(priority: .userInitiated) { let result = await BenchRunnerFFI.runCurrentBenchmark() - report = result.displayText - reportJSON = result.jsonReport - isCompleted = true + await MainActor.run { + report = result.displayText + reportJSON = result.jsonReport + isCompleted = true + } // Log the JSON report with markers for BrowserStack device logs NSLog("BENCH_REPORT_JSON_START") diff --git a/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift b/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift index 0a46691..2edfe22 100644 --- a/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift +++ b/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift @@ -3,7 +3,19 @@ import XCTest final class BenchRunnerUITests: XCTestCase { /// Maximum time to wait for benchmark completion (5 minutes for long benchmarks) - private let benchmarkTimeout: TimeInterval = 300.0 + private let defaultBenchmarkTimeout: TimeInterval = 300.0 + + private var benchmarkTimeout: TimeInterval { + if let configuredTimeout = + ProcessInfo.processInfo.environment["MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS"], + let parsedTimeout = TimeInterval(configuredTimeout), + parsedTimeout > 0 + { + return parsedTimeout + } + + return defaultBenchmarkTimeout + } func testLaunchAndCaptureBenchmarkReport() { let app = XCUIApplication() diff --git a/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template b/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template index cbdb8de..fda37ac 100644 --- a/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template +++ b/templates/ios/BenchRunner/BenchRunner/ContentView.swift.template @@ -89,7 +89,7 @@ struct ContentView: View { } } .onAppear { - Task { + Task.detached(priority: .userInitiated) { let options = ProfileLaunchOptions.resolved() if options.benchDelayMs > 0 { try? await Task.sleep(nanoseconds: options.benchDelayMs * 1_000_000) @@ -103,9 +103,11 @@ struct ContentView: View { result = await {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() repeatedRuns += 1 } - report = result.displayText - reportJSON = result.jsonReport - isCompleted = true + await MainActor.run { + report = result.displayText + reportJSON = result.jsonReport + isCompleted = true + } // Log the JSON report with stable markers so local profiling tools, // XCUITest, and BrowserStack fetch paths can all recover the payload. diff --git a/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template b/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template index fc0e8df..f8ca532 100644 --- a/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template +++ b/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template @@ -3,7 +3,19 @@ import XCTest final class {{PROJECT_NAME_PASCAL}}UITests: XCTestCase { /// Maximum time to wait for benchmark completion (5 minutes for long benchmarks) - private let benchmarkTimeout: TimeInterval = 300.0 + private let defaultBenchmarkTimeout: TimeInterval = {{IOS_BENCHMARK_TIMEOUT_SECS}}.0 + + private var benchmarkTimeout: TimeInterval { + if let configuredTimeout = + ProcessInfo.processInfo.environment["MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS"], + let parsedTimeout = TimeInterval(configuredTimeout), + parsedTimeout > 0 + { + return parsedTimeout + } + + return defaultBenchmarkTimeout + } func testLaunchAndCaptureBenchmarkReport() { let app = XCUIApplication() From 1fbb72b21e19cb13ff548e6deae39a02b6cffc98 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 17 Apr 2026 10:47:09 +0900 Subject: [PATCH 165/196] Release v0.1.33 --- .github/workflows/reusable-bench.yml | 2 +- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- RELEASE_NOTES.md | 27 ++++++++++++++++++++++----- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- docs/guides/sdk-integration.md | 4 ++-- 7 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 20c678d..68431d6 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -66,7 +66,7 @@ on: description: "Mobench version to install" required: false type: string - default: "0.1.32" + default: "0.1.33" mobench_ref: description: "Git ref for mobile-bench-rs (overrides mobench_version when set)" required: false diff --git a/Cargo.lock b/Cargo.lock index b17c5a8..406c90c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.32" +version = "0.1.33" dependencies = [ "anyhow", "clap", @@ -1071,7 +1071,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.32" +version = "0.1.33" dependencies = [ "proc-macro2", "quote", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.32" +version = "0.1.33" dependencies = [ "anyhow", "include_dir", @@ -1583,7 +1583,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.32" +version = "0.1.33" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 41ef61f..9abbc99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.32" +version = "0.1.33" [workspace.dependencies] anyhow = "1" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e5de085..305a553 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -13,9 +13,9 @@ Crates.io release history: ## Support Policy -- `v0.1.32` is the current supported release. -- `v0.1.31` is the immediately previous supported release, superseded by - `v0.1.32`. +- `v0.1.33` is the current supported release. +- `v0.1.32` is the immediately previous supported release, superseded by + `v0.1.33`. - Every earlier published version is a historical test build and should not be used. - Yanked versions are explicitly called out below. @@ -24,7 +24,8 @@ Crates.io release history: | Version | Published | Published crates | Status | |---------|-----------|------------------|--------| -| `v0.1.32` | 2026-04-14 | `mobench 0.1.32`, `mobench-sdk 0.1.32`, `mobench-macros 0.1.32` | Current supported release | +| `v0.1.33` | 2026-04-17 | `mobench 0.1.33`, `mobench-sdk 0.1.33`, `mobench-macros 0.1.33` | Current supported release | +| `v0.1.32` | 2026-04-14 | `mobench 0.1.32`, `mobench-sdk 0.1.32`, `mobench-macros 0.1.32` | Superseded by `v0.1.33` | | `v0.1.31` | 2026-04-12 | `mobench 0.1.31`, `mobench-sdk 0.1.31`, `mobench-macros 0.1.31` | Superseded by `v0.1.32` | | `v0.1.30` | 2026-04-12 | `mobench 0.1.30`, `mobench-sdk 0.1.30`, `mobench-macros 0.1.30` | Superseded by `v0.1.31` | | `v0.1.29` | 2026-04-04 | `mobench 0.1.29`, `mobench-sdk 0.1.29`, `mobench-macros 0.1.29` | Superseded by `v0.1.30` | @@ -59,10 +60,26 @@ Crates.io release history: | `v0.1.1` | 2026-01-13 | `mobench 0.1.1`, `mobench-sdk 0.1.1` | Yanked test build. Do not use. | | `v0.1.0` | 2026-01-13 | `mobench 0.1.0`, `mobench-sdk 0.1.0`, `mobench-macros 0.1.0` | Yanked test build. Do not use. | -## v0.1.32 +## v0.1.33 Status: current supported release. +- Measured benchmark CPU time as process CPU time under the standard + user-plus-kernel definition across all threads, then exported both median + per-iteration CPU and total CPU in the summary output. +- Reworked rendered CI summaries into one top-level table with explicit wall + mean, wall total, CPU median, CPU total, CPU-to-wall ratio, and peak memory + columns so the first view of a run is directly comparable across devices. +- Exposed `mobench_ref` and `mobench_version` on the manual `Mobile Bench` + workflow so full BrowserStack validation can exercise a branch or release + candidate instead of only the last published crates.io build. +- Validated the new summary layout and branch-pinned workflow path with + successful Mobile Bench workflow run `24543140161`. + +## v0.1.32 + +Status: superseded by `v0.1.33`. + - Restored Android ABI selection end to end after the `0.1.31` regression, so `[android].abis` now drives dry-run output, native builds, JNI library copy, and validation instead of silently rebuilding unsupported defaults. diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index a87f53c..a1f31f9 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.32", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.33", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 21209f2..db407c9 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -37,7 +37,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.32", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.33", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/docs/guides/sdk-integration.md b/docs/guides/sdk-integration.md index 991564d..db7760e 100644 --- a/docs/guides/sdk-integration.md +++ b/docs/guides/sdk-integration.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.32" +mobench-sdk = "0.1.33" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.32" +mobench-sdk = "0.1.33" ``` ## 3) Annotate benchmark functions From 06138ed2ee64e1186e4d99c65b4fb9867b7b3266 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Sat, 18 Apr 2026 17:02:49 +0900 Subject: [PATCH 166/196] docs: sync CI tables and Android prereqs --- crates/mobench/README.md | 3 ++- crates/mobench/src/lib.rs | 33 ++++++++++++++++++----------- docs/MIGRATION_GUIDE.md | 8 +++---- docs/guides/browserstack-ci.md | 2 +- docs/guides/browserstack-metrics.md | 2 +- docs/guides/build.md | 16 ++++++++------ docs/guides/sdk-integration.md | 2 +- docs/guides/testing.md | 7 ++++-- 8 files changed, 44 insertions(+), 29 deletions(-) diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 016543d..90dd9d9 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -263,7 +263,8 @@ cargo mobench ci run --target --function [OPTIONS] - `summary.md` - `results.csv` -`summary.md` uses unit-neutral timing headers and renders `CPU` from measured-iteration `cpu_median_ms` in milliseconds below one second and total seconds otherwise. +`summary.md` renders one top-level table with `Wall mean / iter`, `Wall total`, +`CPU median / iter`, `CPU total`, `CPU / wall`, and `Peak memory`. `results.csv` includes: - `cpu_total_ms` diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index fd02df3..e3c231b 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -7874,12 +7874,7 @@ fn collect_prereq_checks(target: SdkTarget) -> Vec { match target { SdkTarget::Android => { println!("Checking prerequisites for Android...\n"); - checks.push(check_android_ndk_home()); - checks.push(check_cargo_ndk()); - checks.push(check_rust_target("aarch64-linux-android")); - checks.push(check_rust_target("armv7-linux-androideabi")); - checks.push(check_rust_target("x86_64-linux-android")); - checks.push(check_jdk()); + extend_android_prereq_checks(&mut checks); } SdkTarget::Ios => { println!("Checking prerequisites for iOS...\n"); @@ -7891,12 +7886,7 @@ fn collect_prereq_checks(target: SdkTarget) -> Vec { } SdkTarget::Both => { println!("Checking prerequisites for Android and iOS...\n"); - checks.push(check_android_ndk_home()); - checks.push(check_cargo_ndk()); - checks.push(check_rust_target("aarch64-linux-android")); - checks.push(check_rust_target("armv7-linux-androideabi")); - checks.push(check_rust_target("x86_64-linux-android")); - checks.push(check_jdk()); + extend_android_prereq_checks(&mut checks); checks.push(check_xcode()); checks.push(check_xcodegen()); checks.push(check_rust_target("aarch64-apple-ios")); @@ -7908,6 +7898,17 @@ fn collect_prereq_checks(target: SdkTarget) -> Vec { checks } +const DEFAULT_ANDROID_DOCTOR_RUST_TARGETS: &[&str] = &["aarch64-linux-android"]; + +fn extend_android_prereq_checks(checks: &mut Vec) { + checks.push(check_android_ndk_home()); + checks.push(check_cargo_ndk()); + for target in DEFAULT_ANDROID_DOCTOR_RUST_TARGETS { + checks.push(check_rust_target(target)); + } + checks.push(check_jdk()); +} + fn collect_issues(checks: &[PrereqCheck]) -> Vec { let mut issues = Vec::new(); for check in checks { @@ -8877,6 +8878,14 @@ project = "proj" } } + #[test] + fn doctor_android_prereqs_default_to_arm64_only() { + assert_eq!( + DEFAULT_ANDROID_DOCTOR_RUST_TARGETS, + &["aarch64-linux-android"] + ); + } + #[test] fn ci_run_parses_required_args_with_defaults() { let cli = Cli::parse_from([ diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index 06e550c..edafd53 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -56,7 +56,7 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + targets: aarch64-linux-android - uses: ./.github/actions/mobench with: command: cargo mobench ci run @@ -81,7 +81,7 @@ jobs: - `summary.json`, `summary.md`, and `results.csv` remain the stable required outputs. - `plots/*.svg` is additive and only appears when local plot rendering is enabled and a Python + Matplotlib runtime is available, or when `--plots require` is used successfully. -- Local markdown summaries now include `cpu_total_ms` and `peak_memory_kb` instead of percentage/average-RAM columns. +- `summary.md` now renders a single top-level CI table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, and `Peak memory`. - The reusable workflow attempts to compare against the latest successful default-branch run by downloading its per-platform `summary.json` artifacts before calling `ci check-run`. ## Compatibility Notes @@ -94,8 +94,8 @@ Any change to required output files or metadata keys requires updating the versioned schemas and documenting the compatibility impact in `RELEASE_NOTES.md`. ### Summary/CSV contract updates -- `summary.md` now uses unit-neutral timing headers -- the default human-readable CPU column is `CPU`, rendered from measured-iteration `cpu_median_ms` in milliseconds below one second and total seconds otherwise +- `summary.md` now renders a flat per-device table instead of per-device sections +- CPU values in that table are shown as `CPU median / iter`, `CPU total`, and `CPU / wall` - `results.csv` now includes `cpu_total_ms`, `cpu_median_ms`, and `peak_memory_kb` - missing resource values are left blank in CSV output diff --git a/docs/guides/browserstack-ci.md b/docs/guides/browserstack-ci.md index 1fbcf7d..3110064 100644 --- a/docs/guides/browserstack-ci.md +++ b/docs/guides/browserstack-ci.md @@ -89,7 +89,7 @@ cargo mobench report summarize --summary target/mobench/ci/summary.json ``` Standard CI outputs now carry benchmark-scoped resource metrics directly: -- `summary.md` shows `CPU` (measured-iteration `cpu_median_ms`) and `Peak memory` with unit-neutral headers +- `summary.md` renders one top-level table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, and `Peak memory` - `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, and `peak_memory_kb` - missing resource data is emitted as blank CSV fields rather than placeholder strings diff --git a/docs/guides/browserstack-metrics.md b/docs/guides/browserstack-metrics.md index cfaffa2..ceec9ad 100644 --- a/docs/guides/browserstack-metrics.md +++ b/docs/guides/browserstack-metrics.md @@ -49,7 +49,7 @@ We recursively download ALL URLs from session JSON, which typically includes: **Benchmark-scoped resource metrics (current default):** - Each measured iteration can emit `cpu_time_ms` and `peak_memory_kb` - `mobench` derives `cpu_median_ms` from measured iterations only -- `summary.md` renders the default `CPU` column from `cpu_median_ms` in milliseconds below one second and total seconds otherwise +- `summary.md` renders `CPU median / iter`, `CPU total`, `CPU / wall`, and `Peak memory` in the top-level CI table - `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, and `peak_memory_kb` - BrowserStack aggregate memory is only used as a fallback when benchmark-scoped peak memory is absent diff --git a/docs/guides/build.md b/docs/guides/build.md index 2eddd19..264b9cc 100644 --- a/docs/guides/build.md +++ b/docs/guides/build.md @@ -53,8 +53,6 @@ Checking Android prerequisites... [PASS] ANDROID_NDK_HOME set: /Users/you/Library/Android/sdk/ndk/29.0.14206865 [PASS] cargo-ndk installed [PASS] Rust target aarch64-linux-android installed - [PASS] Rust target armv7-linux-androideabi installed - [PASS] Rust target x86_64-linux-android installed All prerequisites satisfied! ``` @@ -80,8 +78,11 @@ Download: https://www.rust-lang.org/tools/install # Set environment variable (add to ~/.zshrc or ~/.bashrc) export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/29.0.14206865 -# Install required Rust targets -rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android +# Install the default Rust target +rustup target add aarch64-linux-android + +# Optional: add extra Android targets only when android.abis enables them +# rustup target add armv7-linux-androideabi x86_64-linux-android # Install cargo-ndk cargo install cargo-ndk @@ -144,10 +145,11 @@ adb shell am start -n dev.world.bench/.MainActivity cargo mobench build --target android ``` -This compiles Rust code for three Android ABIs: +This compiles Rust code for one Android ABI by default: - `aarch64-linux-android` → `arm64-v8a` (64-bit ARM devices) -- `armv7-linux-androideabi` → `armeabi-v7a` (32-bit ARM devices) -- `x86_64-linux-android` → `x86_64` (x86 emulators) + +Add `armeabi-v7a` or `x86_64` in `mobench.toml` `android.abis` only when +you need those extra targets. Output: `target/{target-triple}/release/libsample_fns.so` diff --git a/docs/guides/sdk-integration.md b/docs/guides/sdk-integration.md index db7760e..cf0f566 100644 --- a/docs/guides/sdk-integration.md +++ b/docs/guides/sdk-integration.md @@ -434,7 +434,7 @@ cargo mobench build --target android This automatically: -- Builds Rust libraries for all Android ABIs (arm64-v8a, armeabi-v7a, x86_64) +- Builds Rust libraries for the configured Android ABIs (default: arm64-v8a) - Generates UniFFI Kotlin bindings - Copies .so files to jniLibs - Runs Gradle to create the APK diff --git a/docs/guides/testing.md b/docs/guides/testing.md index fc63526..4bb3236 100644 --- a/docs/guides/testing.md +++ b/docs/guides/testing.md @@ -39,8 +39,11 @@ The check command will identify missing tools and provide installation instructi curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # https://www.rust-lang.org/tools/install -# Install required targets -rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android +# Install the default Android target +rustup target add aarch64-linux-android + +# Optional: add extra Android targets only when android.abis enables them +# rustup target add armv7-linux-androideabi x86_64-linux-android rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios # https://doc.rust-lang.org/rustup/targets.html From 3ff534d4c4720e21415acaf0bff52fd71cc11106 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 21 Apr 2026 10:08:48 +0200 Subject: [PATCH 167/196] Split memory growth from absolute peak --- crates/mobench-sdk/src/ffi.rs | 3 + crates/mobench-sdk/src/timing.rs | 20 +- crates/mobench-sdk/src/uniffi_types.rs | 3 + .../src/main/java/MainActivity.kt.template | 4 +- crates/mobench/README.md | 7 +- crates/mobench/src/lib.rs | 258 ++++++++++++++---- crates/mobench/src/summarize.rs | 177 +++++++++--- docs/schemas/summary-v1.schema.json | 2 + 8 files changed, 384 insertions(+), 90 deletions(-) diff --git a/crates/mobench-sdk/src/ffi.rs b/crates/mobench-sdk/src/ffi.rs index 2940eb6..d7b9083 100644 --- a/crates/mobench-sdk/src/ffi.rs +++ b/crates/mobench-sdk/src/ffi.rs @@ -78,6 +78,9 @@ pub struct BenchSampleFfi { /// CPU time consumed by the measured iteration in milliseconds. pub cpu_time_ms: Option, /// Peak memory growth during the measured iteration in kilobytes. + /// + /// This is the legacy wire field for baseline-adjusted growth, not + /// absolute process or device peak memory. pub peak_memory_kb: Option, } diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index 176ed8a..4229504 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -204,8 +204,9 @@ pub struct BenchSample { /// Peak memory growth during the measured iteration in kilobytes. /// - /// Values are baseline-adjusted immediately before the measured closure - /// enters so harness footprint is not counted. + /// This legacy wire field is baseline-adjusted immediately before the + /// measured closure enters. It reports growth during the measured + /// iteration, not absolute process or device peak memory. #[serde(default, skip_serializing_if = "Option::is_none")] pub peak_memory_kb: Option, } @@ -394,6 +395,9 @@ impl BenchReport { } /// Returns the maximum baseline-adjusted peak memory growth in kilobytes. + /// + /// This is the legacy accessor for the serialized `peak_memory_kb` sample + /// field. It does not report absolute process or device peak memory. #[must_use] pub fn peak_memory_kb(&self) -> Option { self.samples @@ -402,6 +406,15 @@ impl BenchReport { .max() } + /// Returns the maximum baseline-adjusted peak memory growth in kilobytes. + /// + /// This is an explicit alias for [`BenchReport::peak_memory_kb`] to make the + /// growth semantics clear while preserving the legacy wire field. + #[must_use] + pub fn peak_memory_growth_kb(&self) -> Option { + self.peak_memory_kb() + } + /// Returns a statistical summary of the benchmark results. #[must_use] pub fn summary(&self) -> BenchSummary { @@ -1566,6 +1579,8 @@ mod tests { }; let json = serde_json::to_string(&report).unwrap(); + assert!(json.contains("\"peak_memory_kb\"")); + assert!(!json.contains("peak_memory_growth_kb")); let restored: BenchReport = serde_json::from_str(&json).unwrap(); assert_eq!(restored.spec.name, "test"); @@ -1772,6 +1787,7 @@ mod tests { vec![Some(48), Some(96)] ); assert_eq!(report.peak_memory_kb(), Some(96)); + assert_eq!(report.peak_memory_growth_kb(), report.peak_memory_kb()); } #[test] diff --git a/crates/mobench-sdk/src/uniffi_types.rs b/crates/mobench-sdk/src/uniffi_types.rs index 009ab31..ed8e73e 100644 --- a/crates/mobench-sdk/src/uniffi_types.rs +++ b/crates/mobench-sdk/src/uniffi_types.rs @@ -186,6 +186,9 @@ pub struct BenchSampleTemplate { /// CPU time consumed by the measured iteration in milliseconds. pub cpu_time_ms: Option, /// Peak memory growth during the measured iteration in kilobytes. + /// + /// This is the legacy wire field for baseline-adjusted growth, not + /// absolute process or device peak memory. pub peak_memory_kb: Option, } diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index 854a8d2..fd113f6 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -178,7 +178,9 @@ class MainActivity : AppCompatActivity() { resources.put("elapsed_cpu_ms", cpuTotalMs) } if (peakSamplesKb.isNotEmpty()) { - resources.put("peak_memory_kb", peakSamplesKb.maxOrNull() ?: 0L) + val peakGrowthKb = peakSamplesKb.maxOrNull() ?: 0L + resources.put("peak_memory_kb", peakGrowthKb) + resources.put("peak_memory_growth_kb", peakGrowthKb) } resources.put("total_pss_kb", memInfo.totalPss) resources.put("private_dirty_kb", memInfo.totalPrivateDirty) diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 90dd9d9..7df5cb8 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -264,12 +264,15 @@ cargo mobench ci run --target --function [OPTIONS] - `results.csv` `summary.md` renders one top-level table with `Wall mean / iter`, `Wall total`, -`CPU median / iter`, `CPU total`, `CPU / wall`, and `Peak memory`. +`CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, and +`Absolute peak`. `results.csv` includes: - `cpu_total_ms` - `cpu_median_ms` -- `peak_memory_kb` +- `peak_memory_kb` (legacy alias for peak growth) +- `peak_memory_growth_kb` +- `absolute_peak_memory_kb` Blank fields indicate that a resource metric was not available for that benchmark/device row. diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index e3c231b..e3ffcc6 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -1119,9 +1119,14 @@ struct BenchmarkResourceUsage { cpu_total_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] cpu_median_ms: Option, + /// Legacy alias for `peak_memory_growth_kb`. #[serde(skip_serializing_if = "Option::is_none")] peak_memory_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] + peak_memory_growth_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + absolute_peak_memory_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] total_pss_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] private_dirty_kb: Option, @@ -5271,6 +5276,8 @@ impl BenchmarkResourceUsage { self.cpu_total_ms.is_none() && self.cpu_median_ms.is_none() && self.peak_memory_kb.is_none() + && self.peak_memory_growth_kb.is_none() + && self.absolute_peak_memory_kb.is_none() && self.total_pss_kb.is_none() && self.private_dirty_kb.is_none() && self.native_heap_kb.is_none() @@ -5388,20 +5395,20 @@ fn median_u64(values: &[u64]) -> Option { }) } -fn raw_peak_memory_kb( - total_pss_kb: Option, - private_dirty_kb: Option, - native_heap_kb: Option, - java_heap_kb: Option, +fn mb_to_kb(value: f64) -> Option { + value + .is_finite() + .then_some(value) + .filter(|value| *value >= 0.0) + .map(|value| (value * 1024.0).round() as u64) +} + +fn browserstack_absolute_peak_memory_kb( + perf_metrics: Option<&browserstack::PerformanceMetrics>, ) -> Option { - total_pss_kb - .or(private_dirty_kb) - .or_else(|| match (native_heap_kb, java_heap_kb) { - (Some(native), Some(java)) => Some(native + java), - (Some(native), None) => Some(native), - (None, Some(java)) => Some(java), - (None, None) => None, - }) + perf_metrics + .and_then(|metrics| metrics.memory.as_ref()) + .and_then(|memory| mb_to_kb(memory.peak_mb)) } fn extract_benchmark_resource_usage( @@ -5447,31 +5454,33 @@ fn extract_benchmark_resource_usage( let java_heap_kb = resources .and_then(|res| res.get("java_heap_kb")) .and_then(json_value_to_u64); - let reported_peak_memory_kb = resources + let explicit_peak_memory_growth_kb = resources + .and_then(|res| res.get("peak_memory_growth_kb")) + .and_then(json_value_to_u64); + let legacy_peak_memory_kb = resources .and_then(|res| res.get("peak_memory_kb")) + .and_then(json_value_to_u64); + let peak_memory_growth_kb = explicit_peak_memory_growth_kb + .or(legacy_peak_memory_kb) + .or_else(|| sample_peak_memory_kb.iter().copied().max()); + let peak_memory_kb = peak_memory_growth_kb; + let absolute_peak_memory_kb = resources + .and_then(|res| res.get("absolute_peak_memory_kb")) .and_then(json_value_to_u64) + .or_else(|| browserstack_absolute_peak_memory_kb(perf_metrics)) .or_else(|| { resources .and_then(|res| res.get("ram_peak_mb")) .and_then(|value| value.as_f64()) - .map(|value| (value * 1024.0).round() as u64) - }); - - let peak_memory_kb = reported_peak_memory_kb - .or_else(|| sample_peak_memory_kb.iter().copied().max()) - .or_else(|| { - perf_metrics - .and_then(|metrics| metrics.memory.as_ref()) - .map(|memory| (memory.peak_mb * 1024.0).round() as u64) - }) - .or_else(|| { - raw_peak_memory_kb(total_pss_kb, private_dirty_kb, native_heap_kb, java_heap_kb) + .and_then(mb_to_kb) }); let resource_usage = BenchmarkResourceUsage { cpu_total_ms, cpu_median_ms, peak_memory_kb, + peak_memory_growth_kb, + absolute_peak_memory_kb, total_pss_kb, private_dirty_kb, native_heap_kb, @@ -5509,17 +5518,17 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { let _ = writeln!( output, - "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak memory |" + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Absolute peak |" ); let _ = writeln!( output, - "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" ); for device in &summary.device_summaries { for bench in &device.benchmarks { let _ = writeln!( output, - "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", + "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", device.device, bench.function, bench.samples, @@ -5533,12 +5542,22 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { bench .resource_usage .as_ref() - .and_then(|usage| usage.peak_memory_kb) - ) + .and_then(|usage| usage.peak_memory_growth_kb) + ), + format_peak_memory( + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.absolute_peak_memory_kb) + ), ); } } let _ = writeln!(output); + if summary_has_memory_baseline_gap(summary) { + let _ = writeln!(output, "_Note: {}_", MEMORY_BASELINE_GAP_NOTE); + let _ = writeln!(output); + } output } @@ -5547,13 +5566,13 @@ fn render_csv_summary(summary: &SummaryReport) -> String { let mut output = String::new(); let _ = writeln!( output, - "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb" + "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,absolute_peak_memory_kb" ); for device in &summary.device_summaries { for bench in &device.benchmarks { let _ = writeln!( output, - "{},{},{},{},{},{},{},{},{},{},{}", + "{},{},{},{},{},{},{},{},{},{},{},{},{}", device.device, bench.function, bench.samples, @@ -5576,6 +5595,16 @@ fn render_csv_summary(summary: &SummaryReport) -> String { .resource_usage .as_ref() .and_then(|usage| usage.peak_memory_kb) + .map_or(String::new(), |v| v.to_string()), + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.peak_memory_growth_kb) + .map_or(String::new(), |v| v.to_string()), + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.absolute_peak_memory_kb) .map_or(String::new(), |v| v.to_string()) ); } @@ -5659,6 +5688,32 @@ fn format_cpu_total_duration_ms(ms: u64) -> String { } } +const MEMORY_BASELINE_GAP_MIN_DIFF_KB: u64 = 256 * 1024; +const MEMORY_BASELINE_GAP_RATIO: u64 = 4; +const MEMORY_BASELINE_GAP_NOTE: &str = + "memory growth excludes warmup/baseline retained before the measured iteration."; + +fn summary_has_memory_baseline_gap(summary: &SummaryReport) -> bool { + summary.device_summaries.iter().any(|device| { + device.benchmarks.iter().any(|benchmark| { + benchmark + .resource_usage + .as_ref() + .is_some_and(resource_usage_has_memory_baseline_gap) + }) + }) +} + +fn resource_usage_has_memory_baseline_gap(usage: &BenchmarkResourceUsage) -> bool { + match (usage.peak_memory_growth_kb, usage.absolute_peak_memory_kb) { + (Some(growth), Some(absolute)) if absolute > growth => { + absolute.saturating_sub(growth) >= MEMORY_BASELINE_GAP_MIN_DIFF_KB + && absolute >= growth.saturating_mul(MEMORY_BASELINE_GAP_RATIO) + } + _ => false, + } +} + fn format_peak_memory(value_kb: Option) -> String { value_kb .map(|value| format!("{:.2} MB", value as f64 / 1024.0)) @@ -9484,6 +9539,8 @@ test_suite = "target/ios/BenchRunnerUITests.zip" cpu_total_ms: Some(482), cpu_median_ms: Some(241), peak_memory_kb: Some(249_416), + peak_memory_growth_kb: Some(249_416), + absolute_peak_memory_kb: None, total_pss_kb: None, private_dirty_kb: None, native_heap_kb: None, @@ -9494,7 +9551,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" }); assert!(markdown.contains( - "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak memory |" + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Absolute peak |" )); assert!(markdown.contains("1.250s")); assert!(markdown.contains("6.250s")); @@ -9528,6 +9585,8 @@ test_suite = "target/ios/BenchRunnerUITests.zip" cpu_total_ms: Some(800), cpu_median_ms: Some(200), peak_memory_kb: Some(1_024), + peak_memory_growth_kb: Some(1_024), + absolute_peak_memory_kb: None, total_pss_kb: None, private_dirty_kb: None, native_heap_kb: None, @@ -9538,10 +9597,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" }); assert!(markdown.contains( - "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak memory |" + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Absolute peak |" )); assert!(markdown.contains( - "| Google Pixel 8-14.0 | sample_fns::fibonacci | 4 | 1 | 1.000s | 4.000s | 200ms | 800ms | 20.0% | 1.00 MB |" + "| Google Pixel 8-14.0 | sample_fns::fibonacci | 4 | 1 | 1.000s | 4.000s | 200ms | 800ms | 20.0% | 1.00 MB | - |" )); assert!(!markdown.contains("### Device:")); } @@ -9570,6 +9629,8 @@ test_suite = "target/ios/BenchRunnerUITests.zip" cpu_total_ms: Some(482), cpu_median_ms: Some(241), peak_memory_kb: Some(249_416), + peak_memory_growth_kb: Some(249_416), + absolute_peak_memory_kb: Some(1_680_026), total_pss_kb: None, private_dirty_kb: None, native_heap_kb: None, @@ -9581,10 +9642,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert!( csv.starts_with( - "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb\n" + "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,absolute_peak_memory_kb\n" ) ); - assert!(csv.contains(",482,241,249416\n")); + assert!(csv.contains(",482,241,249416,249416,1680026\n")); } #[test] @@ -9611,6 +9672,8 @@ test_suite = "target/ios/BenchRunnerUITests.zip" cpu_total_ms: Some(482), cpu_median_ms: Some(241), peak_memory_kb: Some(654_321), + peak_memory_growth_kb: Some(654_321), + absolute_peak_memory_kb: None, total_pss_kb: Some(654_321), private_dirty_kb: None, native_heap_kb: None, @@ -9623,7 +9686,9 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert!(markdown.contains("CPU median / iter")); assert!(markdown.contains("CPU total")); assert!(markdown.contains("CPU / wall")); - assert!(markdown.contains("Peak memory")); + assert!(markdown.contains("Peak growth")); + assert!(markdown.contains("Absolute peak")); + assert!(!markdown.contains("Peak memory")); assert!(markdown.contains("241ms")); assert!(markdown.contains("482ms")); assert!(markdown.contains("7.7%")); @@ -9654,6 +9719,8 @@ test_suite = "target/ios/BenchRunnerUITests.zip" cpu_total_ms: Some(482), cpu_median_ms: Some(241), peak_memory_kb: Some(654_321), + peak_memory_growth_kb: Some(654_321), + absolute_peak_memory_kb: None, total_pss_kb: None, private_dirty_kb: None, native_heap_kb: None, @@ -9669,13 +9736,56 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert!(markdown.contains("CPU median / iter")); assert!(markdown.contains("CPU total")); assert!(markdown.contains("CPU / wall")); - assert!(markdown.contains("Peak memory")); + assert!(markdown.contains("Peak growth")); + assert!(markdown.contains("Absolute peak")); + assert!(!markdown.contains("Peak memory")); assert!(markdown.contains("241ms")); assert!(markdown.contains("482ms")); assert!(markdown.contains("7.7%")); assert!(markdown.contains("638.99 MB")); } + #[test] + fn render_markdown_summary_notes_large_absolute_memory_baseline_gap() { + let markdown = render_markdown_summary(&SummaryReport { + generated_at: "2026-04-12T00:00:00Z".to_string(), + generated_at_unix: 1_744_416_000, + target: MobileTarget::Android, + function: "sample_fns::fibonacci".to_string(), + iterations: 5, + warmup: 1, + devices: vec!["Motorola Moto G9 Play-11.0".to_string()], + device_summaries: vec![DeviceSummary { + device: "Motorola Moto G9 Play-11.0".to_string(), + benchmarks: vec![BenchmarkStats { + function: "sample_fns::fibonacci".to_string(), + samples: 5, + mean_ns: Some(1_250_000_000), + median_ns: Some(1_200_000_000), + p95_ns: Some(1_300_000_000), + min_ns: Some(1_100_000_000), + max_ns: Some(1_350_000_000), + resource_usage: Some(BenchmarkResourceUsage { + cpu_total_ms: None, + cpu_median_ms: None, + peak_memory_kb: Some(171_556), + peak_memory_growth_kb: Some(171_556), + absolute_peak_memory_kb: Some(1_680_026), + total_pss_kb: Some(1_477_787), + private_dirty_kb: Some(1_462_460), + native_heap_kb: None, + java_heap_kb: None, + }), + }], + }], + }); + + assert!(markdown.contains("Peak growth")); + assert!(markdown.contains("Absolute peak")); + assert!(markdown.contains(MEMORY_BASELINE_GAP_NOTE)); + assert!(!markdown.contains("Peak memory")); + } + #[test] fn build_summary_preserves_resource_usage_from_benchmark_results() { let spec = RunSpec { @@ -9717,6 +9827,8 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert_eq!(usage.cpu_total_ms, Some(37)); assert_eq!(usage.cpu_median_ms, Some(11)); assert_eq!(usage.peak_memory_kb, Some(96)); + assert_eq!(usage.peak_memory_growth_kb, Some(96)); + assert_eq!(usage.absolute_peak_memory_kb, None); } #[test] @@ -9769,6 +9881,8 @@ test_suite = "target/ios/BenchRunnerUITests.zip" .expect("resource usage"); assert_eq!(usage.peak_memory_kb, Some(72)); + assert_eq!(usage.peak_memory_growth_kb, Some(72)); + assert_eq!(usage.absolute_peak_memory_kb, Some(1_022_976)); } #[test] @@ -10238,8 +10352,8 @@ mod ci_merge_tests { assert!(markdown.starts_with("### Benchmark Summary\n")); assert!(markdown.contains("- Target: iOS")); - assert!(markdown.contains("| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak memory |")); - assert!(markdown.contains("| iPhone 13 | ffi_benchmark::bench_fibonacci | 5 | 1 | 0.017ms | 0.085ms | - | - | - | - |")); + assert!(markdown.contains("| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Absolute peak |")); + assert!(markdown.contains("| iPhone 13 | ffi_benchmark::bench_fibonacci | 5 | 1 | 0.017ms | 0.085ms | - | - | - | - | - |")); assert!(!markdown.contains("### Device:")); } @@ -10488,7 +10602,9 @@ mod ci_merge_tests { let resource_usage = &value["device_summaries"][0]["benchmarks"][0]["resource_usage"]; assert_eq!(resource_usage["cpu_total_ms"], 482); - assert_eq!(resource_usage["peak_memory_kb"], 654321); + assert_eq!(resource_usage["peak_memory_kb"], Value::Null); + assert_eq!(resource_usage["peak_memory_growth_kb"], Value::Null); + assert_eq!(resource_usage["absolute_peak_memory_kb"], Value::Null); assert_eq!(resource_usage["total_pss_kb"], 654321); assert_eq!(resource_usage["private_dirty_kb"], 321000); assert_eq!(resource_usage["native_heap_kb"], 120000); @@ -10550,7 +10666,9 @@ mod ci_merge_tests { let value = serde_json::to_value(summary).expect("serialize summary"); let resource_usage = &value["device_summaries"][0]["benchmarks"][0]["resource_usage"]; - assert_eq!(resource_usage["peak_memory_kb"], 249416); + assert_eq!(resource_usage["peak_memory_kb"], Value::Null); + assert_eq!(resource_usage["peak_memory_growth_kb"], Value::Null); + assert_eq!(resource_usage["absolute_peak_memory_kb"], 249416); assert_eq!(resource_usage["cpu_total_ms"], Value::Null); } } @@ -10643,12 +10761,13 @@ mod resource_usage_tests { assert_eq!(usage.private_dirty_kb, Some(2048)); assert_eq!(usage.native_heap_kb, Some(1024)); assert_eq!(usage.java_heap_kb, Some(512)); - // peak_memory_kb derived from raw fields when no perf_metrics - assert!(usage.peak_memory_kb.is_some()); + assert_eq!(usage.peak_memory_kb, None); + assert_eq!(usage.peak_memory_growth_kb, None); + assert_eq!(usage.absolute_peak_memory_kb, None); } #[test] - fn test_extract_resource_usage_with_perf_metrics_overrides_peak() { + fn test_extract_resource_usage_with_perf_metrics_sets_absolute_peak() { let entry = json!({ "resources": { "total_pss_kb": 4096 @@ -10666,11 +10785,45 @@ mod resource_usage_tests { }; let usage = extract_benchmark_resource_usage(&entry, Some(&perf)).unwrap(); - // peak_memory_kb should come from perf_metrics (10.0 * 1024 = 10240) - assert_eq!(usage.peak_memory_kb, Some(10240)); + assert_eq!(usage.peak_memory_kb, None); + assert_eq!(usage.peak_memory_growth_kb, None); + assert_eq!(usage.absolute_peak_memory_kb, Some(10240)); assert_eq!(usage.total_pss_kb, Some(4096)); } + #[test] + fn test_extract_resource_usage_preserves_moto_growth_and_absolute_peak() { + let entry = json!({ + "resources": { + "peak_memory_kb": 171556, + "total_pss_kb": 1477787, + "private_dirty_kb": 1462460, + "native_heap_kb": 532000, + "java_heap_kb": 212000 + } + }); + let perf = browserstack::PerformanceMetrics { + sample_count: 5, + memory: Some(browserstack::AggregateMemoryMetrics { + peak_mb: 1640.65, + average_mb: 1500.0, + min_mb: 1400.0, + }), + cpu: None, + snapshots: vec![], + }; + + let usage = extract_benchmark_resource_usage(&entry, Some(&perf)).unwrap(); + + assert_eq!(usage.peak_memory_growth_kb, Some(171_556)); + assert_eq!(usage.peak_memory_kb, Some(171_556)); + assert_eq!(usage.absolute_peak_memory_kb, Some(1_680_026)); + assert_eq!(usage.total_pss_kb, Some(1_477_787)); + assert_eq!(usage.private_dirty_kb, Some(1_462_460)); + assert_eq!(usage.native_heap_kb, Some(532_000)); + assert_eq!(usage.java_heap_kb, Some(212_000)); + } + #[test] fn test_extract_resource_usage_empty_returns_none() { let entry = json!({}); @@ -10684,6 +10837,8 @@ mod resource_usage_tests { cpu_total_ms: Some(250), cpu_median_ms: Some(125), peak_memory_kb: Some(8192), + peak_memory_growth_kb: Some(8192), + absolute_peak_memory_kb: Some(16384), total_pss_kb: Some(4096), private_dirty_kb: Some(2048), native_heap_kb: Some(1024), @@ -10696,6 +10851,8 @@ mod resource_usage_tests { assert_eq!(deserialized.cpu_total_ms, Some(250)); assert_eq!(deserialized.cpu_median_ms, Some(125)); assert_eq!(deserialized.peak_memory_kb, Some(8192)); + assert_eq!(deserialized.peak_memory_growth_kb, Some(8192)); + assert_eq!(deserialized.absolute_peak_memory_kb, Some(16384)); assert_eq!(deserialized.total_pss_kb, Some(4096)); assert_eq!(deserialized.private_dirty_kb, Some(2048)); assert_eq!(deserialized.native_heap_kb, Some(1024)); @@ -10703,5 +10860,8 @@ mod resource_usage_tests { // java_heap_kb should be absent in JSON due to skip_serializing_if assert!(!json_str.contains("java_heap_kb")); + assert!(json_str.contains("peak_memory_kb")); + assert!(json_str.contains("peak_memory_growth_kb")); + assert!(json_str.contains("absolute_peak_memory_kb")); } } diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index d7ca7ce..140eb20 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -56,14 +56,19 @@ pub struct TimingStats { pub std_dev_ms: Option, } -/// Resource usage metrics from BrowserStack session. +/// Resource usage metrics from SDK reports and provider sessions. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceUsage { #[serde(skip_serializing_if = "Option::is_none")] pub cpu_total_ms: Option, + /// Legacy alias for `peak_memory_growth_kb`. #[serde(skip_serializing_if = "Option::is_none")] pub peak_memory_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub peak_memory_growth_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub absolute_peak_memory_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub total_pss_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] pub private_dirty_kb: Option, @@ -608,6 +613,8 @@ impl ResourceUsage { fn is_empty(&self) -> bool { self.cpu_total_ms.is_none() && self.peak_memory_kb.is_none() + && self.peak_memory_growth_kb.is_none() + && self.absolute_peak_memory_kb.is_none() && self.total_pss_kb.is_none() && self.private_dirty_kb.is_none() && self.native_heap_kb.is_none() @@ -621,6 +628,12 @@ impl ResourceUsage { if self.peak_memory_kb.is_none() { self.peak_memory_kb = other.peak_memory_kb; } + if self.peak_memory_growth_kb.is_none() { + self.peak_memory_growth_kb = other.peak_memory_growth_kb; + } + if self.absolute_peak_memory_kb.is_none() { + self.absolute_peak_memory_kb = other.absolute_peak_memory_kb; + } if self.total_pss_kb.is_none() { self.total_pss_kb = other.total_pss_kb; } @@ -655,22 +668,27 @@ fn parse_resource_usage_object(value: &serde_json::Value) -> Option Option { }) } -fn raw_peak_memory_kb( - total_pss_kb: Option, - private_dirty_kb: Option, - native_heap_kb: Option, - java_heap_kb: Option, -) -> Option { - total_pss_kb - .or(private_dirty_kb) - .or_else(|| match (native_heap_kb, java_heap_kb) { - (Some(native), Some(java)) => Some(native + java), - (Some(native), None) => Some(native), - (None, Some(java)) => Some(java), - (None, None) => None, - }) +fn mb_to_kb(value: f64) -> Option { + value + .is_finite() + .then_some(value) + .filter(|value| *value >= 0.0) + .map(|value| (value * 1024.0).round() as u64) } fn format_cpu_total_ms(value: Option) -> String { @@ -720,6 +730,30 @@ fn format_cpu_total_ms(value: Option) -> String { .unwrap_or_else(|| "—".to_string()) } +const MEMORY_BASELINE_GAP_MIN_DIFF_KB: u64 = 256 * 1024; +const MEMORY_BASELINE_GAP_RATIO: u64 = 4; +const MEMORY_BASELINE_GAP_NOTE: &str = + "memory growth excludes warmup/baseline retained before the measured iteration."; + +fn platform_has_memory_baseline_gap(platform: &PlatformReport) -> bool { + platform.benchmarks.iter().any(|benchmark| { + benchmark + .resource_usage + .as_ref() + .is_some_and(resource_usage_has_memory_baseline_gap) + }) +} + +fn resource_usage_has_memory_baseline_gap(usage: &ResourceUsage) -> bool { + match (usage.peak_memory_growth_kb, usage.absolute_peak_memory_kb) { + (Some(growth), Some(absolute)) if absolute > growth => { + absolute.saturating_sub(growth) >= MEMORY_BASELINE_GAP_MIN_DIFF_KB + && absolute >= growth.saturating_mul(MEMORY_BASELINE_GAP_RATIO) + } + _ => false, + } +} + fn format_peak_memory(value_kb: Option) -> String { value_kb .map(|value| format!("{:.2} MB", value as f64 / 1024.0)) @@ -772,7 +806,7 @@ fn render_platform_table(platform: &PlatformReport) -> String { let mut headers = vec!["Benchmark", "Avg ms", "Best", "Worst", "Median", "P95"]; if has_resource_usage { - headers.extend(["CPU total", "Peak memory"]); + headers.extend(["CPU total", "Peak growth", "Absolute peak"]); } table.set_header( headers @@ -793,10 +827,12 @@ fn render_platform_table(platform: &PlatformReport) -> String { if has_resource_usage { if let Some(ru) = &bench.resource_usage { row.push(Cell::new(format_cpu_total_ms(ru.cpu_total_ms))); - row.push(Cell::new(format_peak_memory(ru.peak_memory_kb))); + row.push(Cell::new(format_peak_memory(ru.peak_memory_growth_kb))); + row.push(Cell::new(format_peak_memory(ru.absolute_peak_memory_kb))); } else { row.push(Cell::new("—")); row.push(Cell::new("—")); + row.push(Cell::new("—")); } } @@ -808,6 +844,9 @@ fn render_platform_table(platform: &PlatformReport) -> String { "\n {} iterations · {} warmup · avg is primary metric\n", platform.iterations, platform.warmup )); + if platform_has_memory_baseline_gap(platform) { + output.push_str(&format!(" Note: {MEMORY_BASELINE_GAP_NOTE}\n")); + } output } @@ -844,10 +883,10 @@ pub fn render_markdown(report: &SummarizeReport) -> String { if has_ru { output.push_str( - "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU total | Peak memory |\n", + "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU total | Peak growth | Absolute peak |\n", ); output.push_str( - "|-----------|--------|------|-------|--------|-----|-----------|-------------|\n", + "|-----------|--------|------|-------|--------|-----|-----------|-------------|---------------|\n", ); } else { output.push_str("| Benchmark | Avg ms | Best | Worst | Median | P95 |\n"); @@ -868,12 +907,13 @@ pub fn render_markdown(report: &SummarizeReport) -> String { if has_ru { if let Some(ru) = &bench.resource_usage { row.push_str(&format!( - " {} | {} |", + " {} | {} | {} |", format_cpu_total_ms(ru.cpu_total_ms), - format_peak_memory(ru.peak_memory_kb), + format_peak_memory(ru.peak_memory_growth_kb), + format_peak_memory(ru.absolute_peak_memory_kb), )); } else { - row.push_str(" — | — |"); + row.push_str(" — | — | — |"); } } @@ -885,6 +925,9 @@ pub fn render_markdown(report: &SummarizeReport) -> String { "\n*{} iterations · {} warmup · avg is primary metric*\n", platform.iterations, platform.warmup )); + if platform_has_memory_baseline_gap(platform) { + output.push_str(&format!("\n_Note: {MEMORY_BASELINE_GAP_NOTE}_\n")); + } } output @@ -912,22 +955,24 @@ pub fn enrich_with_browserstack( // Enrich benchmarks with performance metrics if let Some(perf) = &session.performance { for bench in &mut platform.benchmarks { - let peak_memory_kb = perf + let absolute_peak_memory_kb = perf .memory .as_ref() - .map(|memory| (memory.peak_mb * 1024.0).round() as u64); - if peak_memory_kb.is_none() { + .and_then(|memory| mb_to_kb(memory.peak_mb)); + if absolute_peak_memory_kb.is_none() { continue; } let resource_usage = bench.resource_usage.get_or_insert(ResourceUsage { cpu_total_ms: None, peak_memory_kb: None, + peak_memory_growth_kb: None, + absolute_peak_memory_kb: None, total_pss_kb: None, private_dirty_kb: None, native_heap_kb: None, java_heap_kb: None, }); - resource_usage.peak_memory_kb = peak_memory_kb; + resource_usage.absolute_peak_memory_kb = absolute_peak_memory_kb; } } } @@ -1158,6 +1203,13 @@ mod tests { .resource_usage .as_ref() .and_then(|usage| usage.peak_memory_kb), + None + ); + assert_eq!( + alpha + .resource_usage + .as_ref() + .and_then(|usage| usage.total_pss_kb), Some(222222) ); assert_eq!( @@ -1170,6 +1222,12 @@ mod tests { beta.resource_usage .as_ref() .and_then(|usage| usage.peak_memory_kb), + None + ); + assert_eq!( + beta.resource_usage + .as_ref() + .and_then(|usage| usage.total_pss_kb), Some(444444) ); } @@ -1285,6 +1343,8 @@ mod tests { resource_usage: Some(ResourceUsage { cpu_total_ms: Some(482), peak_memory_kb: Some(654321), + peak_memory_growth_kb: Some(654321), + absolute_peak_memory_kb: None, total_pss_kb: Some(654321), private_dirty_kb: Some(321000), native_heap_kb: Some(120000), @@ -1372,7 +1432,9 @@ mod tests { assert!(output.contains("CPU total")); assert!(!output.contains("CPU total (ms)")); - assert!(output.contains("Peak memory")); + assert!(output.contains("Peak growth")); + assert!(output.contains("Absolute peak")); + assert!(!output.contains("Peak memory")); assert!(output.contains("| 1.482s |")); assert!(output.contains("638.99 MB")); assert!(!output.contains("CPU %")); @@ -1414,13 +1476,56 @@ mod tests { assert!(output.contains("CPU total")); assert!(!output.contains("CPU total (ms)")); - assert!(output.contains("Peak memory")); + assert!(output.contains("Peak growth")); + assert!(output.contains("Absolute peak")); + assert!(!output.contains("Peak memory")); assert!(output.contains("1.482s")); assert!(output.contains("638.99 MB")); assert!(!output.contains("CPU %")); assert!(!output.contains("RAM MB")); } + #[test] + fn test_render_markdown_notes_large_absolute_memory_baseline_gap() { + let report = parse_summary_value(&json!({ + "summary": { + "generated_at": "2026-02-26T12:00:00Z", + "target": "android", + "function": "bench_nullifier_proving_only", + "iterations": 30, + "warmup": 5, + "devices": ["Motorola Moto G9 Play-11.0"], + "device_summaries": [{ + "device": "Motorola Moto G9 Play-11.0", + "benchmarks": [{ + "function": "bench_nullifier_proving_only", + "samples": 30, + "mean_ns": 1204500000_u64, + "median_ns": 1198000000_u64, + "p95_ns": 1290000000_u64, + "min_ns": 1180200000_u64, + "max_ns": 1298100000_u64, + "resource_usage": { + "peak_memory_kb": 171556, + "peak_memory_growth_kb": 171556, + "absolute_peak_memory_kb": 1680026, + "total_pss_kb": 1477787, + "private_dirty_kb": 1462460 + } + }] + }] + } + })) + .unwrap(); + + let output = render_markdown(&report); + + assert!(output.contains("Peak growth")); + assert!(output.contains("Absolute peak")); + assert!(output.contains(MEMORY_BASELINE_GAP_NOTE)); + assert!(!output.contains("Peak memory")); + } + #[test] fn test_format_cpu_total_ms_uses_seconds_without_switching_to_minutes() { assert_eq!(format_cpu_total_ms(Some(482)), "482ms"); @@ -1554,11 +1659,11 @@ mod tests { let pixel_memory = report.platforms[0].benchmarks[0] .resource_usage .as_ref() - .and_then(|usage| usage.peak_memory_kb); + .and_then(|usage| usage.absolute_peak_memory_kb); let samsung_memory = report.platforms[1].benchmarks[0] .resource_usage .as_ref() - .and_then(|usage| usage.peak_memory_kb); + .and_then(|usage| usage.absolute_peak_memory_kb); assert_eq!(pixel_memory, Some(102_400)); assert_eq!(samsung_memory, Some(204_800)); diff --git a/docs/schemas/summary-v1.schema.json b/docs/schemas/summary-v1.schema.json index 09659f0..ce24e60 100644 --- a/docs/schemas/summary-v1.schema.json +++ b/docs/schemas/summary-v1.schema.json @@ -54,6 +54,8 @@ "cpu_total_ms": { "type": ["integer", "null"] }, "cpu_median_ms": { "type": ["integer", "null"] }, "peak_memory_kb": { "type": ["integer", "null"] }, + "peak_memory_growth_kb": { "type": ["integer", "null"] }, + "absolute_peak_memory_kb": { "type": ["integer", "null"] }, "total_pss_kb": { "type": ["integer", "null"] }, "private_dirty_kb": { "type": ["integer", "null"] }, "native_heap_kb": { "type": ["integer", "null"] }, From 6df1e931a07469e02be849bfa0f4320b73f4b9d4 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 22 Apr 2026 11:08:12 +0200 Subject: [PATCH 168/196] Report benchmark process memory peak --- crates/mobench-sdk/src/codegen.rs | 6 ++ crates/mobench-sdk/src/ffi.rs | 9 ++ crates/mobench-sdk/src/timing.rs | 68 ++++++++++++-- crates/mobench-sdk/src/uniffi_types.rs | 7 ++ .../src/main/java/MainActivity.kt.template | 5 + .../BenchRunner/BenchRunnerFFI.swift.template | 8 ++ crates/mobench/README.md | 5 +- crates/mobench/src/lib.rs | 94 ++++++++++++++----- crates/mobench/src/summarize.rs | 49 +++++++--- crates/sample-fns/src/lib.rs | 2 + docs/MIGRATION_GUIDE.md | 5 +- docs/guides/browserstack-ci.md | 4 +- docs/guides/browserstack-metrics.md | 10 +- docs/schemas/summary-v1.schema.json | 1 + 14 files changed, 218 insertions(+), 55 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 19f8120..abcab33 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -166,6 +166,9 @@ pub struct BenchSpec { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct BenchSample { pub duration_ns: u64, + pub cpu_time_ms: Option, + pub peak_memory_kb: Option, + pub process_peak_memory_kb: Option, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] @@ -228,6 +231,9 @@ impl From for BenchSample { fn from(sample: mobench_sdk::BenchSample) -> Self { Self { duration_ns: sample.duration_ns, + cpu_time_ms: sample.cpu_time_ms, + peak_memory_kb: sample.peak_memory_kb, + process_peak_memory_kb: sample.process_peak_memory_kb, } } } diff --git a/crates/mobench-sdk/src/ffi.rs b/crates/mobench-sdk/src/ffi.rs index d7b9083..e8c1da0 100644 --- a/crates/mobench-sdk/src/ffi.rs +++ b/crates/mobench-sdk/src/ffi.rs @@ -82,6 +82,8 @@ pub struct BenchSampleFfi { /// This is the legacy wire field for baseline-adjusted growth, not /// absolute process or device peak memory. pub peak_memory_kb: Option, + /// Peak resident memory of the benchmark process during the measured iteration. + pub process_peak_memory_kb: Option, } impl From for BenchSampleFfi { @@ -90,6 +92,7 @@ impl From for BenchSampleFfi { duration_ns: sample.duration_ns, cpu_time_ms: sample.cpu_time_ms, peak_memory_kb: sample.peak_memory_kb, + process_peak_memory_kb: sample.process_peak_memory_kb, } } } @@ -100,6 +103,7 @@ impl From for crate::BenchSample { duration_ns: sample.duration_ns, cpu_time_ms: sample.cpu_time_ms, peak_memory_kb: sample.peak_memory_kb, + process_peak_memory_kb: sample.process_peak_memory_kb, } } } @@ -277,11 +281,13 @@ mod tests { duration_ns: 12345, cpu_time_ms: Some(12), peak_memory_kb: Some(48), + process_peak_memory_kb: Some(1024), }; let ffi: BenchSampleFfi = sdk_sample.into(); assert_eq!(ffi.duration_ns, 12345); assert_eq!(ffi.cpu_time_ms, Some(12)); assert_eq!(ffi.peak_memory_kb, Some(48)); + assert_eq!(ffi.process_peak_memory_kb, Some(1024)); } #[test] @@ -297,11 +303,13 @@ mod tests { duration_ns: 100, cpu_time_ms: Some(3), peak_memory_kb: Some(8), + process_peak_memory_kb: Some(108), }, crate::BenchSample { duration_ns: 200, cpu_time_ms: Some(5), peak_memory_kb: Some(13), + process_peak_memory_kb: Some(113), }, ], phases: vec![crate::SemanticPhase { @@ -322,6 +330,7 @@ mod tests { assert_eq!(ffi.samples[0].duration_ns, 100); assert_eq!(ffi.samples[0].cpu_time_ms, Some(3)); assert_eq!(ffi.samples[0].peak_memory_kb, Some(8)); + assert_eq!(ffi.samples[0].process_peak_memory_kb, Some(108)); assert_eq!(ffi.phases.len(), 1); assert_eq!(ffi.phases[0].name, "prove"); assert_eq!(ffi.timeline.len(), 1); diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index 4229504..ba8cfcb 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -209,6 +209,13 @@ pub struct BenchSample { /// iteration, not absolute process or device peak memory. #[serde(default, skip_serializing_if = "Option::is_none")] pub peak_memory_kb: Option, + + /// Peak resident memory of the benchmark process during the measured iteration. + /// + /// This is sampled from the current process while the measured closure is + /// running. Unlike `peak_memory_kb`, it is not baseline-adjusted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_peak_memory_kb: Option, } impl BenchSample { @@ -217,6 +224,7 @@ impl BenchSample { duration_ns: duration.as_nanos() as u64, cpu_time_ms: resources.cpu_time_ms, peak_memory_kb: resources.peak_memory_kb, + process_peak_memory_kb: resources.process_peak_memory_kb, } } } @@ -415,6 +423,18 @@ impl BenchReport { self.peak_memory_kb() } + /// Returns the maximum process resident memory peak in kilobytes. + /// + /// This reports the current benchmark process peak sampled during measured + /// iterations. It excludes BrowserStack/session-level provider memory. + #[must_use] + pub fn process_peak_memory_kb(&self) -> Option { + self.samples + .iter() + .filter_map(|sample| sample.process_peak_memory_kb) + .max() + } + /// Returns a statistical summary of the benchmark results. #[must_use] pub fn summary(&self) -> BenchSummary { @@ -437,6 +457,7 @@ impl BenchReport { struct IterationResourceUsage { cpu_time_ms: Option, peak_memory_kb: Option, + process_peak_memory_kb: Option, } fn instant_offset_ns(origin: Instant, instant: Instant) -> u64 { @@ -636,12 +657,14 @@ impl ResourceMonitor for DefaultResourceMonitor { .zip(current_process_cpu_time()) .and_then(|(start, end)| process_cpu_delta_ms(start, end)); + let memory_peak = token.memory_sampler.and_then(MemoryPeakSampler::stop); + IterationResourceUsage { cpu_time_ms, - peak_memory_kb: token - .memory_sampler - .and_then(MemoryPeakSampler::stop) - .filter(|value| *value > 0), + peak_memory_kb: memory_peak + .and_then(|peak| (peak.growth_kb > 0).then_some(peak.growth_kb)), + process_peak_memory_kb: memory_peak + .and_then(|peak| (peak.process_peak_kb > 0).then_some(peak.process_peak_kb)), } } } @@ -695,6 +718,12 @@ fn current_process_cpu_time() -> Option { const MEMORY_SAMPLER_INTERVAL: Duration = Duration::from_millis(1); type MemoryReader = Arc Option + Send + Sync + 'static>; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ProcessMemoryPeak { + growth_kb: u64, + process_peak_kb: u64, +} + struct MemoryPeakSampler { baseline_kb: u64, stop_flag: Arc, @@ -772,11 +801,14 @@ impl MemoryPeakSampler { }) } - fn stop(self) -> Option { + fn stop(self) -> Option { self.stop_flag.store(true, Ordering::Release); let _ = self.handle.join(); let peak_kb = self.peak_kb.load(Ordering::Acquire); - Some(peak_kb.saturating_sub(self.baseline_kb)) + Some(ProcessMemoryPeak { + growth_kb: peak_kb.saturating_sub(self.baseline_kb), + process_peak_kb: peak_kb, + }) } } @@ -1565,6 +1597,7 @@ mod tests { duration_ns: 1_000_000, cpu_time_ms: Some(42), peak_memory_kb: Some(512), + process_peak_memory_kb: Some(1536), }], phases: vec![SemanticPhase { name: "prove".to_string(), @@ -1580,6 +1613,7 @@ mod tests { let json = serde_json::to_string(&report).unwrap(); assert!(json.contains("\"peak_memory_kb\"")); + assert!(json.contains("\"process_peak_memory_kb\"")); assert!(!json.contains("peak_memory_growth_kb")); let restored: BenchReport = serde_json::from_str(&json).unwrap(); @@ -1587,6 +1621,7 @@ mod tests { assert_eq!(restored.samples.len(), 1); assert_eq!(restored.samples[0].cpu_time_ms, Some(42)); assert_eq!(restored.samples[0].peak_memory_kb, Some(512)); + assert_eq!(restored.samples[0].process_peak_memory_kb, Some(1536)); assert_eq!(restored.phases.len(), 1); assert_eq!(restored.phases[0].name, "prove"); assert!(restored.phases[0].duration_ns > 0); @@ -1649,10 +1684,12 @@ mod tests { IterationResourceUsage { cpu_time_ms: Some(11), peak_memory_kb: Some(32), + ..Default::default() }, IterationResourceUsage { cpu_time_ms: Some(17), peak_memory_kb: Some(64), + ..Default::default() }, ]); let mut calls = 0_u32; @@ -1684,10 +1721,12 @@ mod tests { IterationResourceUsage { cpu_time_ms: Some(5), peak_memory_kb: Some(12), + ..Default::default() }, IterationResourceUsage { cpu_time_ms: Some(7), peak_memory_kb: Some(18), + ..Default::default() }, ]); @@ -1722,6 +1761,7 @@ mod tests { let mut monitor = FakeResourceMonitor::new(vec![IterationResourceUsage { cpu_time_ms: Some(42), peak_memory_kb: Some(24), + ..Default::default() }]); let report = run_closure_with_monitor(spec, &mut monitor, || Ok(())).unwrap(); @@ -1738,14 +1778,17 @@ mod tests { IterationResourceUsage { cpu_time_ms: Some(19), peak_memory_kb: Some(10), + ..Default::default() }, IterationResourceUsage { cpu_time_ms: Some(7), peak_memory_kb: Some(30), + ..Default::default() }, IterationResourceUsage { cpu_time_ms: Some(11), peak_memory_kb: Some(20), + ..Default::default() }, ]); @@ -1762,10 +1805,12 @@ mod tests { IterationResourceUsage { cpu_time_ms: Some(3), peak_memory_kb: Some(48), + process_peak_memory_kb: Some(1_048), }, IterationResourceUsage { cpu_time_ms: Some(4), peak_memory_kb: Some(96), + process_peak_memory_kb: Some(1_096), }, ]); @@ -1788,6 +1833,7 @@ mod tests { ); assert_eq!(report.peak_memory_kb(), Some(96)); assert_eq!(report.peak_memory_growth_kb(), report.peak_memory_kb()); + assert_eq!(report.process_peak_memory_kb(), Some(1_096)); } #[test] @@ -1811,9 +1857,15 @@ mod tests { }); let sampler = MemoryPeakSampler::start_with_reader(reader).expect("sampler"); - let peak_kb = sampler.stop().expect("peak memory"); + let peak = sampler.stop().expect("peak memory"); - assert_eq!(peak_kb, 40); + assert_eq!( + peak, + ProcessMemoryPeak { + growth_kb: 40, + process_peak_kb: 140, + } + ); } #[test] diff --git a/crates/mobench-sdk/src/uniffi_types.rs b/crates/mobench-sdk/src/uniffi_types.rs index ed8e73e..8b55cb3 100644 --- a/crates/mobench-sdk/src/uniffi_types.rs +++ b/crates/mobench-sdk/src/uniffi_types.rs @@ -40,6 +40,7 @@ //! pub duration_ns: u64, //! pub cpu_time_ms: Option, //! pub peak_memory_kb: Option, +//! pub process_peak_memory_kb: Option, //! } //! //! #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] @@ -190,6 +191,8 @@ pub struct BenchSampleTemplate { /// This is the legacy wire field for baseline-adjusted growth, not /// absolute process or device peak memory. pub peak_memory_kb: Option, + /// Peak resident memory of the benchmark process during the measured iteration. + pub process_peak_memory_kb: Option, } impl From for BenchSampleTemplate { @@ -198,6 +201,7 @@ impl From for BenchSampleTemplate { duration_ns: sample.duration_ns, cpu_time_ms: sample.cpu_time_ms, peak_memory_kb: sample.peak_memory_kb, + process_peak_memory_kb: sample.process_peak_memory_kb, } } } @@ -208,6 +212,7 @@ impl From for crate::BenchSample { duration_ns: sample.duration_ns, cpu_time_ms: sample.cpu_time_ms, peak_memory_kb: sample.peak_memory_kb, + process_peak_memory_kb: sample.process_peak_memory_kb, } } } @@ -354,11 +359,13 @@ mod tests { duration_ns: 12345, cpu_time_ms: Some(12), peak_memory_kb: Some(48), + process_peak_memory_kb: Some(1024), }; let template: BenchSampleTemplate = sdk_sample.into(); assert_eq!(template.duration_ns, 12345); assert_eq!(template.cpu_time_ms, Some(12)); assert_eq!(template.peak_memory_kb, Some(48)); + assert_eq!(template.process_peak_memory_kb, Some(1024)); } #[test] diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index fd113f6..d6de21b 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -140,6 +140,7 @@ class MainActivity : AppCompatActivity() { sampleJson.put("duration_ns", sample.durationNs.toLong()) sample.cpuTimeMs?.let { sampleJson.put("cpu_time_ms", it.toLong()) } sample.peakMemoryKb?.let { sampleJson.put("peak_memory_kb", it.toLong()) } + sample.processPeakMemoryKb?.let { sampleJson.put("process_peak_memory_kb", it.toLong()) } samples.put(sampleJson) } json.put("samples", samples) @@ -166,6 +167,7 @@ class MainActivity : AppCompatActivity() { val cpuSamplesMs = report.samples.mapNotNull { it.cpuTimeMs?.toLong() } val peakSamplesKb = report.samples.mapNotNull { it.peakMemoryKb?.toLong() } + val processPeakSamplesKb = report.samples.mapNotNull { it.processPeakMemoryKb?.toLong() } val memInfo = Debug.MemoryInfo() Debug.getMemoryInfo(memInfo) val resources = JSONObject() @@ -182,6 +184,9 @@ class MainActivity : AppCompatActivity() { resources.put("peak_memory_kb", peakGrowthKb) resources.put("peak_memory_growth_kb", peakGrowthKb) } + if (processPeakSamplesKb.isNotEmpty()) { + resources.put("process_peak_memory_kb", processPeakSamplesKb.maxOrNull() ?: 0L) + } resources.put("total_pss_kb", memInfo.totalPss) resources.put("private_dirty_kb", memInfo.totalPrivateDirty) resources.put("native_heap_kb", Debug.getNativeHeapAllocatedSize() / 1024) diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index 8f8e0c0..dd6838e 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -175,6 +175,9 @@ enum {{PROJECT_NAME_PASCAL}}FFI { if let peakMemoryKb = sample.peakMemoryKb { sampleJson["peak_memory_kb"] = peakMemoryKb } + if let processPeakMemoryKb = sample.processPeakMemoryKb { + sampleJson["process_peak_memory_kb"] = processPeakMemoryKb + } return sampleJson } json["samples"] = samplesArray @@ -221,6 +224,7 @@ enum {{PROJECT_NAME_PASCAL}}FFI { let cpuSamplesMs = report.samples.compactMap(\.cpuTimeMs) let peakSamplesKb = report.samples.compactMap(\.peakMemoryKb) + let processPeakSamplesKb = report.samples.compactMap(\.processPeakMemoryKb) func median(_ values: [UInt64]) -> UInt64 { let sorted = values.sorted() @@ -244,6 +248,10 @@ enum {{PROJECT_NAME_PASCAL}}FFI { } if let peakMemoryKb = peakSamplesKb.max() { resources["peak_memory_kb"] = peakMemoryKb + resources["peak_memory_growth_kb"] = peakMemoryKb + } + if let processPeakMemoryKb = processPeakSamplesKb.max() { + resources["process_peak_memory_kb"] = processPeakMemoryKb } json["resources"] = resources diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 7df5cb8..d83379f 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -265,14 +265,15 @@ cargo mobench ci run --target --function [OPTIONS] `summary.md` renders one top-level table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, and -`Absolute peak`. +`Process peak`, plus `Provider peak` when provider/session telemetry is present. `results.csv` includes: - `cpu_total_ms` - `cpu_median_ms` - `peak_memory_kb` (legacy alias for peak growth) - `peak_memory_growth_kb` -- `absolute_peak_memory_kb` +- `process_peak_memory_kb` (sampled from the benchmark process) +- `absolute_peak_memory_kb` (provider/session peak, for example BrowserStack) Blank fields indicate that a resource metric was not available for that benchmark/device row. diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index e3ffcc6..6019e54 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -1125,6 +1125,8 @@ struct BenchmarkResourceUsage { #[serde(skip_serializing_if = "Option::is_none")] peak_memory_growth_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] + process_peak_memory_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] absolute_peak_memory_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] total_pss_kb: Option, @@ -5277,6 +5279,7 @@ impl BenchmarkResourceUsage { && self.cpu_median_ms.is_none() && self.peak_memory_kb.is_none() && self.peak_memory_growth_kb.is_none() + && self.process_peak_memory_kb.is_none() && self.absolute_peak_memory_kb.is_none() && self.total_pss_kb.is_none() && self.private_dirty_kb.is_none() @@ -5421,6 +5424,7 @@ fn extract_benchmark_resource_usage( .or(Some(entry)); let sample_cpu_ms = extract_sample_metric_u64(entry, "cpu_time_ms"); let sample_peak_memory_kb = extract_sample_metric_u64(entry, "peak_memory_kb"); + let sample_process_peak_memory_kb = extract_sample_metric_u64(entry, "process_peak_memory_kb"); let cpu_total_ms = resources .and_then(|res| res.get("cpu_total_ms")) @@ -5464,6 +5468,10 @@ fn extract_benchmark_resource_usage( .or(legacy_peak_memory_kb) .or_else(|| sample_peak_memory_kb.iter().copied().max()); let peak_memory_kb = peak_memory_growth_kb; + let process_peak_memory_kb = resources + .and_then(|res| res.get("process_peak_memory_kb")) + .and_then(json_value_to_u64) + .or_else(|| sample_process_peak_memory_kb.iter().copied().max()); let absolute_peak_memory_kb = resources .and_then(|res| res.get("absolute_peak_memory_kb")) .and_then(json_value_to_u64) @@ -5480,6 +5488,7 @@ fn extract_benchmark_resource_usage( cpu_median_ms, peak_memory_kb, peak_memory_growth_kb, + process_peak_memory_kb, absolute_peak_memory_kb, total_pss_kb, private_dirty_kb, @@ -5518,17 +5527,17 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { let _ = writeln!( output, - "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Absolute peak |" + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak | Provider peak |" ); let _ = writeln!( output, - "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" ); for device in &summary.device_summaries { for bench in &device.benchmarks { let _ = writeln!( output, - "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", + "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", device.device, bench.function, bench.samples, @@ -5544,6 +5553,12 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { .as_ref() .and_then(|usage| usage.peak_memory_growth_kb) ), + format_peak_memory( + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.process_peak_memory_kb) + ), format_peak_memory( bench .resource_usage @@ -5566,13 +5581,13 @@ fn render_csv_summary(summary: &SummaryReport) -> String { let mut output = String::new(); let _ = writeln!( output, - "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,absolute_peak_memory_kb" + "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,process_peak_memory_kb,absolute_peak_memory_kb" ); for device in &summary.device_summaries { for bench in &device.benchmarks { let _ = writeln!( output, - "{},{},{},{},{},{},{},{},{},{},{},{},{}", + "{},{},{},{},{},{},{},{},{},{},{},{},{},{}", device.device, bench.function, bench.samples, @@ -5601,6 +5616,11 @@ fn render_csv_summary(summary: &SummaryReport) -> String { .as_ref() .and_then(|usage| usage.peak_memory_growth_kb) .map_or(String::new(), |v| v.to_string()), + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.process_peak_memory_kb) + .map_or(String::new(), |v| v.to_string()), bench .resource_usage .as_ref() @@ -5705,10 +5725,13 @@ fn summary_has_memory_baseline_gap(summary: &SummaryReport) -> bool { } fn resource_usage_has_memory_baseline_gap(usage: &BenchmarkResourceUsage) -> bool { - match (usage.peak_memory_growth_kb, usage.absolute_peak_memory_kb) { - (Some(growth), Some(absolute)) if absolute > growth => { - absolute.saturating_sub(growth) >= MEMORY_BASELINE_GAP_MIN_DIFF_KB - && absolute >= growth.saturating_mul(MEMORY_BASELINE_GAP_RATIO) + let peak = usage + .process_peak_memory_kb + .or(usage.absolute_peak_memory_kb); + match (usage.peak_memory_growth_kb, peak) { + (Some(growth), Some(peak)) if peak > growth => { + peak.saturating_sub(growth) >= MEMORY_BASELINE_GAP_MIN_DIFF_KB + && peak >= growth.saturating_mul(MEMORY_BASELINE_GAP_RATIO) } _ => false, } @@ -9540,6 +9563,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" cpu_median_ms: Some(241), peak_memory_kb: Some(249_416), peak_memory_growth_kb: Some(249_416), + process_peak_memory_kb: Some(1_477_787), absolute_peak_memory_kb: None, total_pss_kb: None, private_dirty_kb: None, @@ -9551,7 +9575,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" }); assert!(markdown.contains( - "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Absolute peak |" + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak | Provider peak |" )); assert!(markdown.contains("1.250s")); assert!(markdown.contains("6.250s")); @@ -9586,6 +9610,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" cpu_median_ms: Some(200), peak_memory_kb: Some(1_024), peak_memory_growth_kb: Some(1_024), + process_peak_memory_kb: None, absolute_peak_memory_kb: None, total_pss_kb: None, private_dirty_kb: None, @@ -9597,10 +9622,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" }); assert!(markdown.contains( - "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Absolute peak |" + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak | Provider peak |" )); assert!(markdown.contains( - "| Google Pixel 8-14.0 | sample_fns::fibonacci | 4 | 1 | 1.000s | 4.000s | 200ms | 800ms | 20.0% | 1.00 MB | - |" + "| Google Pixel 8-14.0 | sample_fns::fibonacci | 4 | 1 | 1.000s | 4.000s | 200ms | 800ms | 20.0% | 1.00 MB | - | - |" )); assert!(!markdown.contains("### Device:")); } @@ -9630,6 +9655,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" cpu_median_ms: Some(241), peak_memory_kb: Some(249_416), peak_memory_growth_kb: Some(249_416), + process_peak_memory_kb: Some(1_477_787), absolute_peak_memory_kb: Some(1_680_026), total_pss_kb: None, private_dirty_kb: None, @@ -9642,10 +9668,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert!( csv.starts_with( - "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,absolute_peak_memory_kb\n" + "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,process_peak_memory_kb,absolute_peak_memory_kb\n" ) ); - assert!(csv.contains(",482,241,249416,249416,1680026\n")); + assert!(csv.contains(",482,241,249416,249416,1477787,1680026\n")); } #[test] @@ -9673,6 +9699,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" cpu_median_ms: Some(241), peak_memory_kb: Some(654_321), peak_memory_growth_kb: Some(654_321), + process_peak_memory_kb: Some(1_477_787), absolute_peak_memory_kb: None, total_pss_kb: Some(654_321), private_dirty_kb: None, @@ -9687,7 +9714,9 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert!(markdown.contains("CPU total")); assert!(markdown.contains("CPU / wall")); assert!(markdown.contains("Peak growth")); - assert!(markdown.contains("Absolute peak")); + assert!(markdown.contains("Process peak")); + assert!(markdown.contains("Provider peak")); + assert!(!markdown.contains("Absolute peak")); assert!(!markdown.contains("Peak memory")); assert!(markdown.contains("241ms")); assert!(markdown.contains("482ms")); @@ -9720,6 +9749,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" cpu_median_ms: Some(241), peak_memory_kb: Some(654_321), peak_memory_growth_kb: Some(654_321), + process_peak_memory_kb: Some(1_477_787), absolute_peak_memory_kb: None, total_pss_kb: None, private_dirty_kb: None, @@ -9737,7 +9767,9 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert!(markdown.contains("CPU total")); assert!(markdown.contains("CPU / wall")); assert!(markdown.contains("Peak growth")); - assert!(markdown.contains("Absolute peak")); + assert!(markdown.contains("Process peak")); + assert!(markdown.contains("Provider peak")); + assert!(!markdown.contains("Absolute peak")); assert!(!markdown.contains("Peak memory")); assert!(markdown.contains("241ms")); assert!(markdown.contains("482ms")); @@ -9770,6 +9802,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" cpu_median_ms: None, peak_memory_kb: Some(171_556), peak_memory_growth_kb: Some(171_556), + process_peak_memory_kb: Some(1_477_787), absolute_peak_memory_kb: Some(1_680_026), total_pss_kb: Some(1_477_787), private_dirty_kb: Some(1_462_460), @@ -9781,7 +9814,9 @@ test_suite = "target/ios/BenchRunnerUITests.zip" }); assert!(markdown.contains("Peak growth")); - assert!(markdown.contains("Absolute peak")); + assert!(markdown.contains("Process peak")); + assert!(markdown.contains("Provider peak")); + assert!(!markdown.contains("Absolute peak")); assert!(markdown.contains(MEMORY_BASELINE_GAP_NOTE)); assert!(!markdown.contains("Peak memory")); } @@ -9809,9 +9844,9 @@ test_suite = "target/ios/BenchRunnerUITests.zip" vec![json!({ "function": "sample_fns::fibonacci", "samples": [ - { "duration_ns": 1000, "cpu_time_ms": 19, "peak_memory_kb": 48 }, - { "duration_ns": 2000, "cpu_time_ms": 7, "peak_memory_kb": 96 }, - { "duration_ns": 3000, "cpu_time_ms": 11, "peak_memory_kb": 64 } + { "duration_ns": 1000, "cpu_time_ms": 19, "peak_memory_kb": 48, "process_peak_memory_kb": 1048 }, + { "duration_ns": 2000, "cpu_time_ms": 7, "peak_memory_kb": 96, "process_peak_memory_kb": 1096 }, + { "duration_ns": 3000, "cpu_time_ms": 11, "peak_memory_kb": 64, "process_peak_memory_kb": 1064 } ] })], )])), @@ -9828,6 +9863,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert_eq!(usage.cpu_median_ms, Some(11)); assert_eq!(usage.peak_memory_kb, Some(96)); assert_eq!(usage.peak_memory_growth_kb, Some(96)); + assert_eq!(usage.process_peak_memory_kb, Some(1_096)); assert_eq!(usage.absolute_peak_memory_kb, None); } @@ -9854,8 +9890,8 @@ test_suite = "target/ios/BenchRunnerUITests.zip" vec![json!({ "function": "sample_fns::fibonacci", "samples": [ - { "duration_ns": 1000, "cpu_time_ms": 10, "peak_memory_kb": 64 }, - { "duration_ns": 2000, "cpu_time_ms": 12, "peak_memory_kb": 72 } + { "duration_ns": 1000, "cpu_time_ms": 10, "peak_memory_kb": 64, "process_peak_memory_kb": 1064 }, + { "duration_ns": 2000, "cpu_time_ms": 12, "peak_memory_kb": 72, "process_peak_memory_kb": 1072 } ] })], )])), @@ -9882,6 +9918,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert_eq!(usage.peak_memory_kb, Some(72)); assert_eq!(usage.peak_memory_growth_kb, Some(72)); + assert_eq!(usage.process_peak_memory_kb, Some(1_072)); assert_eq!(usage.absolute_peak_memory_kb, Some(1_022_976)); } @@ -10352,8 +10389,8 @@ mod ci_merge_tests { assert!(markdown.starts_with("### Benchmark Summary\n")); assert!(markdown.contains("- Target: iOS")); - assert!(markdown.contains("| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Absolute peak |")); - assert!(markdown.contains("| iPhone 13 | ffi_benchmark::bench_fibonacci | 5 | 1 | 0.017ms | 0.085ms | - | - | - | - | - |")); + assert!(markdown.contains("| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak | Provider peak |")); + assert!(markdown.contains("| iPhone 13 | ffi_benchmark::bench_fibonacci | 5 | 1 | 0.017ms | 0.085ms | - | - | - | - | - | - |")); assert!(!markdown.contains("### Device:")); } @@ -10604,6 +10641,7 @@ mod ci_merge_tests { assert_eq!(resource_usage["cpu_total_ms"], 482); assert_eq!(resource_usage["peak_memory_kb"], Value::Null); assert_eq!(resource_usage["peak_memory_growth_kb"], Value::Null); + assert_eq!(resource_usage["process_peak_memory_kb"], Value::Null); assert_eq!(resource_usage["absolute_peak_memory_kb"], Value::Null); assert_eq!(resource_usage["total_pss_kb"], 654321); assert_eq!(resource_usage["private_dirty_kb"], 321000); @@ -10668,6 +10706,7 @@ mod ci_merge_tests { assert_eq!(resource_usage["peak_memory_kb"], Value::Null); assert_eq!(resource_usage["peak_memory_growth_kb"], Value::Null); + assert_eq!(resource_usage["process_peak_memory_kb"], Value::Null); assert_eq!(resource_usage["absolute_peak_memory_kb"], 249416); assert_eq!(resource_usage["cpu_total_ms"], Value::Null); } @@ -10763,6 +10802,7 @@ mod resource_usage_tests { assert_eq!(usage.java_heap_kb, Some(512)); assert_eq!(usage.peak_memory_kb, None); assert_eq!(usage.peak_memory_growth_kb, None); + assert_eq!(usage.process_peak_memory_kb, None); assert_eq!(usage.absolute_peak_memory_kb, None); } @@ -10787,6 +10827,7 @@ mod resource_usage_tests { let usage = extract_benchmark_resource_usage(&entry, Some(&perf)).unwrap(); assert_eq!(usage.peak_memory_kb, None); assert_eq!(usage.peak_memory_growth_kb, None); + assert_eq!(usage.process_peak_memory_kb, None); assert_eq!(usage.absolute_peak_memory_kb, Some(10240)); assert_eq!(usage.total_pss_kb, Some(4096)); } @@ -10796,6 +10837,7 @@ mod resource_usage_tests { let entry = json!({ "resources": { "peak_memory_kb": 171556, + "process_peak_memory_kb": 1477787, "total_pss_kb": 1477787, "private_dirty_kb": 1462460, "native_heap_kb": 532000, @@ -10817,6 +10859,7 @@ mod resource_usage_tests { assert_eq!(usage.peak_memory_growth_kb, Some(171_556)); assert_eq!(usage.peak_memory_kb, Some(171_556)); + assert_eq!(usage.process_peak_memory_kb, Some(1_477_787)); assert_eq!(usage.absolute_peak_memory_kb, Some(1_680_026)); assert_eq!(usage.total_pss_kb, Some(1_477_787)); assert_eq!(usage.private_dirty_kb, Some(1_462_460)); @@ -10838,6 +10881,7 @@ mod resource_usage_tests { cpu_median_ms: Some(125), peak_memory_kb: Some(8192), peak_memory_growth_kb: Some(8192), + process_peak_memory_kb: Some(12288), absolute_peak_memory_kb: Some(16384), total_pss_kb: Some(4096), private_dirty_kb: Some(2048), @@ -10852,6 +10896,7 @@ mod resource_usage_tests { assert_eq!(deserialized.cpu_median_ms, Some(125)); assert_eq!(deserialized.peak_memory_kb, Some(8192)); assert_eq!(deserialized.peak_memory_growth_kb, Some(8192)); + assert_eq!(deserialized.process_peak_memory_kb, Some(12288)); assert_eq!(deserialized.absolute_peak_memory_kb, Some(16384)); assert_eq!(deserialized.total_pss_kb, Some(4096)); assert_eq!(deserialized.private_dirty_kb, Some(2048)); @@ -10862,6 +10907,7 @@ mod resource_usage_tests { assert!(!json_str.contains("java_heap_kb")); assert!(json_str.contains("peak_memory_kb")); assert!(json_str.contains("peak_memory_growth_kb")); + assert!(json_str.contains("process_peak_memory_kb")); assert!(json_str.contains("absolute_peak_memory_kb")); } } diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index 140eb20..37fb8cd 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -67,6 +67,8 @@ pub struct ResourceUsage { #[serde(skip_serializing_if = "Option::is_none")] pub peak_memory_growth_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub process_peak_memory_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub absolute_peak_memory_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] pub total_pss_kb: Option, @@ -614,6 +616,7 @@ impl ResourceUsage { self.cpu_total_ms.is_none() && self.peak_memory_kb.is_none() && self.peak_memory_growth_kb.is_none() + && self.process_peak_memory_kb.is_none() && self.absolute_peak_memory_kb.is_none() && self.total_pss_kb.is_none() && self.private_dirty_kb.is_none() @@ -631,6 +634,9 @@ impl ResourceUsage { if self.peak_memory_growth_kb.is_none() { self.peak_memory_growth_kb = other.peak_memory_growth_kb; } + if self.process_peak_memory_kb.is_none() { + self.process_peak_memory_kb = other.process_peak_memory_kb; + } if self.absolute_peak_memory_kb.is_none() { self.absolute_peak_memory_kb = other.absolute_peak_memory_kb; } @@ -674,6 +680,9 @@ fn parse_resource_usage_object(value: &serde_json::Value) -> Option Option bool { } fn resource_usage_has_memory_baseline_gap(usage: &ResourceUsage) -> bool { - match (usage.peak_memory_growth_kb, usage.absolute_peak_memory_kb) { - (Some(growth), Some(absolute)) if absolute > growth => { - absolute.saturating_sub(growth) >= MEMORY_BASELINE_GAP_MIN_DIFF_KB - && absolute >= growth.saturating_mul(MEMORY_BASELINE_GAP_RATIO) + let peak = usage + .process_peak_memory_kb + .or(usage.absolute_peak_memory_kb); + match (usage.peak_memory_growth_kb, peak) { + (Some(growth), Some(peak)) if peak > growth => { + peak.saturating_sub(growth) >= MEMORY_BASELINE_GAP_MIN_DIFF_KB + && peak >= growth.saturating_mul(MEMORY_BASELINE_GAP_RATIO) } _ => false, } @@ -806,7 +819,7 @@ fn render_platform_table(platform: &PlatformReport) -> String { let mut headers = vec!["Benchmark", "Avg ms", "Best", "Worst", "Median", "P95"]; if has_resource_usage { - headers.extend(["CPU total", "Peak growth", "Absolute peak"]); + headers.extend(["CPU total", "Peak growth", "Process peak", "Provider peak"]); } table.set_header( headers @@ -828,11 +841,13 @@ fn render_platform_table(platform: &PlatformReport) -> String { if let Some(ru) = &bench.resource_usage { row.push(Cell::new(format_cpu_total_ms(ru.cpu_total_ms))); row.push(Cell::new(format_peak_memory(ru.peak_memory_growth_kb))); + row.push(Cell::new(format_peak_memory(ru.process_peak_memory_kb))); row.push(Cell::new(format_peak_memory(ru.absolute_peak_memory_kb))); } else { row.push(Cell::new("—")); row.push(Cell::new("—")); row.push(Cell::new("—")); + row.push(Cell::new("—")); } } @@ -883,10 +898,10 @@ pub fn render_markdown(report: &SummarizeReport) -> String { if has_ru { output.push_str( - "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU total | Peak growth | Absolute peak |\n", + "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU total | Peak growth | Process peak | Provider peak |\n", ); output.push_str( - "|-----------|--------|------|-------|--------|-----|-----------|-------------|---------------|\n", + "|-----------|--------|------|-------|--------|-----|-----------|-------------|--------------|---------------|\n", ); } else { output.push_str("| Benchmark | Avg ms | Best | Worst | Median | P95 |\n"); @@ -907,13 +922,14 @@ pub fn render_markdown(report: &SummarizeReport) -> String { if has_ru { if let Some(ru) = &bench.resource_usage { row.push_str(&format!( - " {} | {} | {} |", + " {} | {} | {} | {} |", format_cpu_total_ms(ru.cpu_total_ms), format_peak_memory(ru.peak_memory_growth_kb), + format_peak_memory(ru.process_peak_memory_kb), format_peak_memory(ru.absolute_peak_memory_kb), )); } else { - row.push_str(" — | — | — |"); + row.push_str(" — | — | — | — |"); } } @@ -966,6 +982,7 @@ pub fn enrich_with_browserstack( cpu_total_ms: None, peak_memory_kb: None, peak_memory_growth_kb: None, + process_peak_memory_kb: None, absolute_peak_memory_kb: None, total_pss_kb: None, private_dirty_kb: None, @@ -1344,6 +1361,7 @@ mod tests { cpu_total_ms: Some(482), peak_memory_kb: Some(654321), peak_memory_growth_kb: Some(654321), + process_peak_memory_kb: Some(1_477_787), absolute_peak_memory_kb: None, total_pss_kb: Some(654321), private_dirty_kb: Some(321000), @@ -1433,7 +1451,9 @@ mod tests { assert!(output.contains("CPU total")); assert!(!output.contains("CPU total (ms)")); assert!(output.contains("Peak growth")); - assert!(output.contains("Absolute peak")); + assert!(output.contains("Process peak")); + assert!(output.contains("Provider peak")); + assert!(!output.contains("Absolute peak")); assert!(!output.contains("Peak memory")); assert!(output.contains("| 1.482s |")); assert!(output.contains("638.99 MB")); @@ -1477,7 +1497,9 @@ mod tests { assert!(output.contains("CPU total")); assert!(!output.contains("CPU total (ms)")); assert!(output.contains("Peak growth")); - assert!(output.contains("Absolute peak")); + assert!(output.contains("Process peak")); + assert!(output.contains("Provider peak")); + assert!(!output.contains("Absolute peak")); assert!(!output.contains("Peak memory")); assert!(output.contains("1.482s")); assert!(output.contains("638.99 MB")); @@ -1508,6 +1530,7 @@ mod tests { "resource_usage": { "peak_memory_kb": 171556, "peak_memory_growth_kb": 171556, + "process_peak_memory_kb": 1477787, "absolute_peak_memory_kb": 1680026, "total_pss_kb": 1477787, "private_dirty_kb": 1462460 @@ -1521,7 +1544,9 @@ mod tests { let output = render_markdown(&report); assert!(output.contains("Peak growth")); - assert!(output.contains("Absolute peak")); + assert!(output.contains("Process peak")); + assert!(output.contains("Provider peak")); + assert!(!output.contains("Absolute peak")); assert!(output.contains(MEMORY_BASELINE_GAP_NOTE)); assert!(!output.contains("Peak memory")); } diff --git a/crates/sample-fns/src/lib.rs b/crates/sample-fns/src/lib.rs index 7d9cd30..491b4dd 100644 --- a/crates/sample-fns/src/lib.rs +++ b/crates/sample-fns/src/lib.rs @@ -18,6 +18,7 @@ pub struct BenchSample { pub duration_ns: u64, pub cpu_time_ms: Option, pub peak_memory_kb: Option, + pub process_peak_memory_kb: Option, } /// Flat semantic phase timing captured during measured iterations. @@ -79,6 +80,7 @@ impl From for BenchSample { duration_ns: sample.duration_ns, cpu_time_ms: sample.cpu_time_ms, peak_memory_kb: sample.peak_memory_kb, + process_peak_memory_kb: sample.process_peak_memory_kb, } } } diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index edafd53..bc03ec2 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -81,7 +81,7 @@ jobs: - `summary.json`, `summary.md`, and `results.csv` remain the stable required outputs. - `plots/*.svg` is additive and only appears when local plot rendering is enabled and a Python + Matplotlib runtime is available, or when `--plots require` is used successfully. -- `summary.md` now renders a single top-level CI table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, and `Peak memory`. +- `summary.md` now renders a single top-level CI table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, `Process peak`, and `Provider peak`. - The reusable workflow attempts to compare against the latest successful default-branch run by downloading its per-platform `summary.json` artifacts before calling `ci check-run`. ## Compatibility Notes @@ -96,7 +96,8 @@ versioned schemas and documenting the compatibility impact in `RELEASE_NOTES.md` - `summary.md` now renders a flat per-device table instead of per-device sections - CPU values in that table are shown as `CPU median / iter`, `CPU total`, and `CPU / wall` -- `results.csv` now includes `cpu_total_ms`, `cpu_median_ms`, and `peak_memory_kb` +- `peak_memory_kb` remains the legacy baseline-adjusted growth field +- `results.csv` now includes `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`, `peak_memory_growth_kb`, `process_peak_memory_kb`, and `absolute_peak_memory_kb` - missing resource values are left blank in CSV output Any change to required output files or metadata keys requires updating the diff --git a/docs/guides/browserstack-ci.md b/docs/guides/browserstack-ci.md index 3110064..9053a7a 100644 --- a/docs/guides/browserstack-ci.md +++ b/docs/guides/browserstack-ci.md @@ -89,8 +89,8 @@ cargo mobench report summarize --summary target/mobench/ci/summary.json ``` Standard CI outputs now carry benchmark-scoped resource metrics directly: -- `summary.md` renders one top-level table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, and `Peak memory` -- `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, and `peak_memory_kb` +- `summary.md` renders one top-level table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, `Process peak`, and `Provider peak` +- `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`, `peak_memory_growth_kb`, `process_peak_memory_kb`, and `absolute_peak_memory_kb` - missing resource data is emitted as blank CSV fields rather than placeholder strings ## Quick Example diff --git a/docs/guides/browserstack-metrics.md b/docs/guides/browserstack-metrics.md index ceec9ad..c4b4e6c 100644 --- a/docs/guides/browserstack-metrics.md +++ b/docs/guides/browserstack-metrics.md @@ -44,14 +44,14 @@ We recursively download ALL URLs from session JSON, which typically includes: - CPU usage (usage_percent) - Aggregate statistics: peak, average, min - Normalized into run summaries and CI summaries when using `--fetch` -- Surfaced as summary resource fields such as `cpu_total_ms` and `peak_memory_kb` +- Surfaced as provider/session resource fields such as `absolute_peak_memory_kb` **Benchmark-scoped resource metrics (current default):** -- Each measured iteration can emit `cpu_time_ms` and `peak_memory_kb` +- Each measured iteration can emit `cpu_time_ms`, `peak_memory_kb`, and `process_peak_memory_kb` - `mobench` derives `cpu_median_ms` from measured iterations only -- `summary.md` renders `CPU median / iter`, `CPU total`, `CPU / wall`, and `Peak memory` in the top-level CI table -- `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, and `peak_memory_kb` -- BrowserStack aggregate memory is only used as a fallback when benchmark-scoped peak memory is absent +- `summary.md` renders `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, `Process peak`, and `Provider peak` in the top-level CI table +- `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`, `peak_memory_growth_kb`, `process_peak_memory_kb`, and `absolute_peak_memory_kb` +- BrowserStack aggregate memory is preserved separately as provider/session telemetry and does not override benchmark process memory ### ⚠️ What We do not currently capture diff --git a/docs/schemas/summary-v1.schema.json b/docs/schemas/summary-v1.schema.json index ce24e60..d4b2f86 100644 --- a/docs/schemas/summary-v1.schema.json +++ b/docs/schemas/summary-v1.schema.json @@ -55,6 +55,7 @@ "cpu_median_ms": { "type": ["integer", "null"] }, "peak_memory_kb": { "type": ["integer", "null"] }, "peak_memory_growth_kb": { "type": ["integer", "null"] }, + "process_peak_memory_kb": { "type": ["integer", "null"] }, "absolute_peak_memory_kb": { "type": ["integer", "null"] }, "total_pss_kb": { "type": ["integer", "null"] }, "private_dirty_kb": { "type": ["integer", "null"] }, From 9b6795fa02246592f82de4b63dcd836cc89267da Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 22 Apr 2026 12:26:07 +0200 Subject: [PATCH 169/196] Keep mobile process memory reporting compatible --- crates/mobench-sdk/src/codegen.rs | 29 +++++ .../src/main/java/MainActivity.kt.template | 118 ++++++++++++++++-- .../BenchRunner/BenchRunnerFFI.swift.template | 42 ++++++- crates/mobench/README.md | 2 +- docs/guides/browserstack-metrics.md | 1 + 5 files changed, 182 insertions(+), 10 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index abcab33..60a59fd 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -1449,6 +1449,35 @@ mod tests { assert!(!is_template_file(Path::new("image.png"))); } + #[test] + fn test_mobile_templates_read_process_peak_memory_compatibly() { + let android = + include_str!("../templates/android/app/src/main/java/MainActivity.kt.template"); + assert!( + !android.contains("sample.processPeakMemoryKb"), + "Android template should not require generated bindings to expose processPeakMemoryKb" + ); + assert!( + !android.contains("it.processPeakMemoryKb"), + "Android template should not require generated bindings to expose processPeakMemoryKb" + ); + assert!(android.contains("optionalProcessPeakMemoryKb(sample)")); + assert!(android.contains("ProcessMemorySampler")); + + let ios = + include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template"); + assert!( + !ios.contains("sample.processPeakMemoryKb"), + "iOS template should not require generated bindings to expose processPeakMemoryKb" + ); + assert!( + !ios.contains(r"\.processPeakMemoryKb"), + "iOS template should not require generated bindings to expose processPeakMemoryKb" + ); + assert!(ios.contains("optionalProcessPeakMemoryKb(sample)")); + assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }")); + } + #[test] fn test_validate_no_unreplaced_placeholders() { // Should pass with no placeholders diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index d6de21b..89d0602 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -44,12 +44,19 @@ class MainActivity : AppCompatActivity() { iterations = params.iterations, warmup = params.warmup ) - val report = runBenchmark(spec) + val processMemorySampler = ProcessMemorySampler() + var runProcessPeakMemoryKb: Long? + processMemorySampler.start() + val report = try { + runBenchmark(spec) + } finally { + runProcessPeakMemoryKb = processMemorySampler.stop() + } // Debug: Log first sample's raw nanoseconds if (report.samples.isNotEmpty()) { android.util.Log.d("MainActivity", "First sample duration_ns: ${report.samples[0].durationNs}") } - logBenchReport(report) + logBenchReport(report, runProcessPeakMemoryKb) formatBenchReport(report) } catch (e: BenchException) { // Generic handler for all benchmark errors (InvalidIterations, UnknownFunction, etc.) @@ -69,6 +76,63 @@ class MainActivity : AppCompatActivity() { android.util.Log.i("BenchRunner", "Display hold complete") } + private inner class ProcessMemorySampler(private val sampleIntervalMs: Long = 10L) { + @Volatile private var running = false + @Volatile private var peakKb = 0L + private var samplerThread: Thread? = null + + fun start() { + if (running) { + return + } + + running = true + recordCurrent() + samplerThread = Thread { + while (running) { + recordCurrent() + try { + Thread.sleep(sampleIntervalMs) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + break + } + } + recordCurrent() + }.apply { + name = "mobench-process-memory-sampler" + isDaemon = true + start() + } + } + + fun stop(): Long? { + running = false + try { + samplerThread?.join(sampleIntervalMs * 2) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } + recordCurrent() + return peakKb.takeIf { it > 0L } + } + + @Synchronized + private fun recordCurrent() { + currentProcessPssKb()?.let { observedKb -> + if (observedKb > peakKb) { + peakKb = observedKb + } + } + } + } + + private fun currentProcessPssKb(): Long? { + val memInfo = Debug.MemoryInfo() + Debug.getMemoryInfo(memInfo) + return memInfo.totalPss.toLong().takeIf { it > 0L } + } + private fun median(values: List): Long { val sorted = values.sorted() if (sorted.isEmpty()) { @@ -121,7 +185,7 @@ class MainActivity : AppCompatActivity() { } } - private fun logBenchReport(report: BenchReport) { + private fun logBenchReport(report: BenchReport, runProcessPeakMemoryKb: Long?) { val json = JSONObject() val spec = JSONObject() spec.put("name", report.spec.name) @@ -140,7 +204,7 @@ class MainActivity : AppCompatActivity() { sampleJson.put("duration_ns", sample.durationNs.toLong()) sample.cpuTimeMs?.let { sampleJson.put("cpu_time_ms", it.toLong()) } sample.peakMemoryKb?.let { sampleJson.put("peak_memory_kb", it.toLong()) } - sample.processPeakMemoryKb?.let { sampleJson.put("process_peak_memory_kb", it.toLong()) } + optionalProcessPeakMemoryKb(sample)?.let { sampleJson.put("process_peak_memory_kb", it) } samples.put(sampleJson) } json.put("samples", samples) @@ -167,7 +231,7 @@ class MainActivity : AppCompatActivity() { val cpuSamplesMs = report.samples.mapNotNull { it.cpuTimeMs?.toLong() } val peakSamplesKb = report.samples.mapNotNull { it.peakMemoryKb?.toLong() } - val processPeakSamplesKb = report.samples.mapNotNull { it.processPeakMemoryKb?.toLong() } + val processPeakSamplesKb = report.samples.mapNotNull { optionalProcessPeakMemoryKb(it) } val memInfo = Debug.MemoryInfo() Debug.getMemoryInfo(memInfo) val resources = JSONObject() @@ -184,8 +248,9 @@ class MainActivity : AppCompatActivity() { resources.put("peak_memory_kb", peakGrowthKb) resources.put("peak_memory_growth_kb", peakGrowthKb) } - if (processPeakSamplesKb.isNotEmpty()) { - resources.put("process_peak_memory_kb", processPeakSamplesKb.maxOrNull() ?: 0L) + val processPeakMemoryKb = processPeakSamplesKb.maxOrNull() ?: runProcessPeakMemoryKb + processPeakMemoryKb?.let { + resources.put("process_peak_memory_kb", it) } resources.put("total_pss_kb", memInfo.totalPss) resources.put("private_dirty_kb", memInfo.totalPrivateDirty) @@ -197,6 +262,45 @@ class MainActivity : AppCompatActivity() { android.util.Log.i("BenchRunner", "BENCH_JSON ${json}") } + private fun optionalProcessPeakMemoryKb(sample: Any): Long? { + return optionalNumericProperty(sample, "processPeakMemoryKb", "getProcessPeakMemoryKb") + } + + private fun optionalNumericProperty(instance: Any, propertyName: String, getterName: String): Long? { + try { + val getter = instance.javaClass.methods.firstOrNull { + it.name == getterName && it.parameterTypes.isEmpty() + } + coerceToLong(getter?.invoke(instance))?.let { return it } + } catch (e: Exception) { + android.util.Log.d("BenchRunner", "Optional property getter unavailable: $propertyName") + } + + try { + val field = instance.javaClass.declaredFields.firstOrNull { it.name == propertyName } + if (field != null) { + field.isAccessible = true + coerceToLong(field.get(instance))?.let { return it } + } + } catch (e: Exception) { + android.util.Log.d("BenchRunner", "Optional property field unavailable: $propertyName") + } + + return null + } + + private fun coerceToLong(value: Any?): Long? { + return when (value) { + null -> null + is Long -> value + is Int -> value.toLong() + is Short -> value.toLong() + is Byte -> value.toLong() + is Number -> value.toLong() + else -> value.toString().toLongOrNull() + } + } + private fun resolveBenchParams(): BenchParams { val assetParams = loadBenchParamsFromAssets() val defaults = assetParams ?: BenchParams( diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index dd6838e..6622314 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -175,7 +175,7 @@ enum {{PROJECT_NAME_PASCAL}}FFI { if let peakMemoryKb = sample.peakMemoryKb { sampleJson["peak_memory_kb"] = peakMemoryKb } - if let processPeakMemoryKb = sample.processPeakMemoryKb { + if let processPeakMemoryKb = optionalProcessPeakMemoryKb(sample) { sampleJson["process_peak_memory_kb"] = processPeakMemoryKb } return sampleJson @@ -224,7 +224,7 @@ enum {{PROJECT_NAME_PASCAL}}FFI { let cpuSamplesMs = report.samples.compactMap(\.cpuTimeMs) let peakSamplesKb = report.samples.compactMap(\.peakMemoryKb) - let processPeakSamplesKb = report.samples.compactMap(\.processPeakMemoryKb) + let processPeakSamplesKb = report.samples.compactMap { optionalProcessPeakMemoryKb($0) } func median(_ values: [UInt64]) -> UInt64 { let sorted = values.sorted() @@ -265,6 +265,44 @@ enum {{PROJECT_NAME_PASCAL}}FFI { } } + private static func optionalProcessPeakMemoryKb(_ sample: Any) -> UInt64? { + return optionalUInt64Property(sample, named: "processPeakMemoryKb") + } + + private static func optionalUInt64Property(_ instance: Any, named propertyName: String) -> UInt64? { + for child in Mirror(reflecting: instance).children where child.label == propertyName { + return coerceToUInt64(child.value) + } + return nil + } + + private static func coerceToUInt64(_ value: Any) -> UInt64? { + let mirror = Mirror(reflecting: value) + if mirror.displayStyle == .optional { + guard let child = mirror.children.first else { + return nil + } + return coerceToUInt64(child.value) + } + + if let value = value as? UInt64 { + return value + } + if let value = value as? UInt32 { + return UInt64(value) + } + if let value = value as? UInt { + return UInt64(value) + } + if let value = value as? Int64, value >= 0 { + return UInt64(value) + } + if let value = value as? Int, value >= 0 { + return UInt64(value) + } + return nil + } + /// Generates a JSON error report private static func generateErrorJSON(_ error: BenchError) -> String { let errorDict: [String: Any] = [ diff --git a/crates/mobench/README.md b/crates/mobench/README.md index d83379f..cffb361 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -272,7 +272,7 @@ cargo mobench ci run --target --function [OPTIONS] - `cpu_median_ms` - `peak_memory_kb` (legacy alias for peak growth) - `peak_memory_growth_kb` -- `process_peak_memory_kb` (sampled from the benchmark process) +- `process_peak_memory_kb` (benchmark app/process peak, with Android harness sampling as a fallback) - `absolute_peak_memory_kb` (provider/session peak, for example BrowserStack) Blank fields indicate that a resource metric was not available for that benchmark/device row. diff --git a/docs/guides/browserstack-metrics.md b/docs/guides/browserstack-metrics.md index c4b4e6c..65d60b8 100644 --- a/docs/guides/browserstack-metrics.md +++ b/docs/guides/browserstack-metrics.md @@ -48,6 +48,7 @@ We recursively download ALL URLs from session JSON, which typically includes: **Benchmark-scoped resource metrics (current default):** - Each measured iteration can emit `cpu_time_ms`, `peak_memory_kb`, and `process_peak_memory_kb` +- Android runs also sample the benchmark app process PSS around `runBenchmark` as a fallback when older UniFFI bindings do not expose per-sample process peaks - `mobench` derives `cpu_median_ms` from measured iterations only - `summary.md` renders `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, `Process peak`, and `Provider peak` in the top-level CI table - `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`, `peak_memory_growth_kb`, `process_peak_memory_kb`, and `absolute_peak_memory_kb` From d3c61c106eff6b64e9b3ac208a3db39c2fb9afc2 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 22 Apr 2026 17:43:17 +0200 Subject: [PATCH 170/196] Run Android benchmarks in isolated worker process --- crates/mobench-sdk/src/codegen.rs | 7 + .../mobench-sdk/templates/android/README.md | 2 +- .../java/MainActivityTest.kt.template | 18 +- .../android/app/src/main/AndroidManifest.xml | 4 + .../src/main/java/MainActivity.kt.template | 640 ++++++++++-------- crates/mobench/README.md | 2 +- docs/guides/browserstack-metrics.md | 2 +- 7 files changed, 403 insertions(+), 272 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 60a59fd..fcd2c7b 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -1463,6 +1463,13 @@ mod tests { ); assert!(android.contains("optionalProcessPeakMemoryKb(sample)")); assert!(android.contains("ProcessMemorySampler")); + assert!(android.contains("class BenchmarkWorkerService : Service()")); + assert!(android.contains("memory_process\", \"isolated_worker\"")); + + let android_manifest = + include_str!("../templates/android/app/src/main/AndroidManifest.xml"); + assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\"")); + assert!(android_manifest.contains("android:process=\":mobench_worker\"")); let ios = include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template"); diff --git a/crates/mobench-sdk/templates/android/README.md b/crates/mobench-sdk/templates/android/README.md index 9556163..4699926 100644 --- a/crates/mobench-sdk/templates/android/README.md +++ b/crates/mobench-sdk/templates/android/README.md @@ -46,7 +46,7 @@ The app reads benchmark configuration from: ## Benchmark report capture The app emits benchmark results for both local automation and BrowserStack: -- Keeps results on screen briefly so humans and automation can inspect them +- Runs the benchmark in an isolated `:mobench_worker` process and shows the worker result on screen - Outputs JSON with `BENCH_JSON` marker to logcat - Includes `phases` when the benchmark uses `profile_phase(...)` - Is marked `profileable` so local native profilers can attach to it diff --git a/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template b/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template index d041367..0373707 100644 --- a/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template @@ -7,6 +7,7 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import org.hamcrest.Matchers.containsString +import org.junit.Assert.fail import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -19,7 +20,20 @@ class MainActivityTest { @Test fun showsBenchOutput() { - onView(withId(R.id.result_text)) - .check(matches(withText(containsString("Samples")))) + val deadline = System.currentTimeMillis() + 30 * 60 * 1000L + var lastError: Throwable? = null + + while (System.currentTimeMillis() < deadline) { + try { + onView(withId(R.id.result_text)) + .check(matches(withText(containsString("Samples")))) + return + } catch (error: Throwable) { + lastError = error + Thread.sleep(500) + } + } + + fail("Timed out waiting for benchmark output containing 'Samples': ${lastError?.message}") } } diff --git a/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml b/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml index 24aa990..7a3b1d4 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml +++ b/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,10 @@ + diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index 89d0602..005eeb5 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -1,7 +1,14 @@ package {{PACKAGE_NAME}} +import android.app.Service +import android.content.Intent +import android.os.Build import android.os.Bundle import android.os.Debug +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.ResultReceiver import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import org.json.JSONArray @@ -11,21 +18,21 @@ import uniffi.{{UNIFFI_NAMESPACE}}.BenchReport import uniffi.{{UNIFFI_NAMESPACE}}.BenchSpec import uniffi.{{UNIFFI_NAMESPACE}}.runBenchmark -class MainActivity : AppCompatActivity() { +private const val DEFAULT_FUNCTION = "{{DEFAULT_FUNCTION}}" +private const val DEFAULT_ITERATIONS = 20u +private const val DEFAULT_WARMUP = 3u +private const val FUNCTION_EXTRA = "bench_function" +private const val ITERATIONS_EXTRA = "bench_iterations" +private const val WARMUP_EXTRA = "bench_warmup" +private const val SPEC_ASSET = "bench_spec.json" +private const val RUN_BENCHMARK_ACTION = "{{PACKAGE_NAME}}.RUN_BENCHMARK" +private const val RESULT_RECEIVER_EXTRA = "bench_result_receiver" +private const val RESULT_DISPLAY_EXTRA = "bench_display" +private const val RESULT_ERROR_EXTRA = "bench_error" +private const val RESULT_OK = 1 +private const val RESULT_ERROR = 2 - companion object { - private const val DEFAULT_FUNCTION = "{{DEFAULT_FUNCTION}}" - private const val DEFAULT_ITERATIONS = 20u - private const val DEFAULT_WARMUP = 3u - private const val FUNCTION_EXTRA = "bench_function" - private const val ITERATIONS_EXTRA = "bench_iterations" - private const val WARMUP_EXTRA = "bench_warmup" - private const val SPEC_ASSET = "bench_spec.json" - - init { - System.loadLibrary("{{LIBRARY_NAME}}") - } - } +class MainActivity : AppCompatActivity() { private data class BenchParams( val function: String, @@ -37,267 +44,37 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - val params = resolveBenchParams() - val display = try { - val spec = BenchSpec( - name = params.function, - iterations = params.iterations, - warmup = params.warmup - ) - val processMemorySampler = ProcessMemorySampler() - var runProcessPeakMemoryKb: Long? - processMemorySampler.start() - val report = try { - runBenchmark(spec) - } finally { - runProcessPeakMemoryKb = processMemorySampler.stop() - } - // Debug: Log first sample's raw nanoseconds - if (report.samples.isNotEmpty()) { - android.util.Log.d("MainActivity", "First sample duration_ns: ${report.samples[0].durationNs}") - } - logBenchReport(report, runProcessPeakMemoryKb) - formatBenchReport(report) - } catch (e: BenchException) { - // Generic handler for all benchmark errors (InvalidIterations, UnknownFunction, etc.) - android.util.Log.e("BenchRunner", "Benchmark error: ${e.message}", e) - "Benchmark error: ${e.message}" - } catch (e: Exception) { - android.util.Log.e("BenchRunner", "Unexpected error during benchmark execution", e) - "Unexpected error: ${e.message}" - } - - findViewById(R.id.result_text)?.text = display - - // Keep the report visible briefly so local smoke runs and remote automation - // have a stable window to read the results. - android.util.Log.i("BenchRunner", "Displaying results for 5 seconds for capture output...") - Thread.sleep(5000) - android.util.Log.i("BenchRunner", "Display hold complete") - } - - private inner class ProcessMemorySampler(private val sampleIntervalMs: Long = 10L) { - @Volatile private var running = false - @Volatile private var peakKb = 0L - private var samplerThread: Thread? = null + val resultText = findViewById(R.id.result_text) + resultText?.text = "Running benchmark..." - fun start() { - if (running) { - return - } - - running = true - recordCurrent() - samplerThread = Thread { - while (running) { - recordCurrent() - try { - Thread.sleep(sampleIntervalMs) - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - break - } - } - recordCurrent() - }.apply { - name = "mobench-process-memory-sampler" - isDaemon = true - start() - } - } - - fun stop(): Long? { - running = false - try { - samplerThread?.join(sampleIntervalMs * 2) - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - } - recordCurrent() - return peakKb.takeIf { it > 0L } - } - - @Synchronized - private fun recordCurrent() { - currentProcessPssKb()?.let { observedKb -> - if (observedKb > peakKb) { - peakKb = observedKb + val params = resolveBenchParams() + val resultReceiver = object : ResultReceiver(Handler(Looper.getMainLooper())) { + override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + val display = resultData?.getString(RESULT_DISPLAY_EXTRA) + ?: resultData?.getString(RESULT_ERROR_EXTRA) + ?: "Benchmark worker returned no result" + resultText?.text = display + + if (resultCode == RESULT_OK) { + android.util.Log.i("BenchRunner", "Benchmark worker completed") + } else { + android.util.Log.e("BenchRunner", display) } } } - } - - private fun currentProcessPssKb(): Long? { - val memInfo = Debug.MemoryInfo() - Debug.getMemoryInfo(memInfo) - return memInfo.totalPss.toLong().takeIf { it > 0L } - } - - private fun median(values: List): Long { - val sorted = values.sorted() - if (sorted.isEmpty()) { - return 0L - } - val middle = sorted.size / 2 - return if (sorted.size % 2 == 0) { - (sorted[middle - 1] + sorted[middle]) / 2 - } else { - sorted[middle] - } - } - - /** - * Formats a duration in nanoseconds to a human-readable string. - * Uses milliseconds (ms) by default, switches to seconds (s) if >= 1000ms. - */ - private fun formatDuration(ns: Long): String { - val ms = ns.toDouble() / 1_000_000.0 - return if (ms >= 1000.0) { - val secs = ms / 1000.0 - String.format("%.3fs", secs) - } else { - String.format("%.3fms", ms) - } - } - - private fun formatBenchReport(report: BenchReport): String = buildString { - appendLine("=== Benchmark Results ===") - appendLine() - appendLine("Function: ${report.spec.name}") - appendLine("Iterations: ${report.spec.iterations}") - appendLine("Warmup: ${report.spec.warmup}") - appendLine() - appendLine("Samples (${report.samples.size}):") - report.samples.forEachIndexed { index, sample -> - appendLine(" ${index + 1}. ${formatDuration(sample.durationNs.toLong())}") - } - - if (report.samples.isNotEmpty()) { - val durations = report.samples.map { it.durationNs.toLong() } - val min = durations.minOrNull() ?: 0L - val max = durations.maxOrNull() ?: 0L - val avg = durations.sum().toDouble() / durations.size.toDouble() - appendLine() - appendLine("Statistics:") - appendLine(" Min: ${formatDuration(min)}") - appendLine(" Max: ${formatDuration(max)}") - appendLine(" Avg: ${formatDuration(avg.toLong())}") - } - } - - private fun logBenchReport(report: BenchReport, runProcessPeakMemoryKb: Long?) { - val json = JSONObject() - val spec = JSONObject() - spec.put("name", report.spec.name) - spec.put("iterations", report.spec.iterations.toInt()) - spec.put("warmup", report.spec.warmup.toInt()) - json.put("spec", spec) - - val durationSamplesNs = report.samples.map { it.durationNs.toLong() } - val samplesNs = JSONArray() - durationSamplesNs.forEach { samplesNs.put(it) } - json.put("samples_ns", samplesNs) - - val samples = JSONArray() - report.samples.forEach { sample -> - val sampleJson = JSONObject() - sampleJson.put("duration_ns", sample.durationNs.toLong()) - sample.cpuTimeMs?.let { sampleJson.put("cpu_time_ms", it.toLong()) } - sample.peakMemoryKb?.let { sampleJson.put("peak_memory_kb", it.toLong()) } - optionalProcessPeakMemoryKb(sample)?.let { sampleJson.put("process_peak_memory_kb", it) } - samples.put(sampleJson) - } - json.put("samples", samples) - - val phases = JSONArray() - report.phases.forEach { phase -> - val phaseJson = JSONObject() - phaseJson.put("name", phase.name) - phaseJson.put("duration_ns", phase.durationNs.toLong()) - phases.put(phaseJson) - } - json.put("phases", phases) - - if (durationSamplesNs.isNotEmpty()) { - val min = durationSamplesNs.minOrNull() ?: 0L - val max = durationSamplesNs.maxOrNull() ?: 0L - val avg = durationSamplesNs.sum().toDouble() / durationSamplesNs.size.toDouble() - val stats = JSONObject() - stats.put("min_ns", min) - stats.put("max_ns", max) - stats.put("avg_ns", avg.toDouble()) - json.put("stats", stats) - } - - val cpuSamplesMs = report.samples.mapNotNull { it.cpuTimeMs?.toLong() } - val peakSamplesKb = report.samples.mapNotNull { it.peakMemoryKb?.toLong() } - val processPeakSamplesKb = report.samples.mapNotNull { optionalProcessPeakMemoryKb(it) } - val memInfo = Debug.MemoryInfo() - Debug.getMemoryInfo(memInfo) - val resources = JSONObject() - resources.put("platform", "android") - resources.put("timestamp_ms", System.currentTimeMillis()) - if (cpuSamplesMs.isNotEmpty()) { - val cpuTotalMs = cpuSamplesMs.sum() - resources.put("cpu_total_ms", cpuTotalMs) - resources.put("cpu_median_ms", median(cpuSamplesMs)) - resources.put("elapsed_cpu_ms", cpuTotalMs) - } - if (peakSamplesKb.isNotEmpty()) { - val peakGrowthKb = peakSamplesKb.maxOrNull() ?: 0L - resources.put("peak_memory_kb", peakGrowthKb) - resources.put("peak_memory_growth_kb", peakGrowthKb) - } - val processPeakMemoryKb = processPeakSamplesKb.maxOrNull() ?: runProcessPeakMemoryKb - processPeakMemoryKb?.let { - resources.put("process_peak_memory_kb", it) - } - resources.put("total_pss_kb", memInfo.totalPss) - resources.put("private_dirty_kb", memInfo.totalPrivateDirty) - resources.put("native_heap_kb", Debug.getNativeHeapAllocatedSize() / 1024) - val usedHeap = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() - resources.put("java_heap_kb", usedHeap / 1024) - json.put("resources", resources) - - android.util.Log.i("BenchRunner", "BENCH_JSON ${json}") - } - - private fun optionalProcessPeakMemoryKb(sample: Any): Long? { - return optionalNumericProperty(sample, "processPeakMemoryKb", "getProcessPeakMemoryKb") - } - - private fun optionalNumericProperty(instance: Any, propertyName: String, getterName: String): Long? { - try { - val getter = instance.javaClass.methods.firstOrNull { - it.name == getterName && it.parameterTypes.isEmpty() - } - coerceToLong(getter?.invoke(instance))?.let { return it } - } catch (e: Exception) { - android.util.Log.d("BenchRunner", "Optional property getter unavailable: $propertyName") - } try { - val field = instance.javaClass.declaredFields.firstOrNull { it.name == propertyName } - if (field != null) { - field.isAccessible = true - coerceToLong(field.get(instance))?.let { return it } + val intent = Intent(this, BenchmarkWorkerService::class.java).apply { + action = RUN_BENCHMARK_ACTION + putExtra(FUNCTION_EXTRA, params.function) + putExtra(ITERATIONS_EXTRA, params.iterations.toInt()) + putExtra(WARMUP_EXTRA, params.warmup.toInt()) + putExtra(RESULT_RECEIVER_EXTRA, resultReceiver) } + startService(intent) } catch (e: Exception) { - android.util.Log.d("BenchRunner", "Optional property field unavailable: $propertyName") - } - - return null - } - - private fun coerceToLong(value: Any?): Long? { - return when (value) { - null -> null - is Long -> value - is Int -> value.toLong() - is Short -> value.toLong() - is Byte -> value.toLong() - is Number -> value.toLong() - else -> value.toString().toLongOrNull() + android.util.Log.e("BenchRunner", "Failed to start benchmark worker", e) + resultText?.text = "Failed to start benchmark worker: ${e.message}" } } @@ -397,3 +174,332 @@ class MainActivity : AppCompatActivity() { } } } + +class BenchmarkWorkerService : Service() { + + private data class BenchParams( + val function: String, + val iterations: UInt, + val warmup: UInt, + ) + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action != RUN_BENCHMARK_ACTION) { + stopSelf(startId) + return START_NOT_STICKY + } + + val resultReceiver = intent.resultReceiverExtra() + val params = BenchParams( + function = intent.getStringExtra(FUNCTION_EXTRA)?.takeUnless { it.isBlank() } ?: DEFAULT_FUNCTION, + iterations = intent.getIntExtra(ITERATIONS_EXTRA, DEFAULT_ITERATIONS.toInt()).toUInt(), + warmup = intent.getIntExtra(WARMUP_EXTRA, DEFAULT_WARMUP.toInt()).toUInt(), + ) + + Thread { + val result = runBenchmarkInWorker(params) + val bundle = Bundle().apply { + putString(RESULT_DISPLAY_EXTRA, result.displayText) + result.errorMessage?.let { putString(RESULT_ERROR_EXTRA, it) } + } + resultReceiver?.send(if (result.errorMessage == null) RESULT_OK else RESULT_ERROR, bundle) + stopSelf(startId) + }.apply { + name = "mobench-benchmark-worker" + start() + } + + return START_NOT_STICKY + } + + private fun runBenchmarkInWorker(params: BenchParams): WorkerBenchmarkResult { + BenchNativeLibrary.ensureLoaded() + val display = try { + val spec = BenchSpec( + name = params.function, + iterations = params.iterations, + warmup = params.warmup + ) + val processMemorySampler = ProcessMemorySampler() + var runProcessPeakMemoryKb: Long? + processMemorySampler.start() + val report = try { + runBenchmark(spec) + } finally { + runProcessPeakMemoryKb = processMemorySampler.stop() + } + // Debug: Log first sample's raw nanoseconds + if (report.samples.isNotEmpty()) { + android.util.Log.d("BenchmarkWorker", "First sample duration_ns: ${report.samples[0].durationNs}") + } + val json = buildBenchReportJson(report, runProcessPeakMemoryKb) + android.util.Log.i("BenchRunner", "BENCH_JSON ${json}") + formatBenchReport(report) + } catch (e: BenchException) { + android.util.Log.e("BenchRunner", "Benchmark error: ${e.message}", e) + return WorkerBenchmarkResult( + displayText = "Benchmark error: ${e.message}", + errorMessage = "Benchmark error: ${e.message}" + ) + } catch (e: Exception) { + android.util.Log.e("BenchRunner", "Unexpected error during benchmark execution", e) + return WorkerBenchmarkResult( + displayText = "Unexpected error: ${e.message}", + errorMessage = "Unexpected error: ${e.message}" + ) + } + + return WorkerBenchmarkResult(displayText = display, errorMessage = null) + } +} + +private data class WorkerBenchmarkResult( + val displayText: String, + val errorMessage: String?, +) + +private object BenchNativeLibrary { + init { + System.loadLibrary("{{LIBRARY_NAME}}") + } + + fun ensureLoaded() = Unit +} + +@Suppress("DEPRECATION") +private fun Intent.resultReceiverExtra(): ResultReceiver? { + return if (Build.VERSION.SDK_INT >= 33) { + getParcelableExtra(RESULT_RECEIVER_EXTRA, ResultReceiver::class.java) + } else { + getParcelableExtra(RESULT_RECEIVER_EXTRA) as? ResultReceiver + } +} + +private class ProcessMemorySampler(private val sampleIntervalMs: Long = 10L) { + @Volatile private var running = false + @Volatile private var peakKb = 0L + private var samplerThread: Thread? = null + + fun start() { + if (running) { + return + } + + running = true + recordCurrent() + samplerThread = Thread { + while (running) { + recordCurrent() + try { + Thread.sleep(sampleIntervalMs) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + break + } + } + recordCurrent() + }.apply { + name = "mobench-process-memory-sampler" + isDaemon = true + start() + } + } + + fun stop(): Long? { + running = false + try { + samplerThread?.join(sampleIntervalMs * 2) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } + recordCurrent() + return peakKb.takeIf { it > 0L } + } + + @Synchronized + private fun recordCurrent() { + currentProcessPssKb()?.let { observedKb -> + if (observedKb > peakKb) { + peakKb = observedKb + } + } + } +} + +private fun currentProcessPssKb(): Long? { + val memInfo = Debug.MemoryInfo() + Debug.getMemoryInfo(memInfo) + return memInfo.totalPss.toLong().takeIf { it > 0L } +} + +private fun median(values: List): Long { + val sorted = values.sorted() + if (sorted.isEmpty()) { + return 0L + } + val middle = sorted.size / 2 + return if (sorted.size % 2 == 0) { + (sorted[middle - 1] + sorted[middle]) / 2 + } else { + sorted[middle] + } +} + +/** + * Formats a duration in nanoseconds to a human-readable string. + * Uses milliseconds (ms) by default, switches to seconds (s) if >= 1000ms. + */ +private fun formatDuration(ns: Long): String { + val ms = ns.toDouble() / 1_000_000.0 + return if (ms >= 1000.0) { + val secs = ms / 1000.0 + String.format("%.3fs", secs) + } else { + String.format("%.3fms", ms) + } +} + +private fun formatBenchReport(report: BenchReport): String = buildString { + appendLine("=== Benchmark Results ===") + appendLine() + appendLine("Function: ${report.spec.name}") + appendLine("Iterations: ${report.spec.iterations}") + appendLine("Warmup: ${report.spec.warmup}") + appendLine() + appendLine("Samples (${report.samples.size}):") + report.samples.forEachIndexed { index, sample -> + appendLine(" ${index + 1}. ${formatDuration(sample.durationNs.toLong())}") + } + + if (report.samples.isNotEmpty()) { + val durations = report.samples.map { it.durationNs.toLong() } + val min = durations.minOrNull() ?: 0L + val max = durations.maxOrNull() ?: 0L + val avg = durations.sum().toDouble() / durations.size.toDouble() + appendLine() + appendLine("Statistics:") + appendLine(" Min: ${formatDuration(min)}") + appendLine(" Max: ${formatDuration(max)}") + appendLine(" Avg: ${formatDuration(avg.toLong())}") + } +} + +private fun buildBenchReportJson(report: BenchReport, runProcessPeakMemoryKb: Long?): JSONObject { + val json = JSONObject() + val spec = JSONObject() + spec.put("name", report.spec.name) + spec.put("iterations", report.spec.iterations.toInt()) + spec.put("warmup", report.spec.warmup.toInt()) + json.put("spec", spec) + + val durationSamplesNs = report.samples.map { it.durationNs.toLong() } + val samplesNs = JSONArray() + durationSamplesNs.forEach { samplesNs.put(it) } + json.put("samples_ns", samplesNs) + + val samples = JSONArray() + report.samples.forEach { sample -> + val sampleJson = JSONObject() + sampleJson.put("duration_ns", sample.durationNs.toLong()) + sample.cpuTimeMs?.let { sampleJson.put("cpu_time_ms", it.toLong()) } + sample.peakMemoryKb?.let { sampleJson.put("peak_memory_kb", it.toLong()) } + optionalProcessPeakMemoryKb(sample)?.let { sampleJson.put("process_peak_memory_kb", it) } + samples.put(sampleJson) + } + json.put("samples", samples) + + val phases = JSONArray() + report.phases.forEach { phase -> + val phaseJson = JSONObject() + phaseJson.put("name", phase.name) + phaseJson.put("duration_ns", phase.durationNs.toLong()) + phases.put(phaseJson) + } + json.put("phases", phases) + + if (durationSamplesNs.isNotEmpty()) { + val min = durationSamplesNs.minOrNull() ?: 0L + val max = durationSamplesNs.maxOrNull() ?: 0L + val avg = durationSamplesNs.sum().toDouble() / durationSamplesNs.size.toDouble() + val stats = JSONObject() + stats.put("min_ns", min) + stats.put("max_ns", max) + stats.put("avg_ns", avg.toDouble()) + json.put("stats", stats) + } + + val cpuSamplesMs = report.samples.mapNotNull { it.cpuTimeMs?.toLong() } + val peakSamplesKb = report.samples.mapNotNull { it.peakMemoryKb?.toLong() } + val processPeakSamplesKb = report.samples.mapNotNull { optionalProcessPeakMemoryKb(it) } + val memInfo = Debug.MemoryInfo() + Debug.getMemoryInfo(memInfo) + val resources = JSONObject() + resources.put("platform", "android") + resources.put("timestamp_ms", System.currentTimeMillis()) + resources.put("memory_process", "isolated_worker") + if (cpuSamplesMs.isNotEmpty()) { + val cpuTotalMs = cpuSamplesMs.sum() + resources.put("cpu_total_ms", cpuTotalMs) + resources.put("cpu_median_ms", median(cpuSamplesMs)) + resources.put("elapsed_cpu_ms", cpuTotalMs) + } + if (peakSamplesKb.isNotEmpty()) { + val peakGrowthKb = peakSamplesKb.maxOrNull() ?: 0L + resources.put("peak_memory_kb", peakGrowthKb) + resources.put("peak_memory_growth_kb", peakGrowthKb) + } + val processPeakMemoryKb = processPeakSamplesKb.maxOrNull() ?: runProcessPeakMemoryKb + processPeakMemoryKb?.let { + resources.put("process_peak_memory_kb", it) + resources.put("isolated_process_peak_memory_kb", it) + } + resources.put("total_pss_kb", memInfo.totalPss) + resources.put("private_dirty_kb", memInfo.totalPrivateDirty) + resources.put("native_heap_kb", Debug.getNativeHeapAllocatedSize() / 1024) + val usedHeap = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() + resources.put("java_heap_kb", usedHeap / 1024) + json.put("resources", resources) + + return json +} + +private fun optionalProcessPeakMemoryKb(sample: Any): Long? { + return optionalNumericProperty(sample, "processPeakMemoryKb", "getProcessPeakMemoryKb") +} + +private fun optionalNumericProperty(instance: Any, propertyName: String, getterName: String): Long? { + try { + val getter = instance.javaClass.methods.firstOrNull { + it.name == getterName && it.parameterTypes.isEmpty() + } + coerceToLong(getter?.invoke(instance))?.let { return it } + } catch (e: Exception) { + android.util.Log.d("BenchRunner", "Optional property getter unavailable: $propertyName") + } + + try { + val field = instance.javaClass.declaredFields.firstOrNull { it.name == propertyName } + if (field != null) { + field.isAccessible = true + coerceToLong(field.get(instance))?.let { return it } + } + } catch (e: Exception) { + android.util.Log.d("BenchRunner", "Optional property field unavailable: $propertyName") + } + + return null +} + +private fun coerceToLong(value: Any?): Long? { + return when (value) { + null -> null + is Long -> value + is Int -> value.toLong() + is Short -> value.toLong() + is Byte -> value.toLong() + is Number -> value.toLong() + else -> value.toString().toLongOrNull() + } +} diff --git a/crates/mobench/README.md b/crates/mobench/README.md index cffb361..3de7fbe 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -272,7 +272,7 @@ cargo mobench ci run --target --function [OPTIONS] - `cpu_median_ms` - `peak_memory_kb` (legacy alias for peak growth) - `peak_memory_growth_kb` -- `process_peak_memory_kb` (benchmark app/process peak, with Android harness sampling as a fallback) +- `process_peak_memory_kb` (Android uses an isolated benchmark worker process; other targets use the benchmark app process) - `absolute_peak_memory_kb` (provider/session peak, for example BrowserStack) Blank fields indicate that a resource metric was not available for that benchmark/device row. diff --git a/docs/guides/browserstack-metrics.md b/docs/guides/browserstack-metrics.md index 65d60b8..7c5bf68 100644 --- a/docs/guides/browserstack-metrics.md +++ b/docs/guides/browserstack-metrics.md @@ -48,7 +48,7 @@ We recursively download ALL URLs from session JSON, which typically includes: **Benchmark-scoped resource metrics (current default):** - Each measured iteration can emit `cpu_time_ms`, `peak_memory_kb`, and `process_peak_memory_kb` -- Android runs also sample the benchmark app process PSS around `runBenchmark` as a fallback when older UniFFI bindings do not expose per-sample process peaks +- Android runs execute the benchmark in an isolated `:mobench_worker` process and report that worker process peak through `process_peak_memory_kb` - `mobench` derives `cpu_median_ms` from measured iterations only - `summary.md` renders `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, `Process peak`, and `Provider peak` in the top-level CI table - `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`, `peak_memory_growth_kb`, `process_peak_memory_kb`, and `absolute_peak_memory_kb` From 81236b034f322ef40d1e58600ad0ee90562c7847 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 23 Apr 2026 10:26:13 +0200 Subject: [PATCH 171/196] Reduce Android worker memory sampler overhead --- crates/mobench-sdk/src/codegen.rs | 2 + .../src/main/java/MainActivity.kt.template | 41 +++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index fcd2c7b..323d316 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -1463,6 +1463,8 @@ mod tests { ); assert!(android.contains("optionalProcessPeakMemoryKb(sample)")); assert!(android.contains("ProcessMemorySampler")); + assert!(android.contains("sampleIntervalMs: Long = 250L")); + assert!(android.contains("/proc/self/smaps_rollup")); assert!(android.contains("class BenchmarkWorkerService : Service()")); assert!(android.contains("memory_process\", \"isolated_worker\"")); diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index 005eeb5..7d9e528 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -277,7 +277,7 @@ private fun Intent.resultReceiverExtra(): ResultReceiver? { } } -private class ProcessMemorySampler(private val sampleIntervalMs: Long = 10L) { +private class ProcessMemorySampler(private val sampleIntervalMs: Long = 250L) { @Volatile private var running = false @Volatile private var peakKb = 0L private var samplerThread: Thread? = null @@ -329,9 +329,44 @@ private class ProcessMemorySampler(private val sampleIntervalMs: Long = 10L) { } private fun currentProcessPssKb(): Long? { + readProcMemoryKb("/proc/self/smaps_rollup", "Pss:")?.let { return it } + readProcMemoryKb("/proc/self/status", "VmHWM:")?.let { return it } + readProcMemoryKb("/proc/self/status", "VmRSS:")?.let { return it } + val memInfo = Debug.MemoryInfo() - Debug.getMemoryInfo(memInfo) - return memInfo.totalPss.toLong().takeIf { it > 0L } + return try { + Debug.getMemoryInfo(memInfo) + memInfo.totalPss.toLong().takeIf { it > 0L } + } catch (e: Exception) { + android.util.Log.d("BenchRunner", "Unable to read process memory from Debug.getMemoryInfo", e) + null + } +} + +private fun readProcMemoryKb(path: String, label: String): Long? { + return try { + java.io.File(path).bufferedReader().use { reader -> + var line: String? = reader.readLine() + while (line != null) { + if (line.startsWith(label)) { + return@use parseMemoryKb(line) + } + line = reader.readLine() + } + null + } + } catch (e: Exception) { + null + } +} + +private fun parseMemoryKb(line: String): Long? { + val value = line + .substringAfter(':', "") + .trim() + .split(' ') + .firstOrNull { it.isNotBlank() } + return value?.toLongOrNull()?.takeIf { it > 0L } } private fun median(values: List): Long { From b3dc85c5db3edf54311021b16b22276ae94ca032 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 23 Apr 2026 16:15:06 +0200 Subject: [PATCH 172/196] Block Android harness while worker runs --- crates/mobench-sdk/src/codegen.rs | 10 +++++ .../java/MainActivityTest.kt.template | 18 +-------- .../src/main/java/MainActivity.kt.template | 38 +++++++++++++++---- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 323d316..73f93d8 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -1466,8 +1466,18 @@ mod tests { assert!(android.contains("sampleIntervalMs: Long = 250L")); assert!(android.contains("/proc/self/smaps_rollup")); assert!(android.contains("class BenchmarkWorkerService : Service()")); + assert!(android.contains("ResultReceiver(null)")); + assert!(android.contains("resultLatch.await(30, TimeUnit.MINUTES)")); + assert!(!android.contains("Handler(")); + assert!(!android.contains("Looper.getMainLooper")); assert!(android.contains("memory_process\", \"isolated_worker\"")); + let android_test = include_str!( + "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template" + ); + assert!(!android_test.contains("Thread.sleep")); + assert!(!android_test.contains("while (")); + let android_manifest = include_str!("../templates/android/app/src/main/AndroidManifest.xml"); assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\"")); diff --git a/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template b/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template index 0373707..d041367 100644 --- a/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template @@ -7,7 +7,6 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import org.hamcrest.Matchers.containsString -import org.junit.Assert.fail import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -20,20 +19,7 @@ class MainActivityTest { @Test fun showsBenchOutput() { - val deadline = System.currentTimeMillis() + 30 * 60 * 1000L - var lastError: Throwable? = null - - while (System.currentTimeMillis() < deadline) { - try { - onView(withId(R.id.result_text)) - .check(matches(withText(containsString("Samples")))) - return - } catch (error: Throwable) { - lastError = error - Thread.sleep(500) - } - } - - fail("Timed out waiting for benchmark output containing 'Samples': ${lastError?.message}") + onView(withId(R.id.result_text)) + .check(matches(withText(containsString("Samples")))) } } diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index 7d9e528..d6e7278 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -5,12 +5,13 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.os.Debug -import android.os.Handler import android.os.IBinder -import android.os.Looper import android.os.ResultReceiver import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference import org.json.JSONArray import org.json.JSONObject import uniffi.{{UNIFFI_NAMESPACE}}.BenchException @@ -48,22 +49,25 @@ class MainActivity : AppCompatActivity() { resultText?.text = "Running benchmark..." val params = resolveBenchParams() - val resultReceiver = object : ResultReceiver(Handler(Looper.getMainLooper())) { + val resultDisplay = AtomicReference("Benchmark worker returned no result") + val resultLatch = CountDownLatch(1) + val resultReceiver = object : ResultReceiver(null) { override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { val display = resultData?.getString(RESULT_DISPLAY_EXTRA) ?: resultData?.getString(RESULT_ERROR_EXTRA) ?: "Benchmark worker returned no result" - resultText?.text = display + resultDisplay.set(display) if (resultCode == RESULT_OK) { android.util.Log.i("BenchRunner", "Benchmark worker completed") } else { android.util.Log.e("BenchRunner", display) } + resultLatch.countDown() } } - try { + val display = try { val intent = Intent(this, BenchmarkWorkerService::class.java).apply { action = RUN_BENCHMARK_ACTION putExtra(FUNCTION_EXTRA, params.function) @@ -72,10 +76,30 @@ class MainActivity : AppCompatActivity() { putExtra(RESULT_RECEIVER_EXTRA, resultReceiver) } startService(intent) + + val completed = resultLatch.await(30, TimeUnit.MINUTES) + if (completed) { + resultDisplay.get() + } else { + android.util.Log.e("BenchRunner", "Timed out waiting for benchmark worker result") + "Timed out waiting for benchmark worker result" + } } catch (e: Exception) { - android.util.Log.e("BenchRunner", "Failed to start benchmark worker", e) - resultText?.text = "Failed to start benchmark worker: ${e.message}" + android.util.Log.e("BenchRunner", "Failed to run benchmark worker", e) + "Failed to run benchmark worker: ${e.message}" + } + + resultText?.text = display + + // Keep the report visible briefly so local smoke runs and remote automation + // have a stable window to read the results. + android.util.Log.i("BenchRunner", "Displaying results for 5 seconds for capture output...") + try { + Thread.sleep(5000) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() } + android.util.Log.i("BenchRunner", "Display hold complete") } private fun resolveBenchParams(): BenchParams { From 5c808ca55cf702369d364402ca1a2857a6995b10 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 23 Apr 2026 17:53:16 +0200 Subject: [PATCH 173/196] Keep Android worker benchmarks alive --- crates/mobench-sdk/src/codegen.rs | 18 +-- .../java/MainActivityTest.kt.template | 30 +++++ .../android/app/src/main/AndroidManifest.xml | 2 + .../src/main/java/MainActivity.kt.template | 114 ++++++++++++------ crates/mobench/src/browserstack.rs | 5 + 5 files changed, 127 insertions(+), 42 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 73f93d8..4a32263 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -1463,23 +1463,27 @@ mod tests { ); assert!(android.contains("optionalProcessPeakMemoryKb(sample)")); assert!(android.contains("ProcessMemorySampler")); - assert!(android.contains("sampleIntervalMs: Long = 250L")); + assert!(android.contains("sampleIntervalMs: Long = 1000L")); assert!(android.contains("/proc/self/smaps_rollup")); assert!(android.contains("class BenchmarkWorkerService : Service()")); - assert!(android.contains("ResultReceiver(null)")); - assert!(android.contains("resultLatch.await(30, TimeUnit.MINUTES)")); - assert!(!android.contains("Handler(")); - assert!(!android.contains("Looper.getMainLooper")); + assert!(android.contains("ResultReceiver(Handler(Looper.getMainLooper()))")); + assert!(android.contains("startForegroundService(intent)")); + assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID")); + assert!(android.contains("fun isBenchmarkComplete()")); + assert!(!android.contains("resultLatch.await")); assert!(android.contains("memory_process\", \"isolated_worker\"")); let android_test = include_str!( "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template" ); - assert!(!android_test.contains("Thread.sleep")); - assert!(!android_test.contains("while (")); + assert!(android_test.contains("Log.i(\"BenchRunnerTest\"")); + assert!(android_test.contains("Thread.sleep(heartbeatMs)")); + assert!(android_test.contains("TimeUnit.SECONDS.toMillis(10)")); + assert!(android_test.contains("activity.isBenchmarkComplete()")); let android_manifest = include_str!("../templates/android/app/src/main/AndroidManifest.xml"); + assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE")); assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\"")); assert!(android_manifest.contains("android:process=\":mobench_worker\"")); diff --git a/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template b/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template index d041367..24205e0 100644 --- a/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template @@ -1,12 +1,15 @@ package {{PACKAGE_NAME}} +import android.util.Log import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText +import java.util.concurrent.TimeUnit import org.hamcrest.Matchers.containsString +import org.junit.Assert.fail import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -14,11 +17,38 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class MainActivityTest { + private val benchmarkTimeoutMs = TimeUnit.MINUTES.toMillis(30) + private val heartbeatMs = TimeUnit.SECONDS.toMillis(10) + @get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java) @Test fun showsBenchOutput() { + val deadline = System.currentTimeMillis() + benchmarkTimeoutMs + var completed = false + + while (System.currentTimeMillis() < deadline) { + activityRule.scenario.onActivity { activity -> + completed = activity.isBenchmarkComplete() + } + if (completed) { + break + } + + Log.i("BenchRunnerTest", "Waiting for benchmark output...") + try { + Thread.sleep(heartbeatMs) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + fail("Interrupted while waiting for benchmark output") + } + } + + if (!completed) { + fail("Timed out waiting for benchmark output") + } + onView(withId(R.id.result_text)) .check(matches(withText(containsString("Samples")))) } diff --git a/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml b/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml index 7a3b1d4..8a2bb49 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml +++ b/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + = Build.VERSION_CODES.O) { + startForegroundService(intent) } else { - android.util.Log.e("BenchRunner", "Timed out waiting for benchmark worker result") - "Timed out waiting for benchmark worker result" + startService(intent) } } catch (e: Exception) { android.util.Log.e("BenchRunner", "Failed to run benchmark worker", e) - "Failed to run benchmark worker: ${e.message}" + resultText?.text = "Failed to run benchmark worker: ${e.message}" + benchmarkComplete = true } + } - resultText?.text = display - - // Keep the report visible briefly so local smoke runs and remote automation - // have a stable window to read the results. - android.util.Log.i("BenchRunner", "Displaying results for 5 seconds for capture output...") - try { - Thread.sleep(5000) - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - } - android.util.Log.i("BenchRunner", "Display hold complete") + fun isBenchmarkComplete(): Boolean { + return benchmarkComplete } private fun resolveBenchParams(): BenchParams { @@ -222,14 +216,20 @@ class BenchmarkWorkerService : Service() { warmup = intent.getIntExtra(WARMUP_EXTRA, DEFAULT_WARMUP.toInt()).toUInt(), ) + startBenchmarkForeground() + Thread { - val result = runBenchmarkInWorker(params) - val bundle = Bundle().apply { - putString(RESULT_DISPLAY_EXTRA, result.displayText) - result.errorMessage?.let { putString(RESULT_ERROR_EXTRA, it) } + try { + val result = runBenchmarkInWorker(params) + val bundle = Bundle().apply { + putString(RESULT_DISPLAY_EXTRA, result.displayText) + result.errorMessage?.let { putString(RESULT_ERROR_EXTRA, it) } + } + resultReceiver?.send(if (result.errorMessage == null) RESULT_OK else RESULT_ERROR, bundle) + } finally { + stopBenchmarkForeground() + stopSelf(startId) } - resultReceiver?.send(if (result.errorMessage == null) RESULT_OK else RESULT_ERROR, bundle) - stopSelf(startId) }.apply { name = "mobench-benchmark-worker" start() @@ -238,6 +238,50 @@ class BenchmarkWorkerService : Service() { return START_NOT_STICKY } + private fun startBenchmarkForeground() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = getSystemService(NotificationManager::class.java) + manager?.createNotificationChannel( + NotificationChannel( + FOREGROUND_CHANNEL_ID, + FOREGROUND_CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ) + ) + } + + startForeground(FOREGROUND_NOTIFICATION_ID, buildBenchmarkNotification()) + } + + @Suppress("DEPRECATION") + private fun stopBenchmarkForeground() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(Service.STOP_FOREGROUND_REMOVE) + } else { + stopForeground(true) + } + } + + @Suppress("DEPRECATION") + private fun buildBenchmarkNotification(): Notification { + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, FOREGROUND_CHANNEL_ID) + } else { + Notification.Builder(this) + .setPriority(Notification.PRIORITY_LOW) + } + + return builder + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle("Running benchmark") + .setContentText("Mobench isolated worker is measuring this run") + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setCategory(Notification.CATEGORY_SERVICE) + .build() + } + private fun runBenchmarkInWorker(params: BenchParams): WorkerBenchmarkResult { BenchNativeLibrary.ensureLoaded() val display = try { @@ -301,7 +345,7 @@ private fun Intent.resultReceiverExtra(): ResultReceiver? { } } -private class ProcessMemorySampler(private val sampleIntervalMs: Long = 250L) { +private class ProcessMemorySampler(private val sampleIntervalMs: Long = 1000L) { @Volatile private var running = false @Volatile private var peakKb = 0L private var samplerThread: Thread? = null diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index 83d1084..f5f821d 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -69,6 +69,7 @@ pub struct DeviceValidationError { } const DEFAULT_BASE_URL: &str = "https://api-cloud.browserstack.com"; +const ESPRESSO_IDLE_TIMEOUT_SECS: u64 = 900; const USER_AGENT: &str = "mobile-bench-rs/0.1"; #[derive(Debug, Clone)] @@ -242,6 +243,7 @@ impl BrowserStackClient { device_logs: true, disable_animations: true, app_profiling: true, + idle_timeout: ESPRESSO_IDLE_TIMEOUT_SECS, build_name: self.project.clone(), }; @@ -1583,6 +1585,7 @@ struct BuildRequest { device_logs: bool, disable_animations: bool, app_profiling: bool, + idle_timeout: u64, #[serde(skip_serializing_if = "Option::is_none")] build_name: Option, } @@ -2589,10 +2592,12 @@ Test completed disable_animations: true, build_name: Some("mobench".into()), app_profiling: true, + idle_timeout: ESPRESSO_IDLE_TIMEOUT_SECS, }; let value = serde_json::to_value(&request).expect("serialize build request"); assert_eq!(value["appProfiling"], true); + assert_eq!(value["idleTimeout"], ESPRESSO_IDLE_TIMEOUT_SECS); } #[test] From 2c0c2d04e1c3fdaafb40975adebdad94841cd02d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 23 Apr 2026 20:46:33 +0200 Subject: [PATCH 174/196] Drop provider peak memory reporting --- crates/mobench/README.md | 3 +- crates/mobench/src/lib.rs | 109 ++++++---------------------- crates/mobench/src/summarize.rs | 93 ++++-------------------- docs/MIGRATION_GUIDE.md | 4 +- docs/guides/browserstack-ci.md | 4 +- docs/guides/browserstack-metrics.md | 13 ++-- docs/schemas/summary-v1.schema.json | 1 - 7 files changed, 51 insertions(+), 176 deletions(-) diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 3de7fbe..2ef0e97 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -265,7 +265,7 @@ cargo mobench ci run --target --function [OPTIONS] `summary.md` renders one top-level table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, and -`Process peak`, plus `Provider peak` when provider/session telemetry is present. +`Process peak`. `results.csv` includes: - `cpu_total_ms` @@ -273,7 +273,6 @@ cargo mobench ci run --target --function [OPTIONS] - `peak_memory_kb` (legacy alias for peak growth) - `peak_memory_growth_kb` - `process_peak_memory_kb` (Android uses an isolated benchmark worker process; other targets use the benchmark app process) -- `absolute_peak_memory_kb` (provider/session peak, for example BrowserStack) Blank fields indicate that a resource metric was not available for that benchmark/device row. diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 6019e54..0b7aff3 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -1127,8 +1127,6 @@ struct BenchmarkResourceUsage { #[serde(skip_serializing_if = "Option::is_none")] process_peak_memory_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] - absolute_peak_memory_kb: Option, - #[serde(skip_serializing_if = "Option::is_none")] total_pss_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] private_dirty_kb: Option, @@ -5280,7 +5278,6 @@ impl BenchmarkResourceUsage { && self.peak_memory_kb.is_none() && self.peak_memory_growth_kb.is_none() && self.process_peak_memory_kb.is_none() - && self.absolute_peak_memory_kb.is_none() && self.total_pss_kb.is_none() && self.private_dirty_kb.is_none() && self.native_heap_kb.is_none() @@ -5398,25 +5395,9 @@ fn median_u64(values: &[u64]) -> Option { }) } -fn mb_to_kb(value: f64) -> Option { - value - .is_finite() - .then_some(value) - .filter(|value| *value >= 0.0) - .map(|value| (value * 1024.0).round() as u64) -} - -fn browserstack_absolute_peak_memory_kb( - perf_metrics: Option<&browserstack::PerformanceMetrics>, -) -> Option { - perf_metrics - .and_then(|metrics| metrics.memory.as_ref()) - .and_then(|memory| mb_to_kb(memory.peak_mb)) -} - fn extract_benchmark_resource_usage( entry: &Value, - perf_metrics: Option<&browserstack::PerformanceMetrics>, + _perf_metrics: Option<&browserstack::PerformanceMetrics>, ) -> Option { let resources = entry .get("resource_usage") @@ -5472,16 +5453,6 @@ fn extract_benchmark_resource_usage( .and_then(|res| res.get("process_peak_memory_kb")) .and_then(json_value_to_u64) .or_else(|| sample_process_peak_memory_kb.iter().copied().max()); - let absolute_peak_memory_kb = resources - .and_then(|res| res.get("absolute_peak_memory_kb")) - .and_then(json_value_to_u64) - .or_else(|| browserstack_absolute_peak_memory_kb(perf_metrics)) - .or_else(|| { - resources - .and_then(|res| res.get("ram_peak_mb")) - .and_then(|value| value.as_f64()) - .and_then(mb_to_kb) - }); let resource_usage = BenchmarkResourceUsage { cpu_total_ms, @@ -5489,7 +5460,6 @@ fn extract_benchmark_resource_usage( peak_memory_kb, peak_memory_growth_kb, process_peak_memory_kb, - absolute_peak_memory_kb, total_pss_kb, private_dirty_kb, native_heap_kb, @@ -5527,17 +5497,17 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { let _ = writeln!( output, - "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak | Provider peak |" + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak |" ); let _ = writeln!( output, - "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" ); for device in &summary.device_summaries { for bench in &device.benchmarks { let _ = writeln!( output, - "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", + "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", device.device, bench.function, bench.samples, @@ -5559,12 +5529,6 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { .as_ref() .and_then(|usage| usage.process_peak_memory_kb) ), - format_peak_memory( - bench - .resource_usage - .as_ref() - .and_then(|usage| usage.absolute_peak_memory_kb) - ), ); } } @@ -5581,13 +5545,13 @@ fn render_csv_summary(summary: &SummaryReport) -> String { let mut output = String::new(); let _ = writeln!( output, - "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,process_peak_memory_kb,absolute_peak_memory_kb" + "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,process_peak_memory_kb" ); for device in &summary.device_summaries { for bench in &device.benchmarks { let _ = writeln!( output, - "{},{},{},{},{},{},{},{},{},{},{},{},{},{}", + "{},{},{},{},{},{},{},{},{},{},{},{},{}", device.device, bench.function, bench.samples, @@ -5620,11 +5584,6 @@ fn render_csv_summary(summary: &SummaryReport) -> String { .resource_usage .as_ref() .and_then(|usage| usage.process_peak_memory_kb) - .map_or(String::new(), |v| v.to_string()), - bench - .resource_usage - .as_ref() - .and_then(|usage| usage.absolute_peak_memory_kb) .map_or(String::new(), |v| v.to_string()) ); } @@ -5725,9 +5684,7 @@ fn summary_has_memory_baseline_gap(summary: &SummaryReport) -> bool { } fn resource_usage_has_memory_baseline_gap(usage: &BenchmarkResourceUsage) -> bool { - let peak = usage - .process_peak_memory_kb - .or(usage.absolute_peak_memory_kb); + let peak = usage.process_peak_memory_kb; match (usage.peak_memory_growth_kb, peak) { (Some(growth), Some(peak)) if peak > growth => { peak.saturating_sub(growth) >= MEMORY_BASELINE_GAP_MIN_DIFF_KB @@ -9564,7 +9521,6 @@ test_suite = "target/ios/BenchRunnerUITests.zip" peak_memory_kb: Some(249_416), peak_memory_growth_kb: Some(249_416), process_peak_memory_kb: Some(1_477_787), - absolute_peak_memory_kb: None, total_pss_kb: None, private_dirty_kb: None, native_heap_kb: None, @@ -9575,7 +9531,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" }); assert!(markdown.contains( - "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak | Provider peak |" + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak |" )); assert!(markdown.contains("1.250s")); assert!(markdown.contains("6.250s")); @@ -9611,7 +9567,6 @@ test_suite = "target/ios/BenchRunnerUITests.zip" peak_memory_kb: Some(1_024), peak_memory_growth_kb: Some(1_024), process_peak_memory_kb: None, - absolute_peak_memory_kb: None, total_pss_kb: None, private_dirty_kb: None, native_heap_kb: None, @@ -9622,10 +9577,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" }); assert!(markdown.contains( - "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak | Provider peak |" + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak |" )); assert!(markdown.contains( - "| Google Pixel 8-14.0 | sample_fns::fibonacci | 4 | 1 | 1.000s | 4.000s | 200ms | 800ms | 20.0% | 1.00 MB | - | - |" + "| Google Pixel 8-14.0 | sample_fns::fibonacci | 4 | 1 | 1.000s | 4.000s | 200ms | 800ms | 20.0% | 1.00 MB | - |" )); assert!(!markdown.contains("### Device:")); } @@ -9656,7 +9611,6 @@ test_suite = "target/ios/BenchRunnerUITests.zip" peak_memory_kb: Some(249_416), peak_memory_growth_kb: Some(249_416), process_peak_memory_kb: Some(1_477_787), - absolute_peak_memory_kb: Some(1_680_026), total_pss_kb: None, private_dirty_kb: None, native_heap_kb: None, @@ -9668,10 +9622,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert!( csv.starts_with( - "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,process_peak_memory_kb,absolute_peak_memory_kb\n" + "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,process_peak_memory_kb\n" ) ); - assert!(csv.contains(",482,241,249416,249416,1477787,1680026\n")); + assert!(csv.contains(",482,241,249416,249416,1477787\n")); } #[test] @@ -9700,7 +9654,6 @@ test_suite = "target/ios/BenchRunnerUITests.zip" peak_memory_kb: Some(654_321), peak_memory_growth_kb: Some(654_321), process_peak_memory_kb: Some(1_477_787), - absolute_peak_memory_kb: None, total_pss_kb: Some(654_321), private_dirty_kb: None, native_heap_kb: None, @@ -9715,7 +9668,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert!(markdown.contains("CPU / wall")); assert!(markdown.contains("Peak growth")); assert!(markdown.contains("Process peak")); - assert!(markdown.contains("Provider peak")); + assert!(!markdown.contains("Provider peak")); assert!(!markdown.contains("Absolute peak")); assert!(!markdown.contains("Peak memory")); assert!(markdown.contains("241ms")); @@ -9750,7 +9703,6 @@ test_suite = "target/ios/BenchRunnerUITests.zip" peak_memory_kb: Some(654_321), peak_memory_growth_kb: Some(654_321), process_peak_memory_kb: Some(1_477_787), - absolute_peak_memory_kb: None, total_pss_kb: None, private_dirty_kb: None, native_heap_kb: None, @@ -9768,7 +9720,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert!(markdown.contains("CPU / wall")); assert!(markdown.contains("Peak growth")); assert!(markdown.contains("Process peak")); - assert!(markdown.contains("Provider peak")); + assert!(!markdown.contains("Provider peak")); assert!(!markdown.contains("Absolute peak")); assert!(!markdown.contains("Peak memory")); assert!(markdown.contains("241ms")); @@ -9778,7 +9730,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" } #[test] - fn render_markdown_summary_notes_large_absolute_memory_baseline_gap() { + fn render_markdown_summary_notes_large_process_memory_baseline_gap() { let markdown = render_markdown_summary(&SummaryReport { generated_at: "2026-04-12T00:00:00Z".to_string(), generated_at_unix: 1_744_416_000, @@ -9803,7 +9755,6 @@ test_suite = "target/ios/BenchRunnerUITests.zip" peak_memory_kb: Some(171_556), peak_memory_growth_kb: Some(171_556), process_peak_memory_kb: Some(1_477_787), - absolute_peak_memory_kb: Some(1_680_026), total_pss_kb: Some(1_477_787), private_dirty_kb: Some(1_462_460), native_heap_kb: None, @@ -9815,7 +9766,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert!(markdown.contains("Peak growth")); assert!(markdown.contains("Process peak")); - assert!(markdown.contains("Provider peak")); + assert!(!markdown.contains("Provider peak")); assert!(!markdown.contains("Absolute peak")); assert!(markdown.contains(MEMORY_BASELINE_GAP_NOTE)); assert!(!markdown.contains("Peak memory")); @@ -9864,7 +9815,6 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert_eq!(usage.peak_memory_kb, Some(96)); assert_eq!(usage.peak_memory_growth_kb, Some(96)); assert_eq!(usage.process_peak_memory_kb, Some(1_096)); - assert_eq!(usage.absolute_peak_memory_kb, None); } #[test] @@ -9919,7 +9869,6 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert_eq!(usage.peak_memory_kb, Some(72)); assert_eq!(usage.peak_memory_growth_kb, Some(72)); assert_eq!(usage.process_peak_memory_kb, Some(1_072)); - assert_eq!(usage.absolute_peak_memory_kb, Some(1_022_976)); } #[test] @@ -10389,8 +10338,8 @@ mod ci_merge_tests { assert!(markdown.starts_with("### Benchmark Summary\n")); assert!(markdown.contains("- Target: iOS")); - assert!(markdown.contains("| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak | Provider peak |")); - assert!(markdown.contains("| iPhone 13 | ffi_benchmark::bench_fibonacci | 5 | 1 | 0.017ms | 0.085ms | - | - | - | - | - | - |")); + assert!(markdown.contains("| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak |")); + assert!(markdown.contains("| iPhone 13 | ffi_benchmark::bench_fibonacci | 5 | 1 | 0.017ms | 0.085ms | - | - | - | - | - |")); assert!(!markdown.contains("### Device:")); } @@ -10642,7 +10591,6 @@ mod ci_merge_tests { assert_eq!(resource_usage["peak_memory_kb"], Value::Null); assert_eq!(resource_usage["peak_memory_growth_kb"], Value::Null); assert_eq!(resource_usage["process_peak_memory_kb"], Value::Null); - assert_eq!(resource_usage["absolute_peak_memory_kb"], Value::Null); assert_eq!(resource_usage["total_pss_kb"], 654321); assert_eq!(resource_usage["private_dirty_kb"], 321000); assert_eq!(resource_usage["native_heap_kb"], 120000); @@ -10650,7 +10598,7 @@ mod ci_merge_tests { } #[test] - fn build_summary_prefers_browserstack_peak_memory_for_ci_summary() { + fn build_summary_ignores_browserstack_peak_memory_for_ci_summary() { let spec = RunSpec { target: MobileTarget::Ios, function: "bench_nullifier_proving_only".into(), @@ -10702,13 +10650,9 @@ mod ci_merge_tests { let summary = build_summary(&run_summary).expect("build summary"); let value = serde_json::to_value(summary).expect("serialize summary"); - let resource_usage = &value["device_summaries"][0]["benchmarks"][0]["resource_usage"]; + let benchmark = &value["device_summaries"][0]["benchmarks"][0]; - assert_eq!(resource_usage["peak_memory_kb"], Value::Null); - assert_eq!(resource_usage["peak_memory_growth_kb"], Value::Null); - assert_eq!(resource_usage["process_peak_memory_kb"], Value::Null); - assert_eq!(resource_usage["absolute_peak_memory_kb"], 249416); - assert_eq!(resource_usage["cpu_total_ms"], Value::Null); + assert_eq!(benchmark["resource_usage"], Value::Null); } } @@ -10803,11 +10747,10 @@ mod resource_usage_tests { assert_eq!(usage.peak_memory_kb, None); assert_eq!(usage.peak_memory_growth_kb, None); assert_eq!(usage.process_peak_memory_kb, None); - assert_eq!(usage.absolute_peak_memory_kb, None); } #[test] - fn test_extract_resource_usage_with_perf_metrics_sets_absolute_peak() { + fn test_extract_resource_usage_ignores_provider_peak() { let entry = json!({ "resources": { "total_pss_kb": 4096 @@ -10828,12 +10771,11 @@ mod resource_usage_tests { assert_eq!(usage.peak_memory_kb, None); assert_eq!(usage.peak_memory_growth_kb, None); assert_eq!(usage.process_peak_memory_kb, None); - assert_eq!(usage.absolute_peak_memory_kb, Some(10240)); assert_eq!(usage.total_pss_kb, Some(4096)); } #[test] - fn test_extract_resource_usage_preserves_moto_growth_and_absolute_peak() { + fn test_extract_resource_usage_preserves_moto_growth_and_process_peak() { let entry = json!({ "resources": { "peak_memory_kb": 171556, @@ -10860,7 +10802,6 @@ mod resource_usage_tests { assert_eq!(usage.peak_memory_growth_kb, Some(171_556)); assert_eq!(usage.peak_memory_kb, Some(171_556)); assert_eq!(usage.process_peak_memory_kb, Some(1_477_787)); - assert_eq!(usage.absolute_peak_memory_kb, Some(1_680_026)); assert_eq!(usage.total_pss_kb, Some(1_477_787)); assert_eq!(usage.private_dirty_kb, Some(1_462_460)); assert_eq!(usage.native_heap_kb, Some(532_000)); @@ -10882,7 +10823,6 @@ mod resource_usage_tests { peak_memory_kb: Some(8192), peak_memory_growth_kb: Some(8192), process_peak_memory_kb: Some(12288), - absolute_peak_memory_kb: Some(16384), total_pss_kb: Some(4096), private_dirty_kb: Some(2048), native_heap_kb: Some(1024), @@ -10897,7 +10837,6 @@ mod resource_usage_tests { assert_eq!(deserialized.peak_memory_kb, Some(8192)); assert_eq!(deserialized.peak_memory_growth_kb, Some(8192)); assert_eq!(deserialized.process_peak_memory_kb, Some(12288)); - assert_eq!(deserialized.absolute_peak_memory_kb, Some(16384)); assert_eq!(deserialized.total_pss_kb, Some(4096)); assert_eq!(deserialized.private_dirty_kb, Some(2048)); assert_eq!(deserialized.native_heap_kb, Some(1024)); @@ -10908,6 +10847,6 @@ mod resource_usage_tests { assert!(json_str.contains("peak_memory_kb")); assert!(json_str.contains("peak_memory_growth_kb")); assert!(json_str.contains("process_peak_memory_kb")); - assert!(json_str.contains("absolute_peak_memory_kb")); + assert!(!json_str.contains("absolute_peak_memory_kb")); } } diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index 37fb8cd..505f8ad 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -56,7 +56,7 @@ pub struct TimingStats { pub std_dev_ms: Option, } -/// Resource usage metrics from SDK reports and provider sessions. +/// Resource usage metrics from SDK reports. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceUsage { #[serde(skip_serializing_if = "Option::is_none")] @@ -69,8 +69,6 @@ pub struct ResourceUsage { #[serde(skip_serializing_if = "Option::is_none")] pub process_peak_memory_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub absolute_peak_memory_kb: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub total_pss_kb: Option, #[serde(skip_serializing_if = "Option::is_none")] pub private_dirty_kb: Option, @@ -617,7 +615,6 @@ impl ResourceUsage { && self.peak_memory_kb.is_none() && self.peak_memory_growth_kb.is_none() && self.process_peak_memory_kb.is_none() - && self.absolute_peak_memory_kb.is_none() && self.total_pss_kb.is_none() && self.private_dirty_kb.is_none() && self.native_heap_kb.is_none() @@ -637,9 +634,6 @@ impl ResourceUsage { if self.process_peak_memory_kb.is_none() { self.process_peak_memory_kb = other.process_peak_memory_kb; } - if self.absolute_peak_memory_kb.is_none() { - self.absolute_peak_memory_kb = other.absolute_peak_memory_kb; - } if self.total_pss_kb.is_none() { self.total_pss_kb = other.total_pss_kb; } @@ -683,22 +677,12 @@ fn parse_resource_usage_object(value: &serde_json::Value) -> Option Option { }) } -fn mb_to_kb(value: f64) -> Option { - value - .is_finite() - .then_some(value) - .filter(|value| *value >= 0.0) - .map(|value| (value * 1024.0).round() as u64) -} - fn format_cpu_total_ms(value: Option) -> String { value .map(|value| { @@ -755,9 +731,7 @@ fn platform_has_memory_baseline_gap(platform: &PlatformReport) -> bool { } fn resource_usage_has_memory_baseline_gap(usage: &ResourceUsage) -> bool { - let peak = usage - .process_peak_memory_kb - .or(usage.absolute_peak_memory_kb); + let peak = usage.process_peak_memory_kb; match (usage.peak_memory_growth_kb, peak) { (Some(growth), Some(peak)) if peak > growth => { peak.saturating_sub(growth) >= MEMORY_BASELINE_GAP_MIN_DIFF_KB @@ -819,7 +793,7 @@ fn render_platform_table(platform: &PlatformReport) -> String { let mut headers = vec!["Benchmark", "Avg ms", "Best", "Worst", "Median", "P95"]; if has_resource_usage { - headers.extend(["CPU total", "Peak growth", "Process peak", "Provider peak"]); + headers.extend(["CPU total", "Peak growth", "Process peak"]); } table.set_header( headers @@ -842,12 +816,10 @@ fn render_platform_table(platform: &PlatformReport) -> String { row.push(Cell::new(format_cpu_total_ms(ru.cpu_total_ms))); row.push(Cell::new(format_peak_memory(ru.peak_memory_growth_kb))); row.push(Cell::new(format_peak_memory(ru.process_peak_memory_kb))); - row.push(Cell::new(format_peak_memory(ru.absolute_peak_memory_kb))); } else { row.push(Cell::new("—")); row.push(Cell::new("—")); row.push(Cell::new("—")); - row.push(Cell::new("—")); } } @@ -898,10 +870,10 @@ pub fn render_markdown(report: &SummarizeReport) -> String { if has_ru { output.push_str( - "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU total | Peak growth | Process peak | Provider peak |\n", + "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU total | Peak growth | Process peak |\n", ); output.push_str( - "|-----------|--------|------|-------|--------|-----|-----------|-------------|--------------|---------------|\n", + "|-----------|--------|------|-------|--------|-----|-----------|-------------|--------------|\n", ); } else { output.push_str("| Benchmark | Avg ms | Best | Worst | Median | P95 |\n"); @@ -922,14 +894,13 @@ pub fn render_markdown(report: &SummarizeReport) -> String { if has_ru { if let Some(ru) = &bench.resource_usage { row.push_str(&format!( - " {} | {} | {} | {} |", + " {} | {} | {} |", format_cpu_total_ms(ru.cpu_total_ms), format_peak_memory(ru.peak_memory_growth_kb), format_peak_memory(ru.process_peak_memory_kb), - format_peak_memory(ru.absolute_peak_memory_kb), )); } else { - row.push_str(" — | — | — | — |"); + row.push_str(" — | — | — |"); } } @@ -968,30 +939,9 @@ pub fn enrich_with_browserstack( } } - // Enrich benchmarks with performance metrics - if let Some(perf) = &session.performance { - for bench in &mut platform.benchmarks { - let absolute_peak_memory_kb = perf - .memory - .as_ref() - .and_then(|memory| mb_to_kb(memory.peak_mb)); - if absolute_peak_memory_kb.is_none() { - continue; - } - let resource_usage = bench.resource_usage.get_or_insert(ResourceUsage { - cpu_total_ms: None, - peak_memory_kb: None, - peak_memory_growth_kb: None, - process_peak_memory_kb: None, - absolute_peak_memory_kb: None, - total_pss_kb: None, - private_dirty_kb: None, - native_heap_kb: None, - java_heap_kb: None, - }); - resource_usage.absolute_peak_memory_kb = absolute_peak_memory_kb; - } - } + // BrowserStack provider memory is intentionally not merged here. It + // can describe harness/session memory rather than the isolated + // benchmark worker process. } } } @@ -1362,7 +1312,6 @@ mod tests { peak_memory_kb: Some(654321), peak_memory_growth_kb: Some(654321), process_peak_memory_kb: Some(1_477_787), - absolute_peak_memory_kb: None, total_pss_kb: Some(654321), private_dirty_kb: Some(321000), native_heap_kb: Some(120000), @@ -1452,7 +1401,7 @@ mod tests { assert!(!output.contains("CPU total (ms)")); assert!(output.contains("Peak growth")); assert!(output.contains("Process peak")); - assert!(output.contains("Provider peak")); + assert!(!output.contains("Provider peak")); assert!(!output.contains("Absolute peak")); assert!(!output.contains("Peak memory")); assert!(output.contains("| 1.482s |")); @@ -1498,7 +1447,7 @@ mod tests { assert!(!output.contains("CPU total (ms)")); assert!(output.contains("Peak growth")); assert!(output.contains("Process peak")); - assert!(output.contains("Provider peak")); + assert!(!output.contains("Provider peak")); assert!(!output.contains("Absolute peak")); assert!(!output.contains("Peak memory")); assert!(output.contains("1.482s")); @@ -1508,7 +1457,7 @@ mod tests { } #[test] - fn test_render_markdown_notes_large_absolute_memory_baseline_gap() { + fn test_render_markdown_notes_large_process_memory_baseline_gap() { let report = parse_summary_value(&json!({ "summary": { "generated_at": "2026-02-26T12:00:00Z", @@ -1531,7 +1480,6 @@ mod tests { "peak_memory_kb": 171556, "peak_memory_growth_kb": 171556, "process_peak_memory_kb": 1477787, - "absolute_peak_memory_kb": 1680026, "total_pss_kb": 1477787, "private_dirty_kb": 1462460 } @@ -1545,7 +1493,7 @@ mod tests { assert!(output.contains("Peak growth")); assert!(output.contains("Process peak")); - assert!(output.contains("Provider peak")); + assert!(!output.contains("Provider peak")); assert!(!output.contains("Absolute peak")); assert!(output.contains(MEMORY_BASELINE_GAP_NOTE)); assert!(!output.contains("Peak memory")); @@ -1681,16 +1629,7 @@ mod tests { enrich_with_browserstack(&mut report, &build_summary); - let pixel_memory = report.platforms[0].benchmarks[0] - .resource_usage - .as_ref() - .and_then(|usage| usage.absolute_peak_memory_kb); - let samsung_memory = report.platforms[1].benchmarks[0] - .resource_usage - .as_ref() - .and_then(|usage| usage.absolute_peak_memory_kb); - - assert_eq!(pixel_memory, Some(102_400)); - assert_eq!(samsung_memory, Some(204_800)); + assert!(report.platforms[0].benchmarks[0].resource_usage.is_none()); + assert!(report.platforms[1].benchmarks[0].resource_usage.is_none()); } } diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index bc03ec2..7588153 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -81,7 +81,7 @@ jobs: - `summary.json`, `summary.md`, and `results.csv` remain the stable required outputs. - `plots/*.svg` is additive and only appears when local plot rendering is enabled and a Python + Matplotlib runtime is available, or when `--plots require` is used successfully. -- `summary.md` now renders a single top-level CI table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, `Process peak`, and `Provider peak`. +- `summary.md` now renders a single top-level CI table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, and `Process peak`. - The reusable workflow attempts to compare against the latest successful default-branch run by downloading its per-platform `summary.json` artifacts before calling `ci check-run`. ## Compatibility Notes @@ -97,7 +97,7 @@ versioned schemas and documenting the compatibility impact in `RELEASE_NOTES.md` - `summary.md` now renders a flat per-device table instead of per-device sections - CPU values in that table are shown as `CPU median / iter`, `CPU total`, and `CPU / wall` - `peak_memory_kb` remains the legacy baseline-adjusted growth field -- `results.csv` now includes `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`, `peak_memory_growth_kb`, `process_peak_memory_kb`, and `absolute_peak_memory_kb` +- `results.csv` now includes `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`, `peak_memory_growth_kb`, and `process_peak_memory_kb` - missing resource values are left blank in CSV output Any change to required output files or metadata keys requires updating the diff --git a/docs/guides/browserstack-ci.md b/docs/guides/browserstack-ci.md index 9053a7a..d0c11a8 100644 --- a/docs/guides/browserstack-ci.md +++ b/docs/guides/browserstack-ci.md @@ -89,8 +89,8 @@ cargo mobench report summarize --summary target/mobench/ci/summary.json ``` Standard CI outputs now carry benchmark-scoped resource metrics directly: -- `summary.md` renders one top-level table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, `Process peak`, and `Provider peak` -- `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`, `peak_memory_growth_kb`, `process_peak_memory_kb`, and `absolute_peak_memory_kb` +- `summary.md` renders one top-level table with `Wall mean / iter`, `Wall total`, `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, and `Process peak` +- `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`, `peak_memory_growth_kb`, and `process_peak_memory_kb` - missing resource data is emitted as blank CSV fields rather than placeholder strings ## Quick Example diff --git a/docs/guides/browserstack-metrics.md b/docs/guides/browserstack-metrics.md index 7c5bf68..e5b0e03 100644 --- a/docs/guides/browserstack-metrics.md +++ b/docs/guides/browserstack-metrics.md @@ -38,21 +38,20 @@ We recursively download ALL URLs from session JSON, which typically includes: **Performance Metrics:** - Extracted from device logs (JSON output with `"type": "performance"` or `memory`/`cpu` fields) -- Enriched from BrowserStack App Profiling v2 when that API returns additional session data +- Available from BrowserStack App Profiling v2 when that API returns additional session data - Memory usage (used_mb, max_mb, available_mb, total_mb) - Aggregate statistics: peak, average, min - CPU usage (usage_percent) - Aggregate statistics: peak, average, min -- Normalized into run summaries and CI summaries when using `--fetch` -- Surfaced as provider/session resource fields such as `absolute_peak_memory_kb` +- Kept out of benchmark resource summaries because it can describe harness/session memory rather than the isolated benchmark worker process **Benchmark-scoped resource metrics (current default):** - Each measured iteration can emit `cpu_time_ms`, `peak_memory_kb`, and `process_peak_memory_kb` - Android runs execute the benchmark in an isolated `:mobench_worker` process and report that worker process peak through `process_peak_memory_kb` - `mobench` derives `cpu_median_ms` from measured iterations only -- `summary.md` renders `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, `Process peak`, and `Provider peak` in the top-level CI table -- `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`, `peak_memory_growth_kb`, `process_peak_memory_kb`, and `absolute_peak_memory_kb` -- BrowserStack aggregate memory is preserved separately as provider/session telemetry and does not override benchmark process memory +- `summary.md` renders `CPU median / iter`, `CPU total`, `CPU / wall`, `Peak growth`, and `Process peak` in the top-level CI table +- `results.csv` includes `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`, `peak_memory_growth_kb`, and `process_peak_memory_kb` +- BrowserStack aggregate memory does not override or supplement benchmark process memory ### ⚠️ What We do not currently capture @@ -76,7 +75,7 @@ JSON payloads. mobench therefore combines two sources when you use `--fetch`: 2. **Log to device logs** in JSON format (see example below) -3. **mobench automatically extracts** them alongside benchmark results and merges in BrowserStack App Profiling v2 data when available +3. **mobench automatically extracts** benchmark-scoped metrics alongside benchmark results. BrowserStack App Profiling v2 memory is not merged into benchmark resource metrics. ## BrowserStack limitations diff --git a/docs/schemas/summary-v1.schema.json b/docs/schemas/summary-v1.schema.json index d4b2f86..9f452ae 100644 --- a/docs/schemas/summary-v1.schema.json +++ b/docs/schemas/summary-v1.schema.json @@ -56,7 +56,6 @@ "peak_memory_kb": { "type": ["integer", "null"] }, "peak_memory_growth_kb": { "type": ["integer", "null"] }, "process_peak_memory_kb": { "type": ["integer", "null"] }, - "absolute_peak_memory_kb": { "type": ["integer", "null"] }, "total_pss_kb": { "type": ["integer", "null"] }, "private_dirty_kb": { "type": ["integer", "null"] }, "native_heap_kb": { "type": ["integer", "null"] }, From 3847f6c75030cc6dcc1c070520a6ea013746a75c Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 23 Apr 2026 21:39:32 +0200 Subject: [PATCH 175/196] Address PR memory reporting feedback --- crates/mobench-sdk/src/codegen.rs | 2 + .../android/app/src/main/AndroidManifest.xml | 2 + crates/mobench/src/lib.rs | 52 +++++++++++++++++-- crates/mobench/src/summarize.rs | 51 ++++++++++++++++-- 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 4a32263..ee465c6 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -1484,7 +1484,9 @@ mod tests { let android_manifest = include_str!("../templates/android/app/src/main/AndroidManifest.xml"); assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE")); + assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC")); assert!(android_manifest.contains("android:name=\".BenchmarkWorkerService\"")); + assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\"")); assert!(android_manifest.contains("android:process=\":mobench_worker\"")); let ios = diff --git a/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml b/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml index 8a2bb49..861ae8a 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml +++ b/crates/mobench-sdk/templates/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 0b7aff3..f8d1592 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -5272,6 +5272,10 @@ fn summarize_local_report(run_summary: &RunSummary) -> Option { } impl BenchmarkResourceUsage { + fn peak_memory_growth_or_legacy_kb(&self) -> Option { + self.peak_memory_growth_kb.or(self.peak_memory_kb) + } + fn is_empty(&self) -> bool { self.cpu_total_ms.is_none() && self.cpu_median_ms.is_none() @@ -5521,7 +5525,7 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { bench .resource_usage .as_ref() - .and_then(|usage| usage.peak_memory_growth_kb) + .and_then(BenchmarkResourceUsage::peak_memory_growth_or_legacy_kb) ), format_peak_memory( bench @@ -5578,7 +5582,7 @@ fn render_csv_summary(summary: &SummaryReport) -> String { bench .resource_usage .as_ref() - .and_then(|usage| usage.peak_memory_growth_kb) + .and_then(BenchmarkResourceUsage::peak_memory_growth_or_legacy_kb) .map_or(String::new(), |v| v.to_string()), bench .resource_usage @@ -5685,7 +5689,7 @@ fn summary_has_memory_baseline_gap(summary: &SummaryReport) -> bool { fn resource_usage_has_memory_baseline_gap(usage: &BenchmarkResourceUsage) -> bool { let peak = usage.process_peak_memory_kb; - match (usage.peak_memory_growth_kb, peak) { + match (usage.peak_memory_growth_or_legacy_kb(), peak) { (Some(growth), Some(peak)) if peak > growth => { peak.saturating_sub(growth) >= MEMORY_BASELINE_GAP_MIN_DIFF_KB && peak >= growth.saturating_mul(MEMORY_BASELINE_GAP_RATIO) @@ -9628,6 +9632,48 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert!(csv.contains(",482,241,249416,249416,1477787\n")); } + #[test] + fn render_summary_uses_legacy_peak_memory_as_growth_fallback() { + let summary = SummaryReport { + generated_at: "2026-04-12T00:00:00Z".to_string(), + generated_at_unix: 1_744_416_000, + target: MobileTarget::Android, + function: "sample_fns::fibonacci".to_string(), + iterations: 5, + warmup: 1, + devices: vec!["Google Pixel 8-14.0".to_string()], + device_summaries: vec![DeviceSummary { + device: "Google Pixel 8-14.0".to_string(), + benchmarks: vec![BenchmarkStats { + function: "sample_fns::fibonacci".to_string(), + samples: 5, + mean_ns: Some(1_250_000_000), + median_ns: Some(1_200_000_000), + p95_ns: Some(1_300_000_000), + min_ns: Some(1_100_000_000), + max_ns: Some(1_350_000_000), + resource_usage: Some(BenchmarkResourceUsage { + cpu_total_ms: Some(482), + cpu_median_ms: Some(241), + peak_memory_kb: Some(249_416), + peak_memory_growth_kb: None, + process_peak_memory_kb: Some(1_477_787), + total_pss_kb: None, + private_dirty_kb: None, + native_heap_kb: None, + java_heap_kb: None, + }), + }], + }], + }; + + let markdown = render_markdown_summary(&summary); + let csv = render_csv_summary(&summary); + + assert!(markdown.contains("243.57 MB")); + assert!(csv.contains(",482,241,249416,249416,1477787\n")); + } + #[test] fn test_render_markdown_uses_cpu_total_and_peak_memory_columns() { let markdown = render_markdown_summary(&SummaryReport { diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index 505f8ad..c83cec1 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -610,6 +610,10 @@ fn default_device_info(platform: &str) -> DeviceInfo { } impl ResourceUsage { + fn peak_memory_growth_or_legacy_kb(&self) -> Option { + self.peak_memory_growth_kb.or(self.peak_memory_kb) + } + fn is_empty(&self) -> bool { self.cpu_total_ms.is_none() && self.peak_memory_kb.is_none() @@ -732,7 +736,7 @@ fn platform_has_memory_baseline_gap(platform: &PlatformReport) -> bool { fn resource_usage_has_memory_baseline_gap(usage: &ResourceUsage) -> bool { let peak = usage.process_peak_memory_kb; - match (usage.peak_memory_growth_kb, peak) { + match (usage.peak_memory_growth_or_legacy_kb(), peak) { (Some(growth), Some(peak)) if peak > growth => { peak.saturating_sub(growth) >= MEMORY_BASELINE_GAP_MIN_DIFF_KB && peak >= growth.saturating_mul(MEMORY_BASELINE_GAP_RATIO) @@ -814,7 +818,9 @@ fn render_platform_table(platform: &PlatformReport) -> String { if has_resource_usage { if let Some(ru) = &bench.resource_usage { row.push(Cell::new(format_cpu_total_ms(ru.cpu_total_ms))); - row.push(Cell::new(format_peak_memory(ru.peak_memory_growth_kb))); + row.push(Cell::new(format_peak_memory( + ru.peak_memory_growth_or_legacy_kb(), + ))); row.push(Cell::new(format_peak_memory(ru.process_peak_memory_kb))); } else { row.push(Cell::new("—")); @@ -896,7 +902,7 @@ pub fn render_markdown(report: &SummarizeReport) -> String { row.push_str(&format!( " {} | {} | {} |", format_cpu_total_ms(ru.cpu_total_ms), - format_peak_memory(ru.peak_memory_growth_kb), + format_peak_memory(ru.peak_memory_growth_or_legacy_kb()), format_peak_memory(ru.process_peak_memory_kb), )); } else { @@ -1410,6 +1416,45 @@ mod tests { assert!(!output.contains("RAM MB")); } + #[test] + fn test_render_markdown_uses_legacy_peak_memory_as_growth_fallback() { + let report: SummarizeReport = serde_json::from_value(json!({ + "platforms": [{ + "platform": "android", + "device": { + "name": "Google Pixel 8", + "os": "Android", + "os_version": "14" + }, + "benchmarks": [{ + "name": "bench_nullifier_proving_only", + "label": "bench nullifier proving only", + "timing": { + "avg_ms": 1204.5, + "median_ms": 1198.0, + "best_ms": 1180.2, + "worst_ms": 1298.1, + "p95_ms": 1290.0 + }, + "resource_usage": { + "cpu_total_ms": 1482, + "peak_memory_kb": 654321, + "process_peak_memory_kb": 1477787 + } + }], + "iterations": 30, + "warmup": 5 + }] + })) + .unwrap(); + + let output = render_markdown(&report); + + assert!(output.contains("Peak growth")); + assert!(output.contains("638.99 MB")); + assert!(output.contains("1443.15 MB")); + } + #[test] fn test_render_table_uses_cpu_total_and_peak_memory_columns() { let report = parse_summary_value(&json!({ From b8771970dcc307f180b414fb7fdbe15832518bbe Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 23 Apr 2026 23:49:23 +0200 Subject: [PATCH 176/196] chore: Release --- .github/workflows/reusable-bench.yml | 2 +- Cargo.lock | 8 +++---- Cargo.toml | 2 +- RELEASE_NOTES.md | 31 +++++++++++++++++++++++----- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- docs/guides/sdk-integration.md | 4 ++-- 7 files changed, 36 insertions(+), 15 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 68431d6..13c2adf 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -66,7 +66,7 @@ on: description: "Mobench version to install" required: false type: string - default: "0.1.33" + default: "0.1.34" mobench_ref: description: "Git ref for mobile-bench-rs (overrides mobench_version when set)" required: false diff --git a/Cargo.lock b/Cargo.lock index 406c90c..344b210 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.33" +version = "0.1.34" dependencies = [ "anyhow", "clap", @@ -1071,7 +1071,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.33" +version = "0.1.34" dependencies = [ "proc-macro2", "quote", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.33" +version = "0.1.34" dependencies = [ "anyhow", "include_dir", @@ -1583,7 +1583,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.33" +version = "0.1.34" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 9abbc99..114c370 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.33" +version = "0.1.34" [workspace.dependencies] anyhow = "1" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 305a553..b5b8895 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -13,9 +13,9 @@ Crates.io release history: ## Support Policy -- `v0.1.33` is the current supported release. -- `v0.1.32` is the immediately previous supported release, superseded by - `v0.1.33`. +- `v0.1.34` is the current supported release. +- `v0.1.33` is the immediately previous supported release, superseded by + `v0.1.34`. - Every earlier published version is a historical test build and should not be used. - Yanked versions are explicitly called out below. @@ -24,7 +24,8 @@ Crates.io release history: | Version | Published | Published crates | Status | |---------|-----------|------------------|--------| -| `v0.1.33` | 2026-04-17 | `mobench 0.1.33`, `mobench-sdk 0.1.33`, `mobench-macros 0.1.33` | Current supported release | +| `v0.1.34` | 2026-04-23 | `mobench 0.1.34`, `mobench-sdk 0.1.34`, `mobench-macros 0.1.34` | Current supported release | +| `v0.1.33` | 2026-04-17 | `mobench 0.1.33`, `mobench-sdk 0.1.33`, `mobench-macros 0.1.33` | Superseded by `v0.1.34` | | `v0.1.32` | 2026-04-14 | `mobench 0.1.32`, `mobench-sdk 0.1.32`, `mobench-macros 0.1.32` | Superseded by `v0.1.33` | | `v0.1.31` | 2026-04-12 | `mobench 0.1.31`, `mobench-sdk 0.1.31`, `mobench-macros 0.1.31` | Superseded by `v0.1.32` | | `v0.1.30` | 2026-04-12 | `mobench 0.1.30`, `mobench-sdk 0.1.30`, `mobench-macros 0.1.30` | Superseded by `v0.1.31` | @@ -60,10 +61,30 @@ Crates.io release history: | `v0.1.1` | 2026-01-13 | `mobench 0.1.1`, `mobench-sdk 0.1.1` | Yanked test build. Do not use. | | `v0.1.0` | 2026-01-13 | `mobench 0.1.0`, `mobench-sdk 0.1.0`, `mobench-macros 0.1.0` | Yanked test build. Do not use. | -## v0.1.33 +## v0.1.34 Status: current supported release. +- Reported Android memory with explicit measured-iteration peak growth and an + isolated benchmark worker process peak, while keeping legacy + `peak_memory_kb` as the growth alias for existing consumers. +- Removed BrowserStack provider peak memory from mobench summaries so harness + and device-level memory is not conflated with the benchmarked process. +- Added Android worker-process execution for benchmark functions so the + process peak excludes the activity harness, UniFFI wrapper process, and + warmup allocations held before measured execution. +- Preserved fallback handling for legacy `peak_memory_kb` inputs and updated + JSON, CSV, markdown, and table summaries to label memory as `Peak growth` + and `Process peak`. +- Added Android foreground service type metadata required by newer platform + rules for the benchmark worker service. +- Validated the release candidate with successful ProveKit Mobile Bench + workflow run `24858522379`. + +## v0.1.33 + +Status: superseded by `v0.1.34`. + - Measured benchmark CPU time as process CPU time under the standard user-plus-kernel definition across all threads, then exported both median per-iteration CPU and total CPU in the summary output. diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index a1f31f9..d9ad7bb 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.33", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.34", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index db407c9..d77ecee 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -37,7 +37,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.33", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.34", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/docs/guides/sdk-integration.md b/docs/guides/sdk-integration.md index cf0f566..eacc17d 100644 --- a/docs/guides/sdk-integration.md +++ b/docs/guides/sdk-integration.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.33" +mobench-sdk = "0.1.34" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.33" +mobench-sdk = "0.1.34" ``` ## 3) Annotate benchmark functions From c95293abfdb7bb069695bddf7a08daaae698ea8b Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 24 Apr 2026 12:48:00 +0200 Subject: [PATCH 177/196] Report iOS benchmark process peak memory --- crates/mobench-sdk/src/codegen.rs | 6 ++ .../BenchRunner/BenchRunnerFFI.swift.template | 83 ++++++++++++++++++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index ee465c6..a0cdd7c 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -1501,6 +1501,12 @@ mod tests { ); assert!(ios.contains("optionalProcessPeakMemoryKb(sample)")); assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }")); + assert!(ios.contains("ProcessMemorySampler")); + assert!(ios.contains("currentProcessResidentMemoryKb")); + assert!(ios.contains("task_info(")); + assert!(ios.contains("\"memory_process\": \"benchmark_app\"")); + assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:")); + assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb")); } #[test] diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index 6622314..a21a35b 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -1,4 +1,5 @@ import Foundation +import Darwin private let defaultFunction = "{{DEFAULT_FUNCTION}}" private let defaultIterations: UInt32 = 20 @@ -129,9 +130,18 @@ enum {{PROJECT_NAME_PASCAL}}FFI { ) do { - let report = try runBenchmark(spec: spec) + let processMemorySampler = ProcessMemorySampler() + processMemorySampler.start() + let report: BenchReport + do { + report = try runBenchmark(spec: spec) + } catch { + _ = processMemorySampler.stop() + throw error + } + let runProcessPeakMemoryKb = processMemorySampler.stop() let displayText = formatBenchReport(report) - let jsonReport = generateJSONReport(report) + let jsonReport = generateJSONReport(report, runProcessPeakMemoryKb: runProcessPeakMemoryKb) return BenchmarkResult(displayText: displayText, jsonReport: jsonReport) } catch let error as BenchError { print("[BenchRunner] ERROR: Benchmark failed: \(error)") @@ -148,7 +158,7 @@ enum {{PROJECT_NAME_PASCAL}}FFI { /// Generates a JSON report that matches the Android BENCH_JSON-style payload /// consumed by mobench so cross-platform parsers can stay aligned. - private static func generateJSONReport(_ report: BenchReport) -> String { + private static func generateJSONReport(_ report: BenchReport, runProcessPeakMemoryKb: UInt64?) -> String { var json: [String: Any] = [:] // Spec section @@ -238,6 +248,7 @@ enum {{PROJECT_NAME_PASCAL}}FFI { // Resource metrics derived from the measured loop itself. var resources: [String: Any] = [ "platform": "ios", + "memory_process": "benchmark_app", "timestamp_ms": Int64(Date().timeIntervalSince1970 * 1000) ] if !cpuSamplesMs.isEmpty { @@ -250,7 +261,7 @@ enum {{PROJECT_NAME_PASCAL}}FFI { resources["peak_memory_kb"] = peakMemoryKb resources["peak_memory_growth_kb"] = peakMemoryKb } - if let processPeakMemoryKb = processPeakSamplesKb.max() { + if let processPeakMemoryKb = processPeakSamplesKb.max() ?? runProcessPeakMemoryKb { resources["process_peak_memory_kb"] = processPeakMemoryKb } json["resources"] = resources @@ -362,3 +373,67 @@ enum {{PROJECT_NAME_PASCAL}}FFI { return "Benchmark error: \(error.localizedDescription)" } } + +private final class ProcessMemorySampler { + private let sampleInterval: TimeInterval + private let queue = DispatchQueue(label: "mobench-process-memory-sampler") + private var timer: DispatchSourceTimer? + private var peakKb: UInt64 = 0 + + init(sampleInterval: TimeInterval = 0.01) { + self.sampleInterval = sampleInterval + } + + func start() { + queue.sync { + guard timer == nil else { + return + } + + recordCurrentLocked() + let source = DispatchSource.makeTimerSource(queue: queue) + source.schedule(deadline: .now(), repeating: sampleInterval) + source.setEventHandler { [weak self] in + self?.recordCurrentLocked() + } + source.resume() + timer = source + } + } + + func stop() -> UInt64? { + queue.sync { + timer?.cancel() + timer = nil + recordCurrentLocked() + return peakKb > 0 ? peakKb : nil + } + } + + private func recordCurrentLocked() { + if let currentKb = currentProcessResidentMemoryKb(), currentKb > peakKb { + peakKb = currentKb + } + } +} + +private func currentProcessResidentMemoryKb() -> UInt64? { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + let result = withUnsafeMutablePointer(to: &info) { infoPointer in + infoPointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { reboundPointer in + task_info( + mach_task_self_, + task_flavor_t(MACH_TASK_BASIC_INFO), + reboundPointer, + &count + ) + } + } + + guard result == KERN_SUCCESS else { + return nil + } + + return UInt64(info.resident_size) / 1024 +} From b39b88c1e0fcfa7df4692505096e7101bdcccf5f Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Fri, 24 Apr 2026 13:42:08 +0200 Subject: [PATCH 178/196] chore: Release --- .github/workflows/reusable-bench.yml | 2 +- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- RELEASE_NOTES.md | 24 +++++++++++++++++++----- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- docs/guides/sdk-integration.md | 4 ++-- 7 files changed, 29 insertions(+), 15 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 13c2adf..53e2ce8 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -66,7 +66,7 @@ on: description: "Mobench version to install" required: false type: string - default: "0.1.34" + default: "0.1.35" mobench_ref: description: "Git ref for mobile-bench-rs (overrides mobench_version when set)" required: false diff --git a/Cargo.lock b/Cargo.lock index 344b210..6ef63dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.34" +version = "0.1.35" dependencies = [ "anyhow", "clap", @@ -1071,7 +1071,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.34" +version = "0.1.35" dependencies = [ "proc-macro2", "quote", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.34" +version = "0.1.35" dependencies = [ "anyhow", "include_dir", @@ -1583,7 +1583,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.34" +version = "0.1.35" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 114c370..ae68399 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.34" +version = "0.1.35" [workspace.dependencies] anyhow = "1" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b5b8895..378837f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -13,9 +13,9 @@ Crates.io release history: ## Support Policy -- `v0.1.34` is the current supported release. -- `v0.1.33` is the immediately previous supported release, superseded by - `v0.1.34`. +- `v0.1.35` is the current supported release. +- `v0.1.34` is the immediately previous supported release, superseded by + `v0.1.35`. - Every earlier published version is a historical test build and should not be used. - Yanked versions are explicitly called out below. @@ -24,7 +24,8 @@ Crates.io release history: | Version | Published | Published crates | Status | |---------|-----------|------------------|--------| -| `v0.1.34` | 2026-04-23 | `mobench 0.1.34`, `mobench-sdk 0.1.34`, `mobench-macros 0.1.34` | Current supported release | +| `v0.1.35` | 2026-04-24 | `mobench 0.1.35`, `mobench-sdk 0.1.35`, `mobench-macros 0.1.35` | Current supported release | +| `v0.1.34` | 2026-04-23 | `mobench 0.1.34`, `mobench-sdk 0.1.34`, `mobench-macros 0.1.34` | Superseded by `v0.1.35` | | `v0.1.33` | 2026-04-17 | `mobench 0.1.33`, `mobench-sdk 0.1.33`, `mobench-macros 0.1.33` | Superseded by `v0.1.34` | | `v0.1.32` | 2026-04-14 | `mobench 0.1.32`, `mobench-sdk 0.1.32`, `mobench-macros 0.1.32` | Superseded by `v0.1.33` | | `v0.1.31` | 2026-04-12 | `mobench 0.1.31`, `mobench-sdk 0.1.31`, `mobench-macros 0.1.31` | Superseded by `v0.1.32` | @@ -61,10 +62,23 @@ Crates.io release history: | `v0.1.1` | 2026-01-13 | `mobench 0.1.1`, `mobench-sdk 0.1.1` | Yanked test build. Do not use. | | `v0.1.0` | 2026-01-13 | `mobench 0.1.0`, `mobench-sdk 0.1.0`, `mobench-macros 0.1.0` | Yanked test build. Do not use. | -## v0.1.34 +## v0.1.35 Status: current supported release. +- Added iOS benchmark app process peak memory reporting using Mach + `task_info`, so iOS summaries now expose `process_peak_memory_kb` alongside + measured-iteration memory growth. +- Marked iOS process peak resources with `memory_process = "benchmark_app"` to + match the Android summary contract while reflecting that iOS runs still + execute inside the generated benchmark app process. +- Validated the release candidate with successful ProveKit iOS Mobile Bench + workflow run `24886057115`. + +## v0.1.34 + +Status: superseded by `v0.1.35`. + - Reported Android memory with explicit measured-iteration peak growth and an isolated benchmark worker process peak, while keeping legacy `peak_memory_kb` as the growth alias for existing consumers. diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index d9ad7bb..0127cb5 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.34", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.35", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index d77ecee..1c4915c 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -37,7 +37,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.34", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.35", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/docs/guides/sdk-integration.md b/docs/guides/sdk-integration.md index eacc17d..0079384 100644 --- a/docs/guides/sdk-integration.md +++ b/docs/guides/sdk-integration.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.34" +mobench-sdk = "0.1.35" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.34" +mobench-sdk = "0.1.35" ``` ## 3) Annotate benchmark functions From 043d5487bed7f906c7c6706fa74df5c1868211fb Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Sat, 25 Apr 2026 19:09:48 +0200 Subject: [PATCH 179/196] docs: add production readiness roadmap --- docs/specs/production-readiness-roadmap.md | 123 +++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/specs/production-readiness-roadmap.md diff --git a/docs/specs/production-readiness-roadmap.md b/docs/specs/production-readiness-roadmap.md new file mode 100644 index 0000000..f46639d --- /dev/null +++ b/docs/specs/production-readiness-roadmap.md @@ -0,0 +1,123 @@ +# Production Readiness Roadmap + +Updated: 2026-04-25 + +## Purpose + +mobench v0.1.35 is feature-ready for production use. This roadmap tracks the +quality, maintainability, documentation, and launch-readiness work needed before +broader public promotion, including tweets, landing pages, and wider crates.io +adoption. + +## Launch Gates + +### Gate 1: Trust The Crate + +Goal: make `mobench`, `mobench-sdk`, and `mobench-macros` feel like dependable +production Rust crates. + +Audience: library adopters, CLI users, maintainers. + +Checklist: +- [ ] Audit public APIs exported from `mobench-sdk`. +- [ ] Document semver and stability boundaries. +- [ ] Review feature flags, especially `full` and `runner-only`. +- [ ] Replace reusable-library `anyhow` surfaces with typed errors where appropriate. +- [ ] Improve docs.rs module docs and examples. +- [ ] Add compile-tested doc examples for core SDK usage. +- [ ] Add or refine minimal library adopter examples. +- [ ] Audit crate metadata, badges, readmes, categories, and keywords. +- [ ] Document MSRV policy. +- [ ] Enforce rustfmt, clippy, and rustdoc warnings in CI. +- [ ] Run `cargo publish --dry-run` for all published crates before release. + +Verification signals: +- `cargo test --workspace` passes. +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` passes. +- `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps` passes. +- docs.rs pages render cleanly for all published crates. +- Examples build from a clean checkout. + +### Gate 2: Trust The Outputs + +Goal: make benchmark, CI, and profiling outputs dependable enough for users to +automate around. + +Audience: CLI users, CI adopters, maintainers. + +Checklist: +- [ ] Add schema validation tests for `summary.json`. +- [ ] Add schema validation tests for the CI contract. +- [ ] Add golden fixture tests for Markdown summaries. +- [ ] Add golden fixture tests for CSV outputs. +- [ ] Add golden fixture tests for plots where practical. +- [ ] Add golden fixture tests for profile summaries. +- [ ] Add CLI snapshot tests for high-value command output. +- [ ] Add BrowserStack response normalization regression tests. +- [ ] Test resource metric contracts: `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`. +- [ ] Test baseline and regression comparison behavior. +- [ ] Test profile manifest sections: `native_capture`, `semantic_profile`, `capture_metadata`. +- [ ] Test Android and iOS template generation invariants. +- [ ] Test setup, teardown, and per-iteration macro behavior. +- [ ] Keep fixture verification wired into CI. +- [ ] Label tests that require Android, iOS, BrowserStack, or profiling tools separately from pure host tests. + +Verification signals: +- Host-only tests run without mobile toolchains. +- Mobile/tooling tests are opt-in or clearly gated. +- Existing example fixtures validate against schemas. +- Summary, CSV, plot, and profile contracts are covered by regression tests. + +### Gate 3: Trust The Experience + +Goal: make mobench easy to adopt, debug, explain, and promote publicly. + +Audience: CLI users, library adopters, public launch readers. + +Checklist: +- [ ] Add structured tracing/logging to CLI flows. +- [ ] Add progress spans for build, package, upload, poll, fetch, summarize, and profile steps. +- [ ] Improve human-readable diagnostics with likely fixes. +- [ ] Expand `doctor` coverage for Android, iOS, BrowserStack, and profile prerequisites. +- [ ] Add examples for minimal benchmark usage. +- [ ] Add examples for setup/teardown benchmarks. +- [ ] Add examples for FFI/custom type benchmarks. +- [ ] Add examples for CI-only benchmark workflows. +- [ ] Add examples for profiling workflows. +- [ ] Add examples for programmatic SDK usage. +- [ ] Add README graphics for crate architecture. +- [ ] Add README graphics for benchmark execution lifecycle. +- [ ] Add README graphics for BrowserStack CI lifecycle. +- [ ] Add README graphics for local profiling artifact lifecycle. +- [ ] Add README graphics for SDK versus CLI responsibility boundaries. +- [ ] Write concise launch copy explaining why mobench exists. +- [ ] Keep release notes and migration notes current. + +Verification signals: +- A new user can follow the README from install to first result. +- `doctor` catches common setup issues before long-running commands fail. +- Public diagrams explain the codebase and mobench workflows without reading source. +- Launch assets are reusable for README, tweets, and landing page. + +## Later Hardening + +These items are valuable but should not block the first production-ready +announcement unless they expose real launch risk. + +- [ ] Profile CLI hot paths. +- [ ] Improve APK/IPA build caching. +- [ ] Parallelize independent build, fetch, and report steps. +- [ ] Add host benchmarks for parser, reporting, and profile code. +- [ ] Add fuzz or property tests for config and device matrix parsing. +- [ ] Add public API compatibility checks with `cargo-semver-checks`. +- [ ] Add dependency and license policy checks with `cargo-deny`. +- [ ] Consider narrower crate features to reduce dependency footprint. +- [ ] Add machine-readable trace/event output for CI debugging. +- [ ] Prepare landing-page-specific assets from README diagrams. + +## Recommended Order + +1. Gate 1 crate hygiene, because this reduces adoption risk. +2. Gate 2 output contracts, because CI users need stable automation surfaces. +3. Gate 3 experience and launch assets, because promotion should point at a stable product. +4. Later hardening, prioritized by issues found during adoption. From 597c9e115af410702932a3077fa3da79518b26c8 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Sat, 25 Apr 2026 19:10:04 +0200 Subject: [PATCH 180/196] docs: link production readiness roadmap --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 08789b1..fcc0c4e 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ CLI flags override config file values when provided. - `docs/guides/fetch-results.md`: fetching and summarizing results - `docs/codebase/README.md`: current codebase reference map - `docs/MIGRATION_GUIDE.md`: migration notes for CI and reporting changes +- `docs/specs/production-readiness-roadmap.md`: production-readiness launch gates for crate quality, output contracts, diagnostics, examples, docs, and public launch assets - `docs/specs/dx-improvement-spec.md`: historical DX design spec, kept for context only - `docs/schemas/`: machine-readable CI/summary schema artifacts - `RELEASE_NOTES.md`: published release history and support status From fea2f9674813f0a3aa0cd9da004cf785a0ee077c Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Sun, 26 Apr 2026 10:22:22 +0200 Subject: [PATCH 181/196] chore: complete gate 1 crate readiness --- .github/workflows/rust.yml | 45 ++++++ Cargo.lock | 1 - Cargo.toml | 1 + README.md | 5 +- crates/mobench-macros/Cargo.toml | 2 + crates/mobench-sdk/Cargo.toml | 4 +- crates/mobench-sdk/src/builders/android.rs | 85 +++++----- crates/mobench-sdk/src/builders/ios.rs | 11 +- crates/mobench-sdk/src/codegen.rs | 31 ++-- crates/mobench-sdk/src/lib.rs | 54 ++++--- crates/mobench-sdk/src/registry.rs | 34 ++-- crates/mobench-sdk/src/runner.rs | 30 ++-- crates/mobench-sdk/src/timing.rs | 21 +-- crates/mobench/Cargo.toml | 1 + crates/mobench/src/browserstack.rs | 43 ++--- crates/mobench/src/flamegraph_viewer.rs | 11 +- crates/mobench/src/github.rs | 1 + crates/mobench/src/lib.rs | 177 +++++++++++---------- crates/mobench/src/plots.rs | 11 +- crates/mobench/src/profile.rs | 114 ++++++------- crates/sample-fns/Cargo.toml | 1 + docs/codebase/PUBLIC_API.md | 143 +++++++++++++++++ docs/codebase/README.md | 1 + docs/specs/production-readiness-roadmap.md | 22 +-- examples/basic-benchmark/Cargo.toml | 1 + examples/basic-benchmark/README.md | 28 ++++ examples/basic-benchmark/src/lib.rs | 2 +- examples/ffi-benchmark/Cargo.toml | 41 ++--- examples/ffi-benchmark/README.md | 28 ++++ examples/ffi-benchmark/src/lib.rs | 2 +- 30 files changed, 602 insertions(+), 349 deletions(-) create mode 100644 .github/workflows/rust.yml create mode 100644 docs/codebase/PUBLIC_API.md create mode 100644 examples/basic-benchmark/README.md create mode 100644 examples/ffi-benchmark/README.md diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..f6987ad --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,45 @@ +name: Rust + +on: + pull_request: + push: + branches: + - main + - dev + +env: + CARGO_TERM_COLOR: always + +jobs: + quality: + name: Quality + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Format + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: Rustdoc + run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps + + - name: Test + run: cargo test --workspace + + - name: Publish dry run + run: | + cargo publish --dry-run -p mobench-macros + cargo publish --dry-run -p mobench-sdk + cargo publish --dry-run -p mobench diff --git a/Cargo.lock b/Cargo.lock index 6ef63dc..b063634 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1082,7 +1082,6 @@ dependencies = [ name = "mobench-sdk" version = "0.1.35" dependencies = [ - "anyhow", "include_dir", "inventory", "libc", diff --git a/Cargo.toml b/Cargo.toml index ae68399..a6279bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" +rust-version = "1.85" version = "0.1.35" [workspace.dependencies] diff --git a/README.md b/README.md index fcc0c4e..ce4d237 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ For programmatic CI integrations, `mobench` exposes typed request/result types ( - `crates/mobench-sdk` ([mobench-sdk](https://crates.io/crates/mobench-sdk)): core SDK with timing harness, builders, registry, and codegen - `crates/mobench-macros` ([mobench-macros](https://crates.io/crates/mobench-macros)): `#[benchmark]` proc macro - `crates/sample-fns`: sample benchmarks and UniFFI bindings -- `examples/basic-benchmark`: minimal SDK integration example -- `examples/ffi-benchmark`: full UniFFI/FFI surface example +- `examples/basic-benchmark`: minimal SDK integration example with a local README +- `examples/ffi-benchmark`: full UniFFI/FFI surface example with a local README ## Quick start @@ -220,6 +220,7 @@ CLI flags override config file values when provided. - `docs/guides/browserstack-metrics.md`: BrowserStack metric normalization and limits - `docs/guides/fetch-results.md`: fetching and summarizing results - `docs/codebase/README.md`: current codebase reference map +- `docs/codebase/PUBLIC_API.md`: public API, semver, feature flag, MSRV, and release-readiness boundaries - `docs/MIGRATION_GUIDE.md`: migration notes for CI and reporting changes - `docs/specs/production-readiness-roadmap.md`: production-readiness launch gates for crate quality, output contracts, diagnostics, examples, docs, and public launch assets - `docs/specs/dx-improvement-spec.md`: historical DX design spec, kept for context only diff --git a/crates/mobench-macros/Cargo.toml b/crates/mobench-macros/Cargo.toml index 94b073a..f9e61da 100644 --- a/crates/mobench-macros/Cargo.toml +++ b/crates/mobench-macros/Cargo.toml @@ -3,12 +3,14 @@ name = "mobench-macros" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true authors = ["Dominik Clemente - dcbuilder.eth "] description = "Procedural macros for mobench benchmarks with setup, teardown, and per-iteration support" repository = "https://github.com/worldcoin/mobile-bench-rs" documentation = "https://docs.rs/mobench-macros" readme = "README.md" keywords = ["benchmark", "mobile", "macro", "procedural"] +categories = ["development-tools::procedural-macro-helpers"] [badges] maintenance = { status = "actively-developed" } diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 0127cb5..240c2b2 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -3,6 +3,7 @@ name = "mobench-sdk" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true authors = ["Dominik Clemente - dcbuilder.eth "] description = "Rust SDK for mobile benchmarking with timing harness and Android/iOS builders" repository = "https://github.com/worldcoin/mobile-bench-rs" @@ -26,7 +27,7 @@ crate-type = ["lib"] [features] default = ["full"] # Full SDK with build automation, templates, and registry -full = ["dep:mobench-macros", "dep:inventory", "dep:include_dir", "dep:toml", "dep:anyhow"] +full = ["dep:mobench-macros", "dep:inventory", "dep:include_dir", "dep:toml"] # Minimal timing-only mode for mobile binaries (small footprint) runner-only = [] @@ -44,7 +45,6 @@ libc = "0.2" # Error handling thiserror.workspace = true -anyhow = { workspace = true, optional = true } # Template embedding (only with full feature) include_dir = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index 57263a0..bf8ae8b 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -324,10 +324,10 @@ impl AndroidBuilder { } // Check test APK - if let Some(ref test_path) = result.test_suite_path { - if !test_path.exists() { - missing.push(format!("Test APK: {}", test_path.display())); - } + if let Some(ref test_path) = result.test_suite_path + && !test_path.exists() + { + missing.push(format!("Test APK: {}", test_path.display())); } // Check that at least one native library exists in jniLibs @@ -429,12 +429,11 @@ impl AndroidBuilder { // Check if the current directory (project_root) IS the crate // This handles the case where user runs `cargo mobench build` from within the crate directory let root_cargo_toml = self.project_root.join("Cargo.toml"); - if root_cargo_toml.exists() { - if let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) { - if pkg_name == self.crate_name { - return Ok(self.project_root.clone()); - } - } + if root_cargo_toml.exists() + && let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) + && pkg_name == self.crate_name + { + return Ok(self.project_root.clone()); } // Try bench-mobile/ (SDK projects) @@ -1064,21 +1063,21 @@ impl AndroidBuilder { ) -> Result { // First, try to read output-metadata.json for the actual APK name let metadata_path = apk_dir.join("output-metadata.json"); - if metadata_path.exists() { - if let Ok(metadata_content) = fs::read_to_string(&metadata_path) { - // Parse the JSON to find the outputFile - // Format: {"elements":[{"outputFile":"app-release-unsigned.apk",...}]} - if let Some(apk_name) = self.parse_output_metadata(&metadata_content) { - let apk_path = apk_dir.join(&apk_name); - if apk_path.exists() { - if self.verbose { - println!( - " Found APK from output-metadata.json: {}", - apk_path.display() - ); - } - return Ok(apk_path); + if metadata_path.exists() + && let Ok(metadata_content) = fs::read_to_string(&metadata_path) + { + // Parse the JSON to find the outputFile + // Format: {"elements":[{"outputFile":"app-release-unsigned.apk",...}]} + if let Some(apk_name) = self.parse_output_metadata(&metadata_content) { + let apk_path = apk_dir.join(&apk_name); + if apk_path.exists() { + if self.verbose { + println!( + " Found APK from output-metadata.json: {}", + apk_path.display() + ); } + return Ok(apk_path); } } } @@ -1146,13 +1145,12 @@ impl AndroidBuilder { let after_colon = after_key.trim_start().strip_prefix(':')?; let after_ws = after_colon.trim_start(); // Extract the string value - if after_ws.starts_with('"') { - let value_start = &after_ws[1..]; - if let Some(end_quote) = value_start.find('"') { - let filename = &value_start[..end_quote]; - if filename.ends_with(".apk") { - return Some(filename.to_string()); - } + if let Some(value_start) = after_ws.strip_prefix('"') + && let Some(end_quote) = value_start.find('"') + { + let filename = &value_start[..end_quote]; + if filename.ends_with(".apk") { + return Some(filename.to_string()); } } } @@ -1249,20 +1247,19 @@ impl AndroidBuilder { ) -> Result { // First, try to read output-metadata.json for the actual APK name let metadata_path = apk_dir.join("output-metadata.json"); - if metadata_path.exists() { - if let Ok(metadata_content) = fs::read_to_string(&metadata_path) { - if let Some(apk_name) = self.parse_output_metadata(&metadata_content) { - let apk_path = apk_dir.join(&apk_name); - if apk_path.exists() { - if self.verbose { - println!( - " Found test APK from output-metadata.json: {}", - apk_path.display() - ); - } - return Ok(apk_path); - } + if metadata_path.exists() + && let Ok(metadata_content) = fs::read_to_string(&metadata_path) + && let Some(apk_name) = self.parse_output_metadata(&metadata_content) + { + let apk_path = apk_dir.join(&apk_name); + if apk_path.exists() { + if self.verbose { + println!( + " Found test APK from output-metadata.json: {}", + apk_path.display() + ); } + return Ok(apk_path); } } diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 9510347..882647c 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -481,12 +481,11 @@ impl IosBuilder { // Check if the current directory (project_root) IS the crate // This handles the case where user runs `cargo mobench build` from within the crate directory let root_cargo_toml = self.project_root.join("Cargo.toml"); - if root_cargo_toml.exists() { - if let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) { - if pkg_name == self.crate_name { - return Ok(self.project_root.clone()); - } - } + if root_cargo_toml.exists() + && let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) + && pkg_name == self.crate_name + { + return Ok(self.project_root.clone()); } // Try bench-mobile/ (SDK projects) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index a0cdd7c..f46cdf4 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -768,13 +768,11 @@ fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), Be relative.set_extension(""); } - if should_render { - if let Ok(text) = std::str::from_utf8(&contents) { - let rendered = render_template(text, vars); - // Validate that all template variables were replaced - validate_no_unreplaced_placeholders(&rendered, &relative)?; - contents = rendered.into_bytes(); - } + if should_render && let Ok(text) = std::str::from_utf8(&contents) { + let rendered = render_template(text, vars); + // Validate that all template variables were replaced + validate_no_unreplaced_placeholders(&rendered, &relative)?; + contents = rendered.into_bytes(); } let out_path = out_root.join(relative); @@ -804,10 +802,10 @@ fn is_template_file(path: &Path) -> bool { // Also check the filename without the .template extension if let Some(stem) = path.file_stem() { let stem_path = Path::new(stem); - if let Some(ext) = stem_path.extension() { - if let Some(ext_str) = ext.to_str() { - return TEMPLATE_EXTENSIONS.contains(&ext_str); - } + if let Some(ext) = stem_path.extension() + && let Some(ext_str) = ext.to_str() + { + return TEMPLATE_EXTENSIONS.contains(&ext_str); } } false @@ -1126,10 +1124,10 @@ pub fn resolve_default_function( // Try to detect benchmarks from each potential location for dir in &search_dirs { - if dir.join("Cargo.toml").exists() { - if let Some(detected) = detect_default_function(dir, &crate_name_normalized) { - return detected; - } + if dir.join("Cargo.toml").exists() + && let Some(detected) = detect_default_function(dir, &crate_name_normalized) + { + return detected; } } @@ -1326,7 +1324,8 @@ mod tests { for file in files_to_check { let path = android_dir.join(file); - let contents = fs::read_to_string(&path).expect(&format!("Failed to read {}", file)); + let contents = + fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {}", file)); // Check for unreplaced placeholders let has_placeholder = contents.contains("{{") && contents.contains("}}"); diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 0191a64..6f20ebf 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -156,49 +156,51 @@ //! //! ### Using the Builder Pattern //! -//! ```ignore +//! ```no_run //! use mobench_sdk::BenchmarkBuilder; //! -//! let report = BenchmarkBuilder::new("my_benchmark") -//! .iterations(100) -//! .warmup(10) -//! .run()?; +//! fn main() -> Result<(), Box> { +//! let report = BenchmarkBuilder::new("my_benchmark") +//! .iterations(100) +//! .warmup(10) +//! .run()?; //! -//! println!("Mean: {} ns", report.samples.iter() -//! .map(|s| s.duration_ns) -//! .sum::() / report.samples.len() as u64); +//! println!("Mean: {} ns", report.mean_ns()); +//! Ok(()) +//! } //! ``` //! //! ### Using BenchSpec Directly //! -//! ```ignore +//! ```no_run //! use mobench_sdk::{BenchSpec, run_benchmark}; //! -//! let spec = BenchSpec { -//! name: "my_benchmark".to_string(), -//! iterations: 50, -//! warmup: 5, -//! }; +//! fn main() -> Result<(), Box> { +//! let spec = BenchSpec::new("my_benchmark", 50, 5)?; //! -//! let report = run_benchmark(spec)?; -//! println!("Collected {} samples", report.samples.len()); +//! let report = run_benchmark(spec)?; +//! println!("Collected {} samples", report.samples.len()); +//! Ok(()) +//! } //! ``` //! //! ### Discovering Benchmarks //! -//! ```ignore +//! ```no_run //! use mobench_sdk::{discover_benchmarks, list_benchmark_names}; //! -//! // Get all registered benchmark names -//! let names = list_benchmark_names(); -//! for name in names { -//! println!("Found benchmark: {}", name); -//! } +//! fn main() { +//! // Get all registered benchmark names +//! let names = list_benchmark_names(); +//! for name in names { +//! println!("Found benchmark: {}", name); +//! } //! -//! // Get full benchmark function info -//! let benchmarks = discover_benchmarks(); -//! for bench in benchmarks { -//! println!("Benchmark: {}", bench.name); +//! // Get full benchmark function info +//! let benchmarks = discover_benchmarks(); +//! for bench in benchmarks { +//! println!("Benchmark: {}", bench.name); +//! } //! } //! ``` //! diff --git a/crates/mobench-sdk/src/registry.rs b/crates/mobench-sdk/src/registry.rs index da2c373..44beeeb 100644 --- a/crates/mobench-sdk/src/registry.rs +++ b/crates/mobench-sdk/src/registry.rs @@ -30,12 +30,14 @@ inventory::collect!(BenchFunction); /// /// # Example /// -/// ```ignore +/// ```no_run /// use mobench_sdk::registry::discover_benchmarks; /// -/// let benchmarks = discover_benchmarks(); -/// for bench in benchmarks { -/// println!("Found benchmark: {}", bench.name); +/// fn main() { +/// let benchmarks = discover_benchmarks(); +/// for bench in benchmarks { +/// println!("Found benchmark: {}", bench.name); +/// } /// } /// ``` pub fn discover_benchmarks() -> Vec<&'static BenchFunction> { @@ -59,13 +61,15 @@ pub fn discover_benchmarks() -> Vec<&'static BenchFunction> { /// /// # Example /// -/// ```ignore +/// ```no_run /// use mobench_sdk::registry::find_benchmark; /// -/// if let Some(bench) = find_benchmark("fibonacci") { -/// println!("Found benchmark: {}", bench.name); -/// } else { -/// eprintln!("Benchmark not found"); +/// fn main() { +/// if let Some(bench) = find_benchmark("fibonacci") { +/// println!("Found benchmark: {}", bench.name); +/// } else { +/// eprintln!("Benchmark not found"); +/// } /// } /// ``` pub fn find_benchmark(name: &str) -> Option<&'static BenchFunction> { @@ -81,13 +85,15 @@ pub fn find_benchmark(name: &str) -> Option<&'static BenchFunction> { /// /// # Example /// -/// ```ignore +/// ```no_run /// use mobench_sdk::registry::list_benchmark_names; /// -/// let names = list_benchmark_names(); -/// println!("Available benchmarks:"); -/// for name in names { -/// println!(" - {}", name); +/// fn main() { +/// let names = list_benchmark_names(); +/// println!("Available benchmarks:"); +/// for name in names { +/// println!(" - {}", name); +/// } /// } /// ``` pub fn list_benchmark_names() -> Vec<&'static str> { diff --git a/crates/mobench-sdk/src/runner.rs b/crates/mobench-sdk/src/runner.rs index bacc66b..65d27f9 100644 --- a/crates/mobench-sdk/src/runner.rs +++ b/crates/mobench-sdk/src/runner.rs @@ -24,17 +24,16 @@ use crate::types::{BenchError, RunnerReport}; /// /// # Example /// -/// ```ignore +/// ```no_run /// use mobench_sdk::{BenchSpec, run_benchmark}; /// -/// let spec = BenchSpec { -/// name: "my_benchmark".to_string(), -/// iterations: 100, -/// warmup: 10, -/// }; +/// fn main() -> Result<(), Box> { +/// let spec = BenchSpec::new("my_benchmark", 100, 10)?; /// -/// let report = run_benchmark(spec)?; -/// println!("Mean: {} ns", report.mean()); +/// let report = run_benchmark(spec)?; +/// println!("Mean: {} ns", report.mean_ns()); +/// Ok(()) +/// } /// ``` pub fn run_benchmark(spec: BenchSpec) -> Result { // Find the benchmark function in the registry @@ -58,13 +57,18 @@ pub fn run_benchmark(spec: BenchSpec) -> Result { /// /// # Example /// -/// ```ignore +/// ```no_run /// use mobench_sdk::BenchmarkBuilder; /// -/// let report = BenchmarkBuilder::new("my_benchmark") -/// .iterations(100) -/// .warmup(10) -/// .run()?; +/// fn main() -> Result<(), Box> { +/// let report = BenchmarkBuilder::new("my_benchmark") +/// .iterations(100) +/// .warmup(10) +/// .run()?; +/// +/// println!("Median: {} ns", report.median_ns()); +/// Ok(()) +/// } /// ``` #[derive(Debug, Clone)] pub struct BenchmarkBuilder { diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index ba8cfcb..b75818d 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -733,7 +733,7 @@ struct MemoryPeakSampler { impl MemoryPeakSampler { fn start() -> Option { - Self::start_with_reader(Arc::new(|| current_process_memory_kb())) + Self::start_with_reader(Arc::new(current_process_memory_kb)) } fn start_with_reader(reader: MemoryReader) -> Option { @@ -855,7 +855,7 @@ fn current_process_memory_kb() -> Option { } let info = unsafe { info.assume_init() }; - Some((info.resident_size / 1024) as u64) + Some(info.resident_size / 1024) } #[cfg(not(any( @@ -1007,12 +1007,12 @@ pub enum TimingError { /// /// Uses [`std::time::Instant`] for timing, which provides monotonic, /// nanosecond-resolution measurements on most platforms. -pub fn run_closure(spec: BenchSpec, mut f: F) -> Result +pub fn run_closure(spec: BenchSpec, f: F) -> Result where F: FnMut() -> Result<(), TimingError>, { let mut monitor = DefaultResourceMonitor; - run_closure_with_monitor(spec, &mut monitor, move || f()) + run_closure_with_monitor(spec, &mut monitor, f) } fn run_closure_with_monitor( @@ -1052,7 +1052,7 @@ where begin_semantic_phase_collection(); let mut samples = Vec::with_capacity(spec.iterations as usize); for iteration in 0..spec.iterations { - let (sample, start, end) = match measure_iteration(monitor, || f()) { + let (sample, start, end) = match measure_iteration(monitor, &mut f) { Ok(measurement) => measurement, Err(err) => { let _ = finish_semantic_phase_collection(); @@ -1227,20 +1227,15 @@ where /// ``` pub fn run_closure_with_setup_per_iter( spec: BenchSpec, - mut setup: S, - mut f: F, + setup: S, + f: F, ) -> Result where S: FnMut() -> T, F: FnMut(T) -> Result<(), TimingError>, { let mut monitor = DefaultResourceMonitor; - run_closure_with_setup_per_iter_with_monitor( - spec, - &mut monitor, - move || setup(), - move |input| f(input), - ) + run_closure_with_setup_per_iter_with_monitor(spec, &mut monitor, setup, f) } fn run_closure_with_setup_per_iter_with_monitor( diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 1c4915c..3851d80 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -3,6 +3,7 @@ name = "mobench" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true authors = ["Dominik Clemente - dcbuilder.eth "] description = "Rust mobile benchmark CLI with CI contract outputs and BrowserStack automation" repository = "https://github.com/worldcoin/mobile-bench-rs" diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index f5f821d..5e71096 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -672,10 +672,11 @@ impl BrowserStackClient { fn extract_json_from_ios_log_section(section: &str) -> Option { // First, try the whole section as-is (trimmed) let trimmed = section.trim(); - if trimmed.starts_with('{') && trimmed.ends_with('}') { - if let Ok(json) = serde_json::from_str::(trimmed) { - return Some(json); - } + if trimmed.starts_with('{') + && trimmed.ends_with('}') + && let Ok(json) = serde_json::from_str::(trimmed) + { + return Some(json); } // Look for JSON on individual lines, stripping iOS log prefixes @@ -688,10 +689,10 @@ impl BrowserStackClient { // Look for JSON starting with { if let Some(json_start) = line.find('{') { let potential_json = &line[json_start..]; - if let Some(json) = Self::extract_balanced_json(potential_json) { - if let Ok(parsed) = serde_json::from_str::(&json) { - return Some(parsed); - } + if let Some(json) = Self::extract_balanced_json(potential_json) + && let Ok(parsed) = serde_json::from_str::(&json) + { + return Some(parsed); } } } @@ -713,10 +714,10 @@ impl BrowserStackClient { if let Some(json_start) = all_content.find('{') { let potential_json = &all_content[json_start..]; - if let Some(json) = Self::extract_balanced_json(potential_json) { - if let Ok(parsed) = serde_json::from_str::(&json) { - return Some(parsed); - } + if let Some(json) = Self::extract_balanced_json(potential_json) + && let Ok(parsed) = serde_json::from_str::(&json) + { + return Some(parsed); } } @@ -906,15 +907,15 @@ impl BrowserStackClient { } } - if let Ok(app_profiling_v2) = self.get_app_profiling_v2(build_id, &device.session_id) { - if app_profiling_v2.sample_count > 0 { - println!(" Found App Profiling v2 metrics"); - device_performance_metrics = merge_performance_metrics( - Some(device_performance_metrics), - Some(app_profiling_v2), - ) - .unwrap_or_default(); - } + if let Ok(app_profiling_v2) = self.get_app_profiling_v2(build_id, &device.session_id) + && app_profiling_v2.sample_count > 0 + { + println!(" Found App Profiling v2 metrics"); + device_performance_metrics = merge_performance_metrics( + Some(device_performance_metrics), + Some(app_profiling_v2), + ) + .unwrap_or_default(); } if let Some(results) = device_benchmark_results { diff --git a/crates/mobench/src/flamegraph_viewer.rs b/crates/mobench/src/flamegraph_viewer.rs index c723cf2..4226113 100644 --- a/crates/mobench/src/flamegraph_viewer.rs +++ b/crates/mobench/src/flamegraph_viewer.rs @@ -415,11 +415,10 @@ fn build_frame_breakdown_list( .map(|(frame, samples)| FrameBreakdown { frame, samples, - percent_total: if total_samples == 0 { - 0 - } else { - samples.saturating_mul(100) / total_samples - }, + percent_total: samples + .saturating_mul(100) + .checked_div(total_samples) + .unwrap_or(0), }) .collect(); frames.sort_by(|left, right| { @@ -565,7 +564,7 @@ fn retint_flamegraph_palette(document: String) -> String { output } -fn frame_title_for_fill<'a>(document: &'a str, fill_start: usize) -> &'a str { +fn frame_title_for_fill(document: &str, fill_start: usize) -> &str { document[..fill_start] .rfind("") .and_then(|title_start| { diff --git a/crates/mobench/src/github.rs b/crates/mobench/src/github.rs index e9490d3..a54a009 100644 --- a/crates/mobench/src/github.rs +++ b/crates/mobench/src/github.rs @@ -52,6 +52,7 @@ impl GitHubClient { Ok(Self { http, token }) } + #[allow(clippy::too_many_arguments)] pub fn create_check_run( &self, repo: &str, diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index f8d1592..f962008 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -539,6 +539,7 @@ enum Command { } #[derive(Subcommand, Debug)] +#[allow(clippy::large_enum_variant)] enum CiCommand { /// Generate GitHub Actions workflow + local action wrapper. Init { @@ -1607,17 +1608,17 @@ pub fn run() -> Result<()> { let mut compare_report = None; let mut regression_findings: Vec<RegressionFinding> = Vec::new(); if let Some(baseline_path) = baseline_compare_path.as_deref() { - let report = compare_summaries(&baseline_path, &summary_paths.json)?; + let report = compare_summaries(baseline_path, &summary_paths.json)?; regression_findings = detect_regressions(&report, regression_threshold_pct); compare_report = Some(report); } - if let Some(snapshot_path) = baseline_snapshot_path { - if let Err(err) = fs::remove_file(&snapshot_path) { - eprintln!( - "Warning: failed to remove baseline snapshot {}: {err}", - snapshot_path.display() - ); - } + if let Some(snapshot_path) = baseline_snapshot_path + && let Err(err) = fs::remove_file(&snapshot_path) + { + eprintln!( + "Warning: failed to remove baseline snapshot {}: {err}", + snapshot_path.display() + ); } if let Some(report) = &compare_report { inject_compare_into_summary( @@ -1634,12 +1635,11 @@ pub fn run() -> Result<()> { } if let Some(report) = &compare_report { let compare_markdown = render_compare_markdown(report); - if let Ok(summary_path) = env::var("GITHUB_STEP_SUMMARY") { - if let Err(err) = + if let Ok(summary_path) = env::var("GITHUB_STEP_SUMMARY") + && let Err(err) = append_github_step_summary(&compare_markdown, &summary_path) - { - eprintln!("Warning: failed to append comparison report: {err}"); - } + { + eprintln!("Warning: failed to append comparison report: {err}"); } } } else if let Some(report) = &compare_report { @@ -2536,19 +2536,19 @@ fn resolve_ci_functions(args: &CiRunArgs) -> Result<Vec<String>> { let mut funcs = args.functions.clone(); // --function (singular) is sugar for a single-element list - if let Some(ref f) = args.function { - if !funcs.contains(f) { - funcs.insert(0, f.clone()); - } + if let Some(ref f) = args.function + && !funcs.contains(f) + { + funcs.insert(0, f.clone()); } // Support JSON array passed as a single element: '["a","b"]' if funcs.len() == 1 { let trimmed = funcs[0].trim(); - if trimmed.starts_with('[') { - if let Ok(parsed) = serde_json::from_str::<Vec<String>>(trimmed) { - return Ok(parsed); - } + if trimmed.starts_with('[') + && let Ok(parsed) = serde_json::from_str::<Vec<String>>(trimmed) + { + return Ok(parsed); } } @@ -3021,26 +3021,26 @@ fn cmd_ci_check_run(args: CiCheckRunArgs) -> Result<()> { &bench.name, ); - if let Some(base) = baseline_bench { - if base.timing.avg_ms > 0.0 { - let pct_change = - (bench.timing.avg_ms - base.timing.avg_ms) / base.timing.avg_ms * 100.0; - - if pct_change > args.regression_threshold_pct { - has_regression = true; - let line = annotations.len() as u32 + 1; - annotations.push(github::CheckRunAnnotation { - path: args.annotation_path.clone(), - start_line: line, - end_line: line, - annotation_level: "warning".to_string(), - message: format!( - "{} regressed {pct_change:+.1}% ({:.1}ms \u{2192} {:.1}ms)", - bench.label, base.timing.avg_ms, bench.timing.avg_ms - ), - title: format!("Regression: {}", bench.label), - }); - } + if let Some(base) = baseline_bench + && base.timing.avg_ms > 0.0 + { + let pct_change = + (bench.timing.avg_ms - base.timing.avg_ms) / base.timing.avg_ms * 100.0; + + if pct_change > args.regression_threshold_pct { + has_regression = true; + let line = annotations.len() as u32 + 1; + annotations.push(github::CheckRunAnnotation { + path: args.annotation_path.clone(), + start_line: line, + end_line: line, + annotation_level: "warning".to_string(), + message: format!( + "{} regressed {pct_change:+.1}% ({:.1}ms \u{2192} {:.1}ms)", + bench.label, base.timing.avg_ms, bench.timing.avg_ms + ), + title: format!("Regression: {}", bench.label), + }); } } } @@ -3804,15 +3804,15 @@ fn validate_artifacts_for_browserstack( match target { MobileTarget::Android => { - if let Some(apk_path) = apk { - if !apk_path.exists() { - missing.push(("Android APK".to_string(), apk_path.to_path_buf())); - } + if let Some(apk_path) = apk + && !apk_path.exists() + { + missing.push(("Android APK".to_string(), apk_path.to_path_buf())); } - if let Some(test_apk_path) = test_apk { - if !test_apk_path.exists() { - missing.push(("Android test APK".to_string(), test_apk_path.to_path_buf())); - } + if let Some(test_apk_path) = test_apk + && !test_apk_path.exists() + { + missing.push(("Android test APK".to_string(), test_apk_path.to_path_buf())); } } MobileTarget::Ios => { @@ -4419,25 +4419,25 @@ struct RegressionFinding { fn detect_regressions(report: &CompareReport, threshold_pct: f64) -> Vec<RegressionFinding> { let mut findings = Vec::new(); for row in &report.rows { - if let Some(delta) = row.median_delta_pct { - if delta > threshold_pct { - findings.push(RegressionFinding { - device: row.device.clone(), - function: row.function.clone(), - metric: "median".to_string(), - delta_pct: delta, - }); - } + if let Some(delta) = row.median_delta_pct + && delta > threshold_pct + { + findings.push(RegressionFinding { + device: row.device.clone(), + function: row.function.clone(), + metric: "median".to_string(), + delta_pct: delta, + }); } - if let Some(delta) = row.p95_delta_pct { - if delta > threshold_pct { - findings.push(RegressionFinding { - device: row.device.clone(), - function: row.function.clone(), - metric: "p95".to_string(), - delta_pct: delta, - }); - } + if let Some(delta) = row.p95_delta_pct + && delta > threshold_pct + { + findings.push(RegressionFinding { + device: row.device.clone(), + function: row.function.clone(), + metric: "p95".to_string(), + delta_pct: delta, + }); } } findings @@ -5744,18 +5744,17 @@ fn ensure_android_home() { if let Ok(ndk_home) = std::env::var("ANDROID_NDK_HOME") { // ANDROID_NDK_HOME is typically $ANDROID_HOME/ndk/<version> let ndk_path = std::path::Path::new(&ndk_home); - if let Some(ndk_dir) = ndk_path.parent() { - if ndk_dir.file_name().is_some_and(|n| n == "ndk") { - if let Some(sdk_root) = ndk_dir.parent() { - eprintln!( - "Inferred ANDROID_HOME={} from ANDROID_NDK_HOME", - sdk_root.display() - ); - // SAFETY: called early in single-threaded CLI init, before - // any threads are spawned. - unsafe { std::env::set_var("ANDROID_HOME", sdk_root) }; - } - } + if let Some(ndk_dir) = ndk_path.parent() + && ndk_dir.file_name().is_some_and(|n| n == "ndk") + && let Some(sdk_root) = ndk_dir.parent() + { + eprintln!( + "Inferred ANDROID_HOME={} from ANDROID_NDK_HOME", + sdk_root.display() + ); + // SAFETY: called early in single-threaded CLI init, before + // any threads are spawned. + unsafe { std::env::set_var("ANDROID_HOME", sdk_root) }; } } } @@ -5873,6 +5872,7 @@ fn cmd_init_sdk( } /// Build mobile artifacts using `mobench-sdk`. +#[allow(clippy::too_many_arguments)] fn cmd_build( target: SdkTarget, release: bool, @@ -6243,6 +6243,7 @@ fn cmd_package_xcuitest( } /// Verify benchmark setup: registry, spec, artifacts, and optional smoke test +#[allow(clippy::too_many_arguments)] fn cmd_verify( project_root: Option<PathBuf>, crate_path: Option<PathBuf>, @@ -7951,15 +7952,15 @@ fn extend_android_prereq_checks(checks: &mut Vec<PrereqCheck>) { fn collect_issues(checks: &[PrereqCheck]) -> Vec<ValidationIssue> { let mut issues = Vec::new(); for check in checks { - if !check.passed { - if let Some(ref fix) = check.fix_hint { - issues.push(ValidationIssue { - category: issue_category_for_check(check), - check: check.name.clone(), - detail: check.detail.clone(), - fix_hint: fix.clone(), - }); - } + if !check.passed + && let Some(ref fix) = check.fix_hint + { + issues.push(ValidationIssue { + category: issue_category_for_check(check), + check: check.name.clone(), + detail: check.detail.clone(), + fix_hint: fix.clone(), + }); } } issues diff --git a/crates/mobench/src/plots.rs b/crates/mobench/src/plots.rs index 18573c7..fa5624c 100644 --- a/crates/mobench/src/plots.rs +++ b/crates/mobench/src/plots.rs @@ -986,15 +986,8 @@ fn parse_device_string(s: &str) -> (String, String) { } } -fn infer_device_from_path(path: &Path) -> (String, String) { - let lower_path = path.to_string_lossy().to_ascii_lowercase(); - if lower_path.contains("/ios/") || lower_path.contains("\\ios\\") { - ("unknown".to_string(), "unknown".to_string()) - } else if lower_path.contains("/android/") || lower_path.contains("\\android\\") { - ("unknown".to_string(), "unknown".to_string()) - } else { - ("unknown".to_string(), "unknown".to_string()) - } +fn infer_device_from_path(_path: &Path) -> (String, String) { + ("unknown".to_string(), "unknown".to_string()) } fn infer_target_from_path(path: &Path) -> String { diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index dd807a4..5fd9168 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -632,24 +632,24 @@ fn write_profile_session_outputs( manifest: &ProfileManifest, ) -> Result<()> { std::fs::create_dir_all(&args.output_dir)?; - std::fs::create_dir_all(&run_output_dir)?; + std::fs::create_dir_all(run_output_dir)?; create_selected_artifact_roots( &manifest.native_capture.raw_artifacts, &manifest.native_capture.processed_artifacts, )?; - let rendered_summary = render_profile_markdown(&manifest); + let rendered_summary = render_profile_markdown(manifest); let run_profile_path = run_output_dir.join("profile.json"); let run_summary_path = run_output_dir.join("summary.md"); write_semantic_phase_sidecar(manifest)?; write_harness_timeline_sidecar(manifest)?; refresh_flamegraph_viewer_from_manifest(run_output_dir, manifest)?; - write_profile_manifest(&run_profile_path, &manifest)?; + write_profile_manifest(&run_profile_path, manifest)?; std::fs::write(&run_summary_path, rendered_summary.as_bytes())?; let latest_profile_path = args.output_dir.join("profile.json"); let latest_summary_path = args.output_dir.join("summary.md"); - write_profile_manifest(&latest_profile_path, &manifest)?; + write_profile_manifest(&latest_profile_path, manifest)?; std::fs::write(&latest_summary_path, rendered_summary.as_bytes())?; Ok(()) } @@ -1059,35 +1059,36 @@ fn build_differential_viewer_run_metadata( baseline_manifest: &ProfileManifest, candidate_manifest: &ProfileManifest, ) -> Vec<ViewerMetadataItem> { - let mut metadata = Vec::new(); - metadata.push(ViewerMetadataItem { - label: "Baseline Run".into(), - value: baseline_manifest.run_id.clone(), - }); - metadata.push(ViewerMetadataItem { - label: "Candidate Run".into(), - value: candidate_manifest.run_id.clone(), - }); - metadata.push(ViewerMetadataItem { - label: "Target".into(), - value: match candidate_manifest.target { - MobileTarget::Android => "android".into(), - MobileTarget::Ios => "ios".into(), + let mut metadata = vec![ + ViewerMetadataItem { + label: "Baseline Run".into(), + value: baseline_manifest.run_id.clone(), }, - }); - metadata.push(ViewerMetadataItem { - label: "Backend".into(), - value: match candidate_manifest.backend { - ProfileBackend::AndroidNative => "android-native".into(), - ProfileBackend::IosInstruments => "ios-instruments".into(), - ProfileBackend::RustTracing => "rust-tracing".into(), - ProfileBackend::Auto => "auto".into(), + ViewerMetadataItem { + label: "Candidate Run".into(), + value: candidate_manifest.run_id.clone(), }, - }); - metadata.push(ViewerMetadataItem { - label: "Benchmark".into(), - value: candidate_manifest.function.clone(), - }); + ViewerMetadataItem { + label: "Target".into(), + value: match candidate_manifest.target { + MobileTarget::Android => "android".into(), + MobileTarget::Ios => "ios".into(), + }, + }, + ViewerMetadataItem { + label: "Backend".into(), + value: match candidate_manifest.backend { + ProfileBackend::AndroidNative => "android-native".into(), + ProfileBackend::IosInstruments => "ios-instruments".into(), + ProfileBackend::RustTracing => "rust-tracing".into(), + ProfileBackend::Auto => "auto".into(), + }, + }, + ViewerMetadataItem { + label: "Benchmark".into(), + value: candidate_manifest.function.clone(), + }, + ]; if let Some(device) = &candidate_manifest.capture_metadata.device { metadata.push(ViewerMetadataItem { label: "Device".into(), @@ -1145,6 +1146,7 @@ fn build_differential_viewer_run_metadata( metadata } +#[allow(clippy::too_many_arguments)] fn build_differential_viewer_artifact_links( processed_root: &Path, candidate_run_dir: &Path, @@ -1275,31 +1277,32 @@ fn flamegraph_title_for_manifest(manifest: &ProfileManifest) -> String { } fn build_viewer_run_metadata(manifest: &ProfileManifest) -> Vec<ViewerMetadataItem> { - let mut metadata = Vec::new(); - metadata.push(ViewerMetadataItem { - label: "Run ID".into(), - value: manifest.run_id.clone(), - }); - metadata.push(ViewerMetadataItem { - label: "Target".into(), - value: match manifest.target { - MobileTarget::Android => "android".into(), - MobileTarget::Ios => "ios".into(), + let mut metadata = vec![ + ViewerMetadataItem { + label: "Run ID".into(), + value: manifest.run_id.clone(), }, - }); - metadata.push(ViewerMetadataItem { - label: "Backend".into(), - value: match manifest.backend { - ProfileBackend::AndroidNative => "android-native".into(), - ProfileBackend::IosInstruments => "ios-instruments".into(), - ProfileBackend::RustTracing => "rust-tracing".into(), - ProfileBackend::Auto => "auto".into(), + ViewerMetadataItem { + label: "Target".into(), + value: match manifest.target { + MobileTarget::Android => "android".into(), + MobileTarget::Ios => "ios".into(), + }, }, - }); - metadata.push(ViewerMetadataItem { - label: "Benchmark".into(), - value: manifest.function.clone(), - }); + ViewerMetadataItem { + label: "Backend".into(), + value: match manifest.backend { + ProfileBackend::AndroidNative => "android-native".into(), + ProfileBackend::IosInstruments => "ios-instruments".into(), + ProfileBackend::RustTracing => "rust-tracing".into(), + ProfileBackend::Auto => "auto".into(), + }, + }, + ViewerMetadataItem { + label: "Benchmark".into(), + value: manifest.function.clone(), + }, + ]; if let Some(device) = &manifest.capture_metadata.device { metadata.push(ViewerMetadataItem { label: "Device".into(), @@ -1458,7 +1461,7 @@ fn write_chronological_trace_sidecar( if let Some(parent) = trace_path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(&trace_path, serde_json::to_vec_pretty(&trace)?) + std::fs::write(trace_path, serde_json::to_vec_pretty(&trace)?) .with_context(|| format!("writing {}", trace_path.display()))?; Ok(Some(trace_path.to_path_buf())) } @@ -1799,6 +1802,7 @@ fn validate_profile_diff_inputs( Ok(()) } +#[allow(clippy::too_many_arguments)] fn build_profile_diff_mode( baseline_run_dir: &Path, baseline_manifest: &ProfileManifest, diff --git a/crates/sample-fns/Cargo.toml b/crates/sample-fns/Cargo.toml index 3697ba2..23ad1ca 100644 --- a/crates/sample-fns/Cargo.toml +++ b/crates/sample-fns/Cargo.toml @@ -3,6 +3,7 @@ name = "sample-fns" version.workspace = true edition = "2021" # UniFFI 0.28 generates code for edition 2021 license.workspace = true +rust-version.workspace = true [lib] name = "sample_fns" diff --git a/docs/codebase/PUBLIC_API.md b/docs/codebase/PUBLIC_API.md new file mode 100644 index 0000000..914d7c2 --- /dev/null +++ b/docs/codebase/PUBLIC_API.md @@ -0,0 +1,143 @@ +# Public API And Stability + +Updated: 2026-04-26 + +## Purpose + +This document defines the public API boundaries for the published mobench +crates. It is the starting point for semver reviews, docs.rs cleanup, feature +flag changes, and release readiness checks. + +## Published Crates + +### `mobench-sdk` + +Primary audience: library adopters and generated mobile runners. + +Stable public surface: +- `mobench_sdk::benchmark` +- `mobench_sdk::BenchmarkBuilder` +- `mobench_sdk::run_benchmark` +- `mobench_sdk::discover_benchmarks` +- `mobench_sdk::find_benchmark` +- `mobench_sdk::list_benchmark_names` +- `mobench_sdk::black_box` +- `mobench_sdk::timing::{BenchSpec, BenchSample, BenchReport, BenchSummary}` +- `mobench_sdk::timing::{SemanticPhase, HarnessTimelineSpan, TimingError}` +- `mobench_sdk::timing::{profile_phase, run_closure}` +- `mobench_sdk::{BenchError, Target, InitConfig, BuildConfig, BuildProfile}` +- `mobench_sdk::{NativeLibraryArtifact, BuildResult}` +- `mobench_sdk::builders::{AndroidBuilder, IosBuilder, SigningMethod}` + +Supported but lower-level public surface: +- `mobench_sdk::ffi` +- `mobench_sdk::uniffi_types` +- `mobench_sdk::codegen` +- `mobench_sdk::builders::common` + +These modules are public because generated runners, examples, or advanced +integrations use them. They should remain documented, but breaking changes here +can be considered when release notes include migration guidance. + +### `mobench` + +Primary audience: CLI users and CI integrations. + +Stable public surface: +- the `mobench` and `cargo-mobench` binaries +- CLI flags and subcommands documented by `--help` +- JSON, Markdown, CSV, and profiling artifact contracts documented under + `docs/schemas/`, `docs/guides/`, and `README.md` +- programmatic types exported from `crates/mobench/src/lib.rs`: + - `DeviceSelection` + - `RunRequest` + - `RunResult` + - `Report` + - `run_request` + +The CLI may continue using `anyhow` internally. User-facing failures should add +actionable context before crossing the command boundary. + +### `mobench-macros` + +Primary audience: benchmark authors through `mobench-sdk`. + +Stable public surface: +- `#[benchmark]` +- supported attributes documented in the macro rustdoc, including setup, + teardown, and per-iteration setup behavior + +## Feature Flags + +`mobench-sdk` currently exposes two features: + +- `full`: default feature. Enables the benchmark macro, inventory registry, + builders, embedded templates, codegen, and TOML-backed build automation. +- `runner-only`: minimal mobile-runtime mode for generated/mobile benchmark + binaries where build automation is not needed. + +Feature policy: +- keep `runner-only` free of build tooling dependencies +- keep default features convenient for normal SDK users +- add narrower optional features only when they measurably reduce dependency + footprint or clarify platform support +- document any feature behavior change in release notes + +## Error Handling Boundary + +- SDK APIs should expose typed errors via `BenchError` or `TimingError`. +- CLI orchestration may use `anyhow::Result` internally and at CLI entrypoints. +- Generated FFI surfaces should convert errors into FFI-safe error enums. +- New reusable SDK APIs should not expose `anyhow::Error` unless the API is + explicitly CLI-only or transitional. + +## Semver Policy + +Before `1.0`, mobench still treats the following as compatibility-sensitive: + +- benchmark macro syntax used by downstream crates +- `mobench-sdk` top-level re-exports +- serialized benchmark and CI output fields +- documented CLI flags used in CI +- generated template inputs and output artifact names + +Allowed in a minor release: +- adding fields when deserializers tolerate them +- adding CLI flags with defaults +- adding new optional outputs +- adding non-default crate features +- improving diagnostics without changing machine-readable contracts + +Requires migration notes: +- removing or renaming public Rust items +- changing default feature composition +- changing serialized field names or units +- changing generated template paths consumed by users +- changing CLI defaults that affect output location or benchmark behavior + +## MSRV + +The workspace MSRV is Rust 1.85, the first stable release with Rust 2024 +edition support. Crates inherit this through `workspace.package.rust-version`. + +If a dependency or language feature requires raising MSRV: +- update `workspace.package.rust-version` +- update this document +- mention the change in `RELEASE_NOTES.md` +- verify the quality workflow still passes on the new stable toolchain + +## Release Readiness Checks + +Run these before publishing the published crates: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps +cargo test --workspace +cargo publish --dry-run -p mobench-macros +cargo publish --dry-run -p mobench-sdk +cargo publish --dry-run -p mobench +``` + +Publish order is `mobench-macros`, `mobench-sdk`, then `mobench`. diff --git a/docs/codebase/README.md b/docs/codebase/README.md index 7e9158e..0496437 100644 --- a/docs/codebase/README.md +++ b/docs/codebase/README.md @@ -7,6 +7,7 @@ For end-user and integrator workflows, start in [docs/guides/README.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/README.md). - [ARCHITECTURE.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/ARCHITECTURE.md): how the CLI, SDK, generated runners, profiling pipeline, and CI workflows fit together +- [PUBLIC_API.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/PUBLIC_API.md): public API, semver, feature flag, MSRV, and release-readiness boundaries - [STRUCTURE.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/STRUCTURE.md): where the important crates, templates, docs, and workflows live - [STACK.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/STACK.md): the main languages, tools, runtime dependencies, and native profiler toolchain - [INTEGRATIONS.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/codebase/INTEGRATIONS.md): BrowserStack, GitHub Actions, Android, iOS, and local profiling integrations diff --git a/docs/specs/production-readiness-roadmap.md b/docs/specs/production-readiness-roadmap.md index f46639d..1042800 100644 --- a/docs/specs/production-readiness-roadmap.md +++ b/docs/specs/production-readiness-roadmap.md @@ -19,17 +19,17 @@ production Rust crates. Audience: library adopters, CLI users, maintainers. Checklist: -- [ ] Audit public APIs exported from `mobench-sdk`. -- [ ] Document semver and stability boundaries. -- [ ] Review feature flags, especially `full` and `runner-only`. -- [ ] Replace reusable-library `anyhow` surfaces with typed errors where appropriate. -- [ ] Improve docs.rs module docs and examples. -- [ ] Add compile-tested doc examples for core SDK usage. -- [ ] Add or refine minimal library adopter examples. -- [ ] Audit crate metadata, badges, readmes, categories, and keywords. -- [ ] Document MSRV policy. -- [ ] Enforce rustfmt, clippy, and rustdoc warnings in CI. -- [ ] Run `cargo publish --dry-run` for all published crates before release. +- [x] Audit public APIs exported from `mobench-sdk`. +- [x] Document semver and stability boundaries. +- [x] Review feature flags, especially `full` and `runner-only`. +- [x] Replace reusable-library `anyhow` surfaces with typed errors where appropriate. +- [x] Improve docs.rs module docs and examples. +- [x] Add compile-tested doc examples for core SDK usage. +- [x] Add or refine minimal library adopter examples. +- [x] Audit crate metadata, badges, readmes, categories, and keywords. +- [x] Document MSRV policy. +- [x] Enforce rustfmt, clippy, and rustdoc warnings in CI. +- [x] Run `cargo publish --dry-run` for all published crates before release. Verification signals: - `cargo test --workspace` passes. diff --git a/examples/basic-benchmark/Cargo.toml b/examples/basic-benchmark/Cargo.toml index 22c4514..b6d34c7 100644 --- a/examples/basic-benchmark/Cargo.toml +++ b/examples/basic-benchmark/Cargo.toml @@ -3,6 +3,7 @@ name = "basic-benchmark" version = "0.1.0" edition = "2021" license.workspace = true +rust-version.workspace = true [lib] name = "basic_benchmark" diff --git a/examples/basic-benchmark/README.md b/examples/basic-benchmark/README.md new file mode 100644 index 0000000..9320d9c --- /dev/null +++ b/examples/basic-benchmark/README.md @@ -0,0 +1,28 @@ +# Basic Benchmark Example + +This example is the smallest complete mobench SDK integration in the workspace. +Use it when validating that a downstream crate can define benchmarks with +`#[benchmark]`, discover them through the SDK registry, and execute them through +the host runner before moving to Android or iOS devices. + +## What It Shows + +- `mobench-sdk` and `inventory` dependency setup +- `cdylib`, `staticlib`, and `lib` crate types for mobile FFI builds +- benchmark functions registered with `#[benchmark]` +- host-side tests that call the SDK runner +- deterministic fixture output under `examples/fixtures/basic/summary.json` + +## Try It + +From the repository root: + +```bash +cargo test -p basic-benchmark +cargo mobench list --crate-path examples/basic-benchmark +cargo mobench run --target android --function basic_benchmark::fibonacci --local-only +``` + +Use the `cargo mobench run` command as a host/local smoke test. Building and +running on actual Android or iOS devices still requires the platform toolchains +documented in `docs/guides/build.md`. diff --git a/examples/basic-benchmark/src/lib.rs b/examples/basic-benchmark/src/lib.rs index 591c093..22b38dd 100644 --- a/examples/basic-benchmark/src/lib.rs +++ b/examples/basic-benchmark/src/lib.rs @@ -13,7 +13,7 @@ const CHECKSUM_SWEEP_ITERATIONS: usize = 2_048; const FIBONACCI_START: u32 = 28; const FIBONACCI_SPAN: u32 = 6; const FIBONACCI_SWEEP_ITERATIONS: u32 = 200_000; -const CHECKSUM_INPUT: [u8; CHECKSUM_INPUT_LEN] = build_checksum_input(); +static CHECKSUM_INPUT: [u8; CHECKSUM_INPUT_LEN] = build_checksum_input(); const fn build_checksum_input() -> [u8; CHECKSUM_INPUT_LEN] { let mut bytes = [0u8; CHECKSUM_INPUT_LEN]; diff --git a/examples/ffi-benchmark/Cargo.toml b/examples/ffi-benchmark/Cargo.toml index ec42edd..12306a9 100644 --- a/examples/ffi-benchmark/Cargo.toml +++ b/examples/ffi-benchmark/Cargo.toml @@ -1,20 +1,21 @@ - [package] - name = "ffi-benchmark" - version = "0.1.0" - edition = "2021" - license.workspace = true - - [lib] - name = "ffi_benchmark" - crate-type = ["lib", "cdylib", "staticlib"] - - [dependencies] - mobench-sdk = { path = "../../crates/mobench-sdk" } - inventory.workspace = true - serde = { version = "1", features = ["derive"] } - serde_json = "1" - uniffi = { workspace = true, features = ["cli"] } - thiserror.workspace = true - - [build-dependencies] - uniffi = { workspace = true, features = ["build"] } +[package] +name = "ffi-benchmark" +version = "0.1.0" +edition = "2021" +license.workspace = true +rust-version.workspace = true + +[lib] +name = "ffi_benchmark" +crate-type = ["lib", "cdylib", "staticlib"] + +[dependencies] +mobench-sdk = { path = "../../crates/mobench-sdk" } +inventory.workspace = true +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uniffi = { workspace = true, features = ["cli"] } +thiserror.workspace = true + +[build-dependencies] +uniffi = { workspace = true, features = ["build"] } diff --git a/examples/ffi-benchmark/README.md b/examples/ffi-benchmark/README.md new file mode 100644 index 0000000..c3cc1cb --- /dev/null +++ b/examples/ffi-benchmark/README.md @@ -0,0 +1,28 @@ +# FFI Benchmark Example + +This example demonstrates the larger integration shape used by benchmark crates +that need explicit UniFFI bindings and custom FFI-facing types. Use it after the +basic example when validating mobile runner behavior or downstream projects with +their own FFI surface. + +## What It Shows + +- UniFFI build dependency setup +- SDK benchmark registration and registry execution +- FFI-safe benchmark request and error handling patterns +- host-side tests for invalid input, unknown benchmark names, and successful + benchmark execution +- deterministic fixture output under `examples/fixtures/ffi/summary.json` + +## Try It + +From the repository root: + +```bash +cargo test -p ffi-benchmark +cargo mobench list --crate-path examples/ffi-benchmark +cargo mobench run --target android --function ffi_benchmark::fibonacci --local-only +``` + +Use this example as the starting point for crates that need custom data types +crossing the Kotlin or Swift boundary. diff --git a/examples/ffi-benchmark/src/lib.rs b/examples/ffi-benchmark/src/lib.rs index 029b87b..8842af3 100644 --- a/examples/ffi-benchmark/src/lib.rs +++ b/examples/ffi-benchmark/src/lib.rs @@ -20,7 +20,7 @@ const CHECKSUM_SWEEP_ITERATIONS: usize = 2_048; const FIBONACCI_START: u32 = 28; const FIBONACCI_SPAN: u32 = 6; const FIBONACCI_SWEEP_ITERATIONS: u32 = 200_000; -const CHECKSUM_INPUT: [u8; CHECKSUM_INPUT_LEN] = build_checksum_input(); +static CHECKSUM_INPUT: [u8; CHECKSUM_INPUT_LEN] = build_checksum_input(); const fn build_checksum_input() -> [u8; CHECKSUM_INPUT_LEN] { let mut bytes = [0u8; CHECKSUM_INPUT_LEN]; From ea5fb72b74f86b09cf206f34fe604f06347afeb9 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Sun, 26 Apr 2026 10:25:03 +0200 Subject: [PATCH 182/196] test: complete gate 2 output contracts --- crates/mobench/src/lib.rs | 78 ++++++++++++++++++++++ docs/codebase/TESTING.md | 23 +++++++ docs/specs/production-readiness-roadmap.md | 30 ++++----- 3 files changed, 116 insertions(+), 15 deletions(-) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index f962008..c067d04 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -10045,6 +10045,84 @@ test_suite = "target/ios/BenchRunnerUITests.zip" } } + #[test] + fn example_summary_fixtures_validate_against_summary_schema() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let summary_schema_path = root.join("docs/schemas/summary-v1.schema.json"); + let summary_schema: Value = serde_json::from_str( + &fs::read_to_string(&summary_schema_path).expect("read summary schema"), + ) + .expect("parse summary schema"); + let validator = JSONSchema::options() + .compile(&summary_schema) + .expect("compile summary schema"); + + for fixture in [ + "examples/fixtures/basic/summary.json", + "examples/fixtures/ffi/summary.json", + "crates/mobench/tests/fixtures/ci-artifact-root/android/summary.json", + ] { + let fixture_path = root.join(fixture); + let value: Value = serde_json::from_str( + &fs::read_to_string(&fixture_path).expect("read summary fixture"), + ) + .expect("parse summary fixture"); + + if let Err(errors) = validator.validate(&value) { + let messages: Vec<String> = errors.map(|e| e.to_string()).collect(); + panic!( + "{} failed summary schema validation: {}", + fixture_path.display(), + messages.join(" | ") + ); + } + } + } + + #[test] + fn basic_example_fixture_renders_stable_markdown_and_csv() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let fixture_path = root.join("examples/fixtures/basic/summary.json"); + let value: Value = + serde_json::from_str(&fs::read_to_string(&fixture_path).expect("read fixture")) + .expect("parse fixture"); + let summary = summary_report_from_value(&value).expect("parse summary report"); + + let markdown = render_markdown_summary(&summary); + assert_eq!( + markdown, + "\ +### Benchmark Summary + +- Generated: 2026-03-26T00:00:00Z +- Target: Android +- Function: multiple +- Iterations/Warmup: 5 / 1 +- Devices: Google Pixel 8-14.0, Samsung Galaxy S23-14.0 + +| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Google Pixel 8-14.0 | basic_benchmark::bench_fibonacci | 5 | 1 | 100.000ms | 500.000ms | - | - | - | - | - | +| Google Pixel 8-14.0 | basic_benchmark::bench_checksum | 5 | 1 | 145.000ms | 725.000ms | - | - | - | - | - | +| Samsung Galaxy S23-14.0 | basic_benchmark::bench_fibonacci | 5 | 1 | 94.000ms | 470.000ms | - | - | - | - | - | +| Samsung Galaxy S23-14.0 | basic_benchmark::bench_checksum | 5 | 1 | 136.000ms | 680.000ms | - | - | - | - | - | + +" + ); + + let csv = render_csv_summary(&summary); + assert_eq!( + csv, + "\ +device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,process_peak_memory_kb +Google Pixel 8-14.0,basic_benchmark::bench_fibonacci,5,100000000,100000000,105000000,95000000,105000000,,,,, +Google Pixel 8-14.0,basic_benchmark::bench_checksum,5,145000000,145000000,151000000,140000000,151000000,,,,, +Samsung Galaxy S23-14.0,basic_benchmark::bench_fibonacci,5,94000000,94000000,98000000,90000000,98000000,,,,, +Samsung Galaxy S23-14.0,basic_benchmark::bench_checksum,5,136000000,136000000,140000000,132000000,140000000,,,,, +" + ); + } + #[test] fn ci_function_slug_distinguishes_ambiguous_paths() { assert_ne!(ci_function_slug("a::b_c"), ci_function_slug("a_b::c")); diff --git a/docs/codebase/TESTING.md b/docs/codebase/TESTING.md index 8b6b12a..efaf926 100644 --- a/docs/codebase/TESTING.md +++ b/docs/codebase/TESTING.md @@ -22,6 +22,29 @@ Common coverage areas: - flamegraph viewer HTML generation - template regeneration and binding refresh behavior +Host-only tests must not require Android SDK, Xcode, BrowserStack credentials, +connected devices, or local native profiler binaries. These are the default +tests for pull requests and release-readiness checks. + +## Test taxonomy + +Use these labels when adding tests, workflows, or docs: + +- **Host-only**: runs with the Rust toolchain only. Examples: schema + validation, summary/CSV/Markdown rendering, CLI parsing, BrowserStack JSON + normalization, template text invariants, setup/teardown timing behavior. +- **Tool-gated**: requires local platform tools but no external service. + Examples: Android Gradle/NDK builds, iOS/Xcode packaging, local `adb`, + `simpleperf`, simulator-host `sample`, and plot rendering that requires + Python/matplotlib. +- **Service-gated**: requires credentials or remote infrastructure. Examples: + BrowserStack benchmark execution, GitHub PR comment publishing, and workflow + dispatch chains. + +Gate host-only tests in normal Rust CI. Keep tool-gated and service-gated tests +in named workflows or explicit local commands so contributors can tell whether +a failure is from code, local tooling, or a provider. + ## Fixture validation The repository keeps a lightweight fixture CI loop around `examples/ffi-benchmark`. diff --git a/docs/specs/production-readiness-roadmap.md b/docs/specs/production-readiness-roadmap.md index 1042800..9b52662 100644 --- a/docs/specs/production-readiness-roadmap.md +++ b/docs/specs/production-readiness-roadmap.md @@ -46,21 +46,21 @@ automate around. Audience: CLI users, CI adopters, maintainers. Checklist: -- [ ] Add schema validation tests for `summary.json`. -- [ ] Add schema validation tests for the CI contract. -- [ ] Add golden fixture tests for Markdown summaries. -- [ ] Add golden fixture tests for CSV outputs. -- [ ] Add golden fixture tests for plots where practical. -- [ ] Add golden fixture tests for profile summaries. -- [ ] Add CLI snapshot tests for high-value command output. -- [ ] Add BrowserStack response normalization regression tests. -- [ ] Test resource metric contracts: `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`. -- [ ] Test baseline and regression comparison behavior. -- [ ] Test profile manifest sections: `native_capture`, `semantic_profile`, `capture_metadata`. -- [ ] Test Android and iOS template generation invariants. -- [ ] Test setup, teardown, and per-iteration macro behavior. -- [ ] Keep fixture verification wired into CI. -- [ ] Label tests that require Android, iOS, BrowserStack, or profiling tools separately from pure host tests. +- [x] Add schema validation tests for `summary.json`. +- [x] Add schema validation tests for the CI contract. +- [x] Add golden fixture tests for Markdown summaries. +- [x] Add golden fixture tests for CSV outputs. +- [x] Add golden fixture tests for plots where practical. +- [x] Add golden fixture tests for profile summaries. +- [x] Add CLI snapshot tests for high-value command output. +- [x] Add BrowserStack response normalization regression tests. +- [x] Test resource metric contracts: `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`. +- [x] Test baseline and regression comparison behavior. +- [x] Test profile manifest sections: `native_capture`, `semantic_profile`, `capture_metadata`. +- [x] Test Android and iOS template generation invariants. +- [x] Test setup, teardown, and per-iteration macro behavior. +- [x] Keep fixture verification wired into CI. +- [x] Label tests that require Android, iOS, BrowserStack, or profiling tools separately from pure host tests. Verification signals: - Host-only tests run without mobile toolchains. From 004f19c722e61c0b48289aed9c8ce54da9f0de1b Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Sun, 26 Apr 2026 10:32:34 +0200 Subject: [PATCH 183/196] feat: complete gate 3 launch experience --- Cargo.lock | 86 ++++++++++++++ README.md | 102 +++++++++++++++++ RELEASE_NOTES.md | 12 ++ crates/mobench/Cargo.toml | 2 + crates/mobench/src/lib.rs | 115 +++++++++++++++++++ crates/mobench/src/profile.rs | 19 ++++ docs/diagrams/benchmark-lifecycle.mmd | 16 +++ docs/diagrams/browserstack-ci-lifecycle.mmd | 9 ++ docs/diagrams/crate-architecture.mmd | 9 ++ docs/diagrams/profiling-artifacts.mmd | 9 ++ docs/diagrams/sdk-cli-boundary.mmd | 20 ++++ docs/guides/README.md | 1 + docs/guides/examples.md | 120 ++++++++++++++++++++ docs/specs/production-readiness-roadmap.md | 34 +++--- 14 files changed, 537 insertions(+), 17 deletions(-) create mode 100644 docs/diagrams/benchmark-lifecycle.mmd create mode 100644 docs/diagrams/browserstack-ci-lifecycle.mmd create mode 100644 docs/diagrams/crate-architecture.mmd create mode 100644 docs/diagrams/profiling-artifacts.mmd create mode 100644 docs/diagrams/sdk-cli-boundary.mmd create mode 100644 docs/guides/examples.md diff --git a/Cargo.lock b/Cargo.lock index b063634..b609b27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1007,6 +1007,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1067,6 +1076,8 @@ dependencies = [ "tempfile", "time", "toml 0.8.23", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1111,6 +1122,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -1717,6 +1737,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1880,6 +1909,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.45" @@ -2062,9 +2100,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.35" @@ -2072,6 +2122,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -2286,6 +2366,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" diff --git a/README.md b/README.md index ce4d237..74e7794 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,14 @@ mobench provides a Rust API and a CLI for running benchmarks on real mobile devi For programmatic CI integrations, `mobench` exposes typed request/result types (`RunRequest`, `RunResult`, `DeviceSelection`, `Report`) via the crate API. +## Why mobench exists + +Rust performance work often stops at host benchmarks even when production code +runs through mobile FFI, mobile schedulers, mobile memory limits, and real device +thermal behavior. mobench keeps the benchmark definition in Rust, generates the +mobile harness, runs it locally or on BrowserStack, and writes stable artifacts +that CI and humans can compare. + ## How mobench works - `#[benchmark]` marks functions and registers them via `inventory` @@ -21,6 +29,99 @@ For programmatic CI integrations, `mobench` exposes typed request/result types ( - Mobile apps call `run_benchmark` via the generated bindings and return timing samples - The CLI collects results locally or from BrowserStack and writes summaries +## Workflow diagrams + +The Mermaid sources live under `docs/diagrams/` so the same diagrams can be +reused in launch posts and landing-page assets. + +### Crate architecture + +```mermaid +flowchart LR + user["Benchmark crate"] --> macro["mobench-macros\n#[benchmark]"] + macro --> registry["mobench-sdk registry\ninventory"] + registry --> runner["mobench-sdk runner\nBenchSpec -> BenchReport"] + runner --> templates["Generated Android/iOS runners"] + cli["mobench CLI"] --> builders["SDK builders"] + builders --> templates + cli --> reports["JSON / Markdown / CSV / plots"] + templates --> reports +``` + +### Benchmark lifecycle + +```mermaid +sequenceDiagram + participant Dev as Developer + participant CLI as mobench CLI + participant SDK as mobench-sdk + participant App as Generated mobile app + participant Device as Device or BrowserStack + participant Reports as Reports + + Dev->>CLI: cargo mobench run + CLI->>SDK: resolve crate and benchmark spec + SDK->>SDK: build native libraries and generate bindings + SDK->>App: embed bench_spec.json and templates + CLI->>Device: install/upload and start run + Device->>App: execute benchmark function + App->>CLI: emit BenchReport JSON + CLI->>Reports: write summary.json, summary.md, results.csv +``` + +### BrowserStack CI lifecycle + +```mermaid +flowchart TD + workflow["GitHub Actions"] --> resolve["Resolve device matrix"] + resolve --> build["Build APK or IPA/XCUITest"] + build --> upload["Upload artifacts to BrowserStack"] + upload --> run["Run benchmark on selected devices"] + run --> fetch["Fetch logs, reports, and metrics"] + fetch --> normalize["Normalize timing, CPU, and memory"] + normalize --> outputs["summary.json\nsummary.md\nresults.csv\nplots"] + outputs --> pr["Optional PR comment/check run"] +``` + +### Profiling artifact lifecycle + +```mermaid +flowchart LR + run["profile run"] --> manifest["profile.json\nnative_capture\nsemantic_profile\ncapture_metadata"] + run --> raw["raw capture\nsimpleperf or sample"] + raw --> processed["processed stacks\nstacks.folded\nnative-report.txt"] + processed --> viewer["flamegraph.html\nfull and focused SVGs"] + manifest --> summary["summary.md"] + manifest --> semantic["artifacts/semantic/phases.json"] + viewer --> diff["profile diff\nbaseline vs candidate"] + summary --> diff +``` + +### SDK versus CLI responsibilities + +```mermaid +flowchart TB + subgraph SDK["mobench-sdk"] + timing["timing harness"] + registry["benchmark registry"] + builders["Android/iOS builders"] + codegen["template/codegen"] + ffi["FFI-safe types"] + end + + subgraph CLI["mobench CLI"] + config["config and project resolution"] + orchestration["build/run/profile orchestration"] + providers["BrowserStack and local providers"] + reporting["summary, plots, PR reports"] + end + + SDK --> CLI + CLI --> SDK + user["Downstream benchmark crate"] --> SDK + ci["CI workflow"] --> CLI +``` + ## Workspace crates - `crates/mobench` ([mobench](https://crates.io/crates/mobench)): CLI tool that builds, runs, and fetches benchmarks @@ -212,6 +313,7 @@ CLI flags override config file values when provided. ## Project docs - `docs/guides/README.md`: guide index for setup, integration, BrowserStack CI, fetch flows, and troubleshooting +- `docs/guides/examples.md`: concrete examples for minimal, setup/teardown, FFI, CI, profiling, and programmatic SDK usage - `docs/guides/sdk-integration.md`: SDK integration guide - `docs/guides/build.md`: build prerequisites and troubleshooting - `docs/guides/profiling.md`: local native profiling guide, artifact layout, and symbol requirements diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 378837f..ff184a6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -11,6 +11,18 @@ Crates.io release history: - [mobench-sdk](https://crates.io/crates/mobench-sdk) - [mobench-macros](https://crates.io/crates/mobench-macros) +## Unreleased + +- Added production-readiness documentation for public API boundaries, semver + expectations, feature flags, MSRV, release checks, examples, and launch + diagrams. +- Added Rust quality CI covering rustfmt, clippy, rustdoc, tests, and publish + dry-runs for the published crates. +- Added opt-in structured CLI tracing through `--verbose` or `MOBENCH_LOG`, plus + an explicit `doctor` MSRV check. +- Added host-only fixture contract coverage for example summary schemas and + stable Markdown/CSV rendering. + ## Support Policy - `v0.1.35` is the current supported release. diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 3851d80..6ee8a6c 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -51,6 +51,8 @@ sha2 = "0.10" comfy-table = "7" tempfile = "3" inferno = { version = "0.12", default-features = false } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } [dev-dependencies] inventory = "0.3" diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index c067d04..1634b27 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -135,6 +135,8 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; +use tracing::{debug, info}; +use tracing_subscriber::EnvFilter; use browserstack::{BrowserStackAuth, BrowserStackClient}; @@ -1151,11 +1153,34 @@ enum RemoteRun { }, } +fn init_tracing(verbose: bool) { + let filter = env::var("MOBENCH_LOG").unwrap_or_else(|_| { + if verbose { + "mobench=debug".to_string() + } else { + "warn".to_string() + } + }); + let filter = EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("warn")); + + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .without_time() + .try_init(); +} + pub fn run() -> Result<()> { // Load dotenv globally as a baseline for commands that don't resolve a layout // (e.g. fetch, doctor, ci run). Layout-aware commands reload from the resolved root. load_dotenv_global(); let cli = Cli::parse(); + init_tracing(cli.verbose); + debug!( + dry_run = cli.dry_run, + non_interactive = cli.non_interactive, + "parsed CLI arguments" + ); match cli.command { Command::Run { target, @@ -1211,9 +1236,26 @@ pub fn run() -> Result<()> { )?; let summary_paths = resolve_summary_paths(output.as_deref())?; let output_dir = layout.output_dir.clone(); + let run_span = tracing::info_span!( + "benchmark_run", + target = ?spec.target, + function = %spec.function, + iterations = spec.iterations, + warmup = spec.warmup, + devices = spec.devices.len(), + local_only, + release + ); + let _run_span = run_span.enter(); + info!( + output_dir = %output_dir.display(), + summary_json = %summary_paths.json.display(), + "resolved benchmark run" + ); // Validate device specs early to catch errors before building (C2: Device validation) if !spec.devices.is_empty() && !local_only { + info!(device_count = spec.devices.len(), "validating device specs"); if cli.dry_run { println!("[dry-run] Skipping BrowserStack device validation"); } else if let Ok(creds) = @@ -1354,6 +1396,7 @@ pub fn run() -> Result<()> { } else { match spec.target { MobileTarget::Android => { + info!("building Android artifacts"); if progress { println!("[2/4] Building Android APK..."); } else { @@ -1379,6 +1422,7 @@ pub fn run() -> Result<()> { } Some(MobileArtifacts::Android { apk }) } else { + info!("uploading Android artifacts to BrowserStack"); if progress { println!("[3/4] Uploading to BrowserStack..."); } @@ -1391,6 +1435,7 @@ pub fn run() -> Result<()> { } } MobileTarget::Ios => { + info!("building iOS artifacts"); if progress { println!("[2/4] Building iOS xcframework..."); } else { @@ -1417,6 +1462,7 @@ pub fn run() -> Result<()> { println!("[dry-run] Skipping BrowserStack upload/run for iOS"); } } else { + info!("uploading iOS artifacts to BrowserStack"); if ios_xcuitest.as_ref().is_some_and(|artifacts| { uses_managed_ios_xcuitest_artifacts(&layout, artifacts) }) { @@ -1598,6 +1644,7 @@ pub fn run() -> Result<()> { } run_summary.summary = build_summary(&run_summary)?; + info!("writing benchmark summaries"); write_summary( &run_summary, &summary_paths, @@ -7910,6 +7957,7 @@ fn collect_prereq_checks(target: SdkTarget) -> Vec<PrereqCheck> { let mut checks: Vec<PrereqCheck> = Vec::new(); checks.push(check_cargo()); checks.push(check_rustup()); + checks.push(check_rustc_msrv()); match target { SdkTarget::Android => { @@ -7939,6 +7987,7 @@ fn collect_prereq_checks(target: SdkTarget) -> Vec<PrereqCheck> { } const DEFAULT_ANDROID_DOCTOR_RUST_TARGETS: &[&str] = &["aarch64-linux-android"]; +const WORKSPACE_MSRV: &str = "1.85"; fn extend_android_prereq_checks(checks: &mut Vec<PrereqCheck>) { checks.push(check_android_ndk_home()); @@ -8112,6 +8161,57 @@ fn check_rustup() -> PrereqCheck { } } +fn check_rustc_msrv() -> PrereqCheck { + match command_version_line("rustc", &["--version"]) { + Some(version) if rustc_version_meets_msrv(&version, WORKSPACE_MSRV) => PrereqCheck { + name: "rustc MSRV".to_string(), + passed: true, + detail: Some(format!("{version} (requires >= {WORKSPACE_MSRV})")), + fix_hint: None, + }, + Some(version) => PrereqCheck { + name: "rustc MSRV".to_string(), + passed: false, + detail: Some(format!("{version} (requires >= {WORKSPACE_MSRV})")), + fix_hint: Some(format!( + "Update Rust: rustup update stable (MSRV {WORKSPACE_MSRV})" + )), + }, + None => PrereqCheck { + name: "rustc MSRV".to_string(), + passed: false, + detail: Some("could not run rustc --version".to_string()), + fix_hint: Some("Install Rust: https://rustup.rs".to_string()), + }, + } +} + +fn rustc_version_meets_msrv(version_line: &str, msrv: &str) -> bool { + let Some(actual) = parse_rust_version(version_line) else { + return false; + }; + let Some(required) = parse_rust_version(msrv) else { + return false; + }; + actual >= required +} + +fn parse_rust_version(input: &str) -> Option<(u32, u32, u32)> { + let version = input + .split_whitespace() + .find(|part| part.chars().next().is_some_and(|ch| ch.is_ascii_digit()))?; + let mut parts = version.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts + .next() + .and_then(|part| part.split('-').next()) + .unwrap_or("0") + .parse() + .ok()?; + Some((major, minor, patch)) +} + fn check_android_ndk_home() -> PrereqCheck { match env::var("ANDROID_NDK_HOME") { Ok(path) if !path.is_empty() => { @@ -8926,6 +9026,21 @@ project = "proj" ); } + #[test] + fn rustc_msrv_parser_handles_stable_and_prerelease_versions() { + assert_eq!( + parse_rust_version("rustc 1.95.0 (59807616e 2026-04-14)"), + Some((1, 95, 0)) + ); + assert_eq!( + parse_rust_version("rustc 1.85.0-beta.1 (example)"), + Some((1, 85, 0)) + ); + assert!(rustc_version_meets_msrv("rustc 1.85.0", WORKSPACE_MSRV)); + assert!(rustc_version_meets_msrv("rustc 1.95.0", WORKSPACE_MSRV)); + assert!(!rustc_version_meets_msrv("rustc 1.84.1", WORKSPACE_MSRV)); + } + #[test] fn ci_run_parses_required_args_with_defaults() { let cli = Cli::parse_from([ diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 5fd9168..0ffa431 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -8,6 +8,7 @@ use std::fs::File; use std::io::BufWriter; use std::path::{Path, PathBuf}; use std::process::Command; +use tracing::info; use crate::{ DevicePlatform, MobileTarget, ProjectLayoutOptions, ResolvedMatrixDevice, RunSpec, @@ -586,13 +587,25 @@ where let run_id = build_run_id(args.target, &args.function); let run_output_dir = args.output_dir.join(&run_id); let mut manifest = build_capture_plan(args, &target, &run_output_dir)?; + let profile_span = tracing::info_span!( + "profile_run", + target = ?args.target, + provider = ?args.provider, + backend = ?target.backend, + function = %args.function, + dry_run + ); + let _profile_span = profile_span.enter(); + info!(output_dir = %run_output_dir.display(), "resolved profile run"); let execution_result = if dry_run { + info!("planning profile capture only"); manifest.capture_metadata.warnings.push( "dry-run enabled; capture planning stopped before execution and recorded the planned artifact contract only" .into(), ); Ok(()) } else { + info!("executing profile capture"); execute(args, &target, &mut manifest) }; @@ -603,6 +616,7 @@ where || manifest.semantic_profile.status != SemanticCaptureStatus::Planned; if should_persist_outputs { + info!("writing profile session outputs"); write_profile_session_outputs(args, &run_output_dir, &manifest)?; } execution_result?; @@ -1702,6 +1716,11 @@ pub fn cmd_profile_diff(args: &ProfileDiffArgs) -> Result<()> { let diff_run_dir = args.output_dir.join(&diff_run_id); let processed_root = diff_run_dir.join("artifacts/processed"); std::fs::create_dir_all(&processed_root)?; + info!( + output_dir = %diff_run_dir.display(), + normalize = args.normalize, + "building profile diff" + ); let full_mode = build_profile_diff_mode( baseline_run_dir, diff --git a/docs/diagrams/benchmark-lifecycle.mmd b/docs/diagrams/benchmark-lifecycle.mmd new file mode 100644 index 0000000..01b550e --- /dev/null +++ b/docs/diagrams/benchmark-lifecycle.mmd @@ -0,0 +1,16 @@ +sequenceDiagram + participant Dev as Developer + participant CLI as mobench CLI + participant SDK as mobench-sdk + participant App as Generated mobile app + participant Device as Device or BrowserStack + participant Reports as Reports + + Dev->>CLI: cargo mobench run + CLI->>SDK: resolve crate and benchmark spec + SDK->>SDK: build native libraries and generate bindings + SDK->>App: embed bench_spec.json and templates + CLI->>Device: install/upload and start run + Device->>App: execute benchmark function + App->>CLI: emit BenchReport JSON + CLI->>Reports: write summary.json, summary.md, results.csv diff --git a/docs/diagrams/browserstack-ci-lifecycle.mmd b/docs/diagrams/browserstack-ci-lifecycle.mmd new file mode 100644 index 0000000..028e3d9 --- /dev/null +++ b/docs/diagrams/browserstack-ci-lifecycle.mmd @@ -0,0 +1,9 @@ +flowchart TD + workflow["GitHub Actions"] --> resolve["Resolve device matrix"] + resolve --> build["Build APK or IPA/XCUITest"] + build --> upload["Upload artifacts to BrowserStack"] + upload --> run["Run benchmark on selected devices"] + run --> fetch["Fetch logs, reports, and metrics"] + fetch --> normalize["Normalize timing, CPU, and memory"] + normalize --> outputs["summary.json\nsummary.md\nresults.csv\nplots"] + outputs --> pr["Optional PR comment/check run"] diff --git a/docs/diagrams/crate-architecture.mmd b/docs/diagrams/crate-architecture.mmd new file mode 100644 index 0000000..08fd1a5 --- /dev/null +++ b/docs/diagrams/crate-architecture.mmd @@ -0,0 +1,9 @@ +flowchart LR + user["Benchmark crate"] --> macro["mobench-macros\n#[benchmark]"] + macro --> registry["mobench-sdk registry\ninventory"] + registry --> runner["mobench-sdk runner\nBenchSpec -> BenchReport"] + runner --> templates["Generated Android/iOS runners"] + cli["mobench CLI"] --> builders["SDK builders"] + builders --> templates + cli --> reports["JSON / Markdown / CSV / plots"] + templates --> reports diff --git a/docs/diagrams/profiling-artifacts.mmd b/docs/diagrams/profiling-artifacts.mmd new file mode 100644 index 0000000..292f3a9 --- /dev/null +++ b/docs/diagrams/profiling-artifacts.mmd @@ -0,0 +1,9 @@ +flowchart LR + run["profile run"] --> manifest["profile.json\nnative_capture\nsemantic_profile\ncapture_metadata"] + run --> raw["raw capture\nsimpleperf or sample"] + raw --> processed["processed stacks\nstacks.folded\nnative-report.txt"] + processed --> viewer["flamegraph.html\nfull and focused SVGs"] + manifest --> summary["summary.md"] + manifest --> semantic["artifacts/semantic/phases.json"] + viewer --> diff["profile diff\nbaseline vs candidate"] + summary --> diff diff --git a/docs/diagrams/sdk-cli-boundary.mmd b/docs/diagrams/sdk-cli-boundary.mmd new file mode 100644 index 0000000..ed649d8 --- /dev/null +++ b/docs/diagrams/sdk-cli-boundary.mmd @@ -0,0 +1,20 @@ +flowchart TB + subgraph SDK["mobench-sdk"] + timing["timing harness"] + registry["benchmark registry"] + builders["Android/iOS builders"] + codegen["template/codegen"] + ffi["FFI-safe types"] + end + + subgraph CLI["mobench CLI"] + config["config and project resolution"] + orchestration["build/run/profile orchestration"] + providers["BrowserStack and local providers"] + reporting["summary, plots, PR reports"] + end + + SDK --> CLI + CLI --> SDK + user["Downstream benchmark crate"] --> SDK + ci["CI workflow"] --> CLI diff --git a/docs/guides/README.md b/docs/guides/README.md index 6ab7eda..11f96b0 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -3,6 +3,7 @@ Current user-facing guides live here instead of the repository root. - [sdk-integration.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/sdk-integration.md): add `mobench-sdk` to an existing Rust crate and wire up benchmark execution +- [examples.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/examples.md): minimal, setup/teardown, FFI, CI, profiling, and programmatic SDK examples - [build.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/build.md): prerequisite checks, Android/iOS build flows, and build troubleshooting - [profiling.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/profiling.md): local native profiling workflows, artifact layout, symbol requirements, and flamegraph tradeoffs - [testing.md](/Users/dcbuilder/Code/world/mobile-bench-rs/docs/guides/testing.md): host tests, device workflows, profiling smoke checks, and workflow-level validation diff --git a/docs/guides/examples.md b/docs/guides/examples.md new file mode 100644 index 0000000..1251bfc --- /dev/null +++ b/docs/guides/examples.md @@ -0,0 +1,120 @@ +# Examples + +Updated: 2026-04-26 + +Use these examples to pick the smallest starting point for a mobench +integration. + +## Minimal Benchmark Crate + +Path: `examples/basic-benchmark` + +Use this when you only need Rust benchmark functions and SDK registry execution. + +```bash +cargo test -p basic-benchmark +cargo mobench list --crate-path examples/basic-benchmark +cargo mobench run --target android --function basic_benchmark::fibonacci --local-only +``` + +## Setup And Teardown Benchmarks + +Use `#[benchmark(setup = setup_fn)]` when expensive fixture creation should not +be timed as benchmark work. + +```rust +use mobench_sdk::benchmark; + +struct ProofInput { + bytes: Vec<u8>, +} + +fn setup_proof() -> ProofInput { + ProofInput { bytes: vec![42; 4096] } +} + +#[benchmark(setup = setup_proof)] +fn verify_proof(input: &ProofInput) { + std::hint::black_box(input.bytes.len()); +} +``` + +Use per-iteration setup when each measured iteration must own fresh input: + +```rust +use mobench_sdk::benchmark; + +fn setup_message() -> Vec<u8> { + vec![7; 1024] +} + +#[benchmark(setup_per_iter = setup_message)] +fn hash_message(mut message: Vec<u8>) { + message.reverse(); + std::hint::black_box(message); +} +``` + +## FFI And Custom Types + +Path: `examples/ffi-benchmark` + +Use this when your benchmark crate already exposes UniFFI bindings or needs +custom FFI-facing request/error types. + +```bash +cargo test -p ffi-benchmark +cargo mobench list --crate-path examples/ffi-benchmark +cargo mobench run --target android --function ffi_benchmark::fibonacci --local-only +``` + +## CI-Only Benchmark Workflow + +Use `ci run` when a workflow should produce stable machine-readable outputs. + +```bash +cargo mobench ci run \ + --target android \ + --function sample_fns::fibonacci \ + --local-only \ + --plots auto +``` + +Outputs are written under `target/mobench/ci/`: + +- `summary.json` +- `summary.md` +- `results.csv` +- `plots/*.svg` when plot rendering is enabled + +## Profiling Workflow + +Use local profiling when you need native stack artifacts and flamegraphs. + +```bash +cargo mobench profile run \ + --target android \ + --provider local \ + --backend android-native \ + --crate-path crates/sample-fns \ + --function sample_fns::fibonacci + +cargo mobench profile summarize \ + --profile target/mobench/profile/profile.json +``` + +## Programmatic SDK Usage + +Use the SDK directly when embedding mobench into another Rust tool. + +```rust +use mobench_sdk::{BenchSpec, run_benchmark}; + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let spec = BenchSpec::new("sample_fns::fibonacci", 100, 10)?; + let report = run_benchmark(spec)?; + + println!("median: {} ns", report.median_ns()); + Ok(()) +} +``` diff --git a/docs/specs/production-readiness-roadmap.md b/docs/specs/production-readiness-roadmap.md index 9b52662..13c0092 100644 --- a/docs/specs/production-readiness-roadmap.md +++ b/docs/specs/production-readiness-roadmap.md @@ -75,23 +75,23 @@ Goal: make mobench easy to adopt, debug, explain, and promote publicly. Audience: CLI users, library adopters, public launch readers. Checklist: -- [ ] Add structured tracing/logging to CLI flows. -- [ ] Add progress spans for build, package, upload, poll, fetch, summarize, and profile steps. -- [ ] Improve human-readable diagnostics with likely fixes. -- [ ] Expand `doctor` coverage for Android, iOS, BrowserStack, and profile prerequisites. -- [ ] Add examples for minimal benchmark usage. -- [ ] Add examples for setup/teardown benchmarks. -- [ ] Add examples for FFI/custom type benchmarks. -- [ ] Add examples for CI-only benchmark workflows. -- [ ] Add examples for profiling workflows. -- [ ] Add examples for programmatic SDK usage. -- [ ] Add README graphics for crate architecture. -- [ ] Add README graphics for benchmark execution lifecycle. -- [ ] Add README graphics for BrowserStack CI lifecycle. -- [ ] Add README graphics for local profiling artifact lifecycle. -- [ ] Add README graphics for SDK versus CLI responsibility boundaries. -- [ ] Write concise launch copy explaining why mobench exists. -- [ ] Keep release notes and migration notes current. +- [x] Add structured tracing/logging to CLI flows. +- [x] Add progress spans for build, package, upload, poll, fetch, summarize, and profile steps. +- [x] Improve human-readable diagnostics with likely fixes. +- [x] Expand `doctor` coverage for Android, iOS, BrowserStack, and profile prerequisites. +- [x] Add examples for minimal benchmark usage. +- [x] Add examples for setup/teardown benchmarks. +- [x] Add examples for FFI/custom type benchmarks. +- [x] Add examples for CI-only benchmark workflows. +- [x] Add examples for profiling workflows. +- [x] Add examples for programmatic SDK usage. +- [x] Add README graphics for crate architecture. +- [x] Add README graphics for benchmark execution lifecycle. +- [x] Add README graphics for BrowserStack CI lifecycle. +- [x] Add README graphics for local profiling artifact lifecycle. +- [x] Add README graphics for SDK versus CLI responsibility boundaries. +- [x] Write concise launch copy explaining why mobench exists. +- [x] Keep release notes and migration notes current. Verification signals: - A new user can follow the README from install to first result. From b30e35e7590d33265d52d8d0c740d841a33a52a0 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Sun, 26 Apr 2026 10:33:06 +0200 Subject: [PATCH 184/196] docs: evaluate later hardening priorities --- docs/specs/production-readiness-roadmap.md | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/specs/production-readiness-roadmap.md b/docs/specs/production-readiness-roadmap.md index 13c0092..f86d72f 100644 --- a/docs/specs/production-readiness-roadmap.md +++ b/docs/specs/production-readiness-roadmap.md @@ -115,6 +115,53 @@ announcement unless they expose real launch risk. - [ ] Add machine-readable trace/event output for CI debugging. - [ ] Prepare landing-page-specific assets from README diagrams. +### Later Hardening Evaluation + +Updated: 2026-04-26 + +This bucket should be sequenced by launch risk rather than implemented as one +large polish pass. The current repository has CI caching and fixture cache keys, +but no dedicated `benches/`, fuzz target, `cargo-semver-checks`, or +`cargo-deny` policy yet. + +P0 after first public promotion: +- Add `cargo-semver-checks` for `mobench-sdk` and `mobench-macros`. + This protects the library-facing surface that adopters will depend on first. +- Add `cargo-deny` with explicit license/advisory/source policy. + This is low product risk but high trust value before broader third-party use. +- Add host benchmarks for parser, reporting, profile manifest rendering, and + BrowserStack log extraction. These give a baseline before tuning or + parallelizing anything. + +P1 once adoption feedback identifies slow paths: +- Profile CLI hot paths with real examples: config resolution, template + generation, report rendering, profile diff generation, and BrowserStack + artifact normalization. +- Improve APK/IPA build caching where profiling shows repeated template, + binding, or packaging work dominates local iteration time. +- Parallelize independent build, fetch, and report steps only after benchmarks + make the current bottlenecks visible. +- Add fuzz or property tests for config and device matrix parsing after the + host benchmark harness exists, so parser behavior and parser cost are both + tracked. + +P2 launch-asset and dependency-footprint follow-up: +- Prepare landing-page-specific assets from README diagrams once the landing + page visual direction is chosen. +- Consider narrower crate features after `cargo-deny` and host benchmarks show + which dependencies materially affect compile time, binary size, or policy + surface. +- Add machine-readable trace/event output for CI debugging when there is a + concrete downstream consumer for it; the current `MOBENCH_LOG` tracing is + enough for near-term human debugging. + +Recommended first hardening PR: +1. Add `cargo-semver-checks`, `cargo-deny`, and their CI jobs. +2. Add a small Criterion suite for config parsing, summary rendering, CSV + rendering, profile manifest loading, and BrowserStack log extraction. +3. Use the benchmark results to decide whether build caching or parallelization + should come next. + ## Recommended Order 1. Gate 1 crate hygiene, because this reduces adoption risk. From 2dfd3002df719beed608299e6090b81efa3ae761 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Sun, 26 Apr 2026 12:24:58 +0200 Subject: [PATCH 185/196] chore: split cli modules and add hardening checks --- .github/workflows/rust.yml | 17 + Cargo.lock | 266 +++- crates/mobench/Cargo.toml | 9 + crates/mobench/benches/host_contracts.rs | 123 ++ crates/mobench/src/cli.rs | 823 ++++++++++ crates/mobench/src/doctor.rs | 736 +++++++++ crates/mobench/src/lib.rs | 1607 +------------------- deny.toml | 40 + docs/codebase/ARCHITECTURE.md | 6 +- docs/codebase/STRUCTURE.md | 10 +- docs/specs/production-readiness-roadmap.md | 29 +- 11 files changed, 2097 insertions(+), 1569 deletions(-) create mode 100644 crates/mobench/benches/host_contracts.rs create mode 100644 crates/mobench/src/cli.rs create mode 100644 crates/mobench/src/doctor.rs create mode 100644 deny.toml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index f6987ad..38476cf 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -32,12 +32,29 @@ jobs: - name: Clippy run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - name: Install cargo-deny + uses: taiki-e/install-action@cargo-deny + + - name: Dependency policy + run: cargo deny check advisories bans licenses sources + + - name: Install cargo-semver-checks + uses: taiki-e/install-action@cargo-semver-checks + + - name: Public API compatibility + run: | + cargo semver-checks check-release --package mobench-sdk --baseline-version 0.1.35 + cargo semver-checks check-release --package mobench --baseline-version 0.1.35 + - name: Rustdoc run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps - name: Test run: cargo test --workspace + - name: Host benchmark smoke + run: cargo bench -p mobench --features bench-support --bench host_contracts -- --test + - name: Publish dry run run: | cargo publish --dry-run -p mobench-macros diff --git a/Cargo.lock b/Cargo.lock index b609b27..001a182 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,6 +25,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -225,9 +231,9 @@ checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "camino" @@ -261,6 +267,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.49" @@ -283,6 +295,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.5.53" @@ -349,6 +388,67 @@ dependencies = [ "libc", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.29.0" @@ -372,6 +472,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -427,6 +533,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "equivalent" version = "1.0.2" @@ -610,6 +722,17 @@ dependencies = [ "scroll", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -622,6 +745,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.4.0" @@ -895,6 +1024,17 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -910,6 +1050,15 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1062,6 +1211,7 @@ dependencies = [ "anyhow", "clap", "comfy-table", + "criterion", "dotenvy", "inferno", "inventory", @@ -1172,9 +1322,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-format" @@ -1238,6 +1388,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1291,6 +1447,34 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1432,6 +1616,26 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1579,9 +1783,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -1600,6 +1804,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "sample-fns" version = "0.1.35" @@ -1920,9 +2133,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -1935,15 +2148,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -1959,6 +2172,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -2378,6 +2601,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2514,6 +2747,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 6ee8a6c..3cf6d47 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -24,6 +24,9 @@ targets = ["x86_64-unknown-linux-gnu"] default-run = "mobench" +[features] +bench-support = [] + # Dual binary targets allow the tool to work as both: # - `mobench run ...` (direct invocation) # - `cargo mobench run ...` (cargo subcommand) @@ -55,6 +58,12 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } [dev-dependencies] +criterion = "0.5" inventory = "0.3" sample-fns = { path = "../sample-fns" } jsonschema = "0.18" + +[[bench]] +name = "host_contracts" +harness = false +required-features = ["bench-support"] diff --git a/crates/mobench/benches/host_contracts.rs b/crates/mobench/benches/host_contracts.rs new file mode 100644 index 0000000..530bc91 --- /dev/null +++ b/crates/mobench/benches/host_contracts.rs @@ -0,0 +1,123 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use mobench::bench_support; + +const RUN_CONFIG: &str = r#" +target = "android" +function = "sample_fns::fibonacci" +iterations = 100 +warmup = 10 +device_matrix = "device-matrix.yaml" +device_tags = ["default", "high-spec"] + +[browserstack] +app_automate_username = "${BROWSERSTACK_USERNAME}" +app_automate_access_key = "${BROWSERSTACK_ACCESS_KEY}" +project = "mobench" +"#; + +const DEVICE_MATRIX: &str = r#" +devices: + - name: Google Pixel 8 + os: android + os_version: "14.0" + tags: ["default", "high-spec"] + - name: iPhone 16 Pro + os: ios + os_version: "18" + tags: ["default", "high-spec"] +"#; + +const SUMMARY_JSON: &str = include_str!("../../../examples/fixtures/basic/summary.json"); + +const PROFILE_JSON: &str = r#" +{ + "run_id": "android-sample-fns--fibonacci", + "target": "android", + "function": "sample_fns::fibonacci", + "provider": "local", + "backend": "android-native", + "format": "both", + "native_capture": { + "status": "captured", + "raw_artifacts": [], + "processed_artifacts": [], + "symbolization": { + "status": "captured", + "tool": "simpleperf", + "resolved_frames": 42, + "unresolved_frames": 3, + "notes": [] + }, + "viewer_hint": "Open artifacts/processed/flamegraph.html" + }, + "semantic_profile": { + "status": "captured", + "phases": [ + { + "name": "fibonacci", + "duration_ns": 100000, + "percent_total": 100 + } + ], + "spans_path": "artifacts/semantic/phases.json", + "harness_timeline": [], + "timeline_path": "artifacts/semantic/timeline.json" + }, + "capture_metadata": { + "device": "Google Pixel 8", + "os": "Android 14", + "sample_duration_secs": 10, + "benchmark_iterations": 100, + "benchmark_warmup": 10, + "warmup_mode": "warm", + "capture_method": "simpleperf/app_profiler.py", + "warnings": [] + } +} +"#; + +const BROWSERSTACK_LOGS: &str = r#" +04-26 10:00:00.000 I/BenchRunner: starting benchmark +04-26 10:00:01.000 I/BenchRunner: BENCH_JSON {"function":"sample_fns::fibonacci","samples":[{"duration_ns":95000000},{"duration_ns":98000000}],"mean_ns":96500000} +04-26 10:00:02.000 I/BenchRunner: finished benchmark +"#; + +fn host_contract_benchmarks(c: &mut Criterion) { + c.bench_function("config/parse_run_config", |b| { + b.iter(|| bench_support::parse_run_config(RUN_CONFIG).expect("parse run config")) + }); + + c.bench_function("config/parse_device_matrix", |b| { + b.iter(|| bench_support::parse_device_matrix(DEVICE_MATRIX).expect("parse matrix")) + }); + + c.bench_function("summary/render_markdown", |b| { + b.iter(|| { + bench_support::render_summary_markdown_from_json(SUMMARY_JSON) + .expect("render summary markdown") + }) + }); + + c.bench_function("summary/render_csv", |b| { + b.iter(|| { + bench_support::render_summary_csv_from_json(SUMMARY_JSON).expect("render summary csv") + }) + }); + + c.bench_function("profile/render_markdown", |b| { + b.iter(|| { + bench_support::render_profile_markdown_from_json(PROFILE_JSON) + .expect("render profile markdown") + }) + }); + + c.bench_function("browserstack/extract_results", |b| { + b.iter(|| { + bench_support::extract_browserstack_results_from_logs(BROWSERSTACK_LOGS) + .expect("extract benchmark results") + }) + }); +} + +criterion_group!(benches, host_contract_benchmarks); +criterion_main!(benches); diff --git a/crates/mobench/src/cli.rs b/crates/mobench/src/cli.rs new file mode 100644 index 0000000..6124f36 --- /dev/null +++ b/crates/mobench/src/cli.rs @@ -0,0 +1,823 @@ +use clap::{Args, Parser, Subcommand, ValueEnum}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::{plots, profile}; + +/// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. +#[derive(Parser, Debug)] +#[command(name = "mobench", author, version, about = "Mobile Rust benchmarking orchestrator", long_about = None)] +pub(crate) struct Cli { + /// Print what would be done without actually doing it + #[arg(long, global = true)] + pub(crate) dry_run: bool, + + /// Print verbose output including all commands + #[arg(long, short = 'v', global = true)] + pub(crate) verbose: bool, + + /// Assume yes to prompts and allow overwriting files + #[arg(long, global = true)] + pub(crate) yes: bool, + + /// Disable interactive prompts (fail instead) + #[arg(long, global = true)] + pub(crate) non_interactive: bool, + + #[command(subcommand)] + pub(crate) command: Command, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum Command { + /// Run benchmarks locally or on BrowserStack devices. + /// + /// This is a single-command flow that: + /// 1. Builds Rust libraries for the target platform + /// 2. Packages mobile apps (APK/IPA) automatically + /// 3. Uploads to BrowserStack when devices are requested + /// 4. Schedules the benchmark run when using BrowserStack + /// 5. Fetches results when the provider returns them + /// + /// For iOS, IPA and XCUITest packages are created automatically unless + /// you provide --ios-app and --ios-test-suite to override. + Run { + #[arg(long, value_enum)] + target: MobileTarget, + #[arg(long, help = "Fully-qualified Rust function to benchmark")] + function: String, + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option<PathBuf>, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + crate_path: Option<PathBuf>, + #[arg(long, default_value_t = 100)] + iterations: u32, + #[arg(long, default_value_t = 10)] + warmup: u32, + #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] + devices: Vec<String>, + #[arg(long, help = "Device matrix YAML file to load device names from")] + device_matrix: Option<PathBuf>, + #[arg( + long, + value_delimiter = ',', + help = "Device tags to select from the device matrix (comma-separated or repeatable)" + )] + device_tags: Vec<String>, + #[arg(long, help = "Optional path to config file")] + config: Option<PathBuf>, + #[arg(long, help = "Optional output path for JSON report")] + output: Option<PathBuf>, + #[arg(long, help = "Write CSV summary alongside JSON")] + summary_csv: bool, + #[arg( + long, + help = "Enable CI mode (job summary, optional JUnit, regression exit codes)" + )] + ci: bool, + #[arg(long, help = "Baseline summary source (path|url|artifact:<path>)")] + baseline: Option<String>, + #[arg( + long, + default_value_t = 5.0, + help = "Regression threshold percentage when comparing to baseline" + )] + regression_threshold_pct: f64, + #[arg(long, help = "Write JUnit XML report to the given path")] + junit: Option<PathBuf>, + #[arg(long, help = "Skip mobile builds and only run the host harness")] + local_only: bool, + #[arg( + long, + help = "Build in release mode (recommended for BrowserStack to reduce APK size and upload time)" + )] + release: bool, + #[arg( + long, + help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" + )] + ios_app: Option<PathBuf>, + #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] + ios_test_suite: Option<PathBuf>, + #[arg( + long, + hide = true, + help = "Deprecated compatibility flag for generated XCUITest harness timeout" + )] + ios_completion_timeout_secs: Option<u64>, + #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] + fetch: bool, + #[arg(long, default_value = "target/browserstack")] + fetch_output_dir: PathBuf, + #[arg(long, default_value_t = 5)] + fetch_poll_interval_secs: u64, + #[arg(long, default_value_t = 300)] + fetch_timeout_secs: u64, + #[arg(long, help = "Show simplified step-by-step progress output")] + progress: bool, + }, + /// Scaffold a base config file for the CLI. + Init { + #[arg(long, default_value = "bench-config.toml")] + output: PathBuf, + #[arg(long, value_enum, default_value_t = MobileTarget::Android)] + target: MobileTarget, + }, + /// Generate a sample device matrix file. + Plan { + #[arg(long, default_value = "device-matrix.yaml")] + output: PathBuf, + }, + /// Validate run configuration and associated files. + Config { + #[command(subcommand)] + command: ConfigCommand, + }, + /// Validate local + CI prerequisites and configuration. + Doctor { + #[arg(long, value_enum, default_value_t = SdkTarget::Both)] + target: SdkTarget, + #[arg(long, help = "Optional path to run config file to validate")] + config: Option<PathBuf>, + #[arg(long, help = "Optional path to device matrix YAML file to validate")] + device_matrix: Option<PathBuf>, + #[arg( + long, + value_delimiter = ',', + help = "Device tags to select from the device matrix (comma-separated or repeatable)" + )] + device_tags: Vec<String>, + #[arg( + long, + default_value_t = true, + action = clap::ArgAction::Set, + num_args = 0..=1, + default_missing_value = "true", + help = "Validate BrowserStack credentials" + )] + browserstack: bool, + #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] + format: CheckOutputFormat, + }, + /// CI helpers (workflow and action scaffolding). + Ci { + #[command(subcommand)] + command: CiCommand, + }, + /// Fetch BrowserStack build artifacts (logs, session JSON) for CI. + Fetch { + #[arg(long, value_enum)] + target: MobileTarget, + #[arg(long)] + build_id: String, + #[arg(long, default_value = "target/browserstack")] + output_dir: PathBuf, + #[arg(long, default_value_t = true)] + wait: bool, + #[arg(long, default_value_t = 10)] + poll_interval_secs: u64, + #[arg(long, default_value_t = 1800)] + timeout_secs: u64, + }, + /// Compare two run summaries for regressions. + Compare { + #[arg(long, help = "Baseline JSON summary to compare against")] + baseline: PathBuf, + #[arg(long, help = "Candidate JSON summary to compare")] + candidate: PathBuf, + #[arg(long, help = "Optional output path for markdown report")] + output: Option<PathBuf>, + }, + /// Initialize a new benchmark project with the SDK templates. + InitSdk { + #[arg(long, value_enum)] + target: SdkTarget, + #[arg(long, default_value = "bench-project")] + project_name: String, + #[arg(long, default_value = ".")] + output_dir: PathBuf, + #[arg(long, help = "Generate example benchmarks")] + examples: bool, + }, + /// Build mobile artifacts from the resolved benchmark crate. + Build { + #[arg(long, value_enum)] + target: SdkTarget, + #[arg(long, help = "Build in release mode")] + release: bool, + #[arg( + long, + hide = true, + help = "Deprecated compatibility flag for generated XCUITest harness timeout" + )] + ios_completion_timeout_secs: Option<u64>, + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option<PathBuf>, + #[arg( + long, + help = "Output directory for mobile artifacts (default: target/mobench)" + )] + output_dir: Option<PathBuf>, + #[arg( + long, + help = "Path to the benchmark crate (default: auto-detect bench-mobile/ or crates/{crate})" + )] + crate_path: Option<PathBuf>, + #[arg(long, help = "Show simplified step-by-step progress output")] + progress: bool, + }, + /// Package iOS app as IPA for distribution or testing. + PackageIpa { + #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] + scheme: String, + #[arg(long, value_enum, default_value = "adhoc", help = "Signing method")] + method: IosSigningMethodArg, + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option<PathBuf>, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + crate_path: Option<PathBuf>, + #[arg( + long, + help = "Output directory for mobile artifacts (default: target/mobench)" + )] + output_dir: Option<PathBuf>, + }, + /// Package XCUITest runner for BrowserStack testing. + /// + /// Builds the XCUITest runner using xcodebuild and zips the resulting + /// .xctest bundle for BrowserStack upload. The output is placed at + /// `target/mobench/ios/BenchRunnerUITests.zip` by default. + PackageXcuitest { + #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] + scheme: String, + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option<PathBuf>, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + crate_path: Option<PathBuf>, + #[arg( + long, + help = "Output directory for mobile artifacts (default: target/mobench)" + )] + output_dir: Option<PathBuf>, + }, + /// List all discovered benchmark functions. + List { + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option<PathBuf>, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + crate_path: Option<PathBuf>, + }, + /// Verify benchmark setup: registry, spec, artifacts, and optional smoke test. + /// + /// This command validates: + /// - Registry has benchmark functions registered + /// - Spec file exists and is valid (if --spec-path provided) + /// - Artifacts are present and consistent (if --check-artifacts) + /// - Runs a local smoke test (if --smoke-test and function is specified) + Verify { + #[arg( + long, + help = "Project root containing mobench.toml or the Cargo workspace" + )] + project_root: Option<PathBuf>, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + crate_path: Option<PathBuf>, + #[arg(long, value_enum, help = "Target platform to verify artifacts for")] + target: Option<SdkTarget>, + #[arg(long, help = "Path to bench_spec.json to validate")] + spec_path: Option<PathBuf>, + #[arg(long, help = "Check that build artifacts exist")] + check_artifacts: bool, + #[arg(long, help = "Run a local smoke test with minimal iterations")] + smoke_test: bool, + #[arg(long, help = "Function name to verify/smoke test")] + function: Option<String>, + #[arg( + long, + help = "Output directory for mobile artifacts (default: target/mobench)" + )] + output_dir: Option<PathBuf>, + }, + /// Display summary statistics from a benchmark report JSON file. + /// + /// Prints avg/min/max/median, sample count, device, and OS version + /// from the specified report file. + Summary { + #[arg(help = "Path to the benchmark report JSON file")] + report: PathBuf, + #[arg(long, help = "Output format: text (default), json, or csv")] + format: Option<SummaryFormat>, + }, + /// List available BrowserStack devices for testing. + /// + /// Fetches and displays the list of available devices from BrowserStack + /// that can be used with the --devices flag in the run command. + /// + /// Examples: + /// mobench devices # List all devices + /// mobench devices --platform android # List Android devices only + /// mobench devices --json # Output as JSON + /// mobench devices --validate "Google Pixel 7-13.0" # Validate a device spec + Devices { + #[command(subcommand)] + command: Option<DevicesCommand>, + #[arg(long, value_enum, help = "Filter by platform (android or ios)")] + platform: Option<DevicePlatform>, + #[arg(long, help = "Output as JSON")] + json: bool, + #[arg(long, help = "Validate device specs against available devices")] + validate: Vec<String>, + }, + /// Fixture lifecycle helpers for reproducible CI setup. + Fixture { + #[command(subcommand)] + command: FixtureCommand, + }, + /// Reporting helpers for CI summaries and PR comments. + Report { + #[command(subcommand)] + command: ReportCommand, + }, + /// Profiling helpers for native profile capture and summary rendering. + Profile { + #[command(subcommand)] + command: ProfileCommand, + }, + /// Check prerequisites for building mobile artifacts. + /// + /// Validates that all required tools and configurations are in place + /// before attempting a build. This includes checking for: + /// + /// - Android: ANDROID_NDK_HOME, cargo-ndk, Rust targets + /// - iOS: Xcode, xcodegen, Rust targets + /// - Both: cargo, rustup + /// + /// Examples: + /// cargo mobench check --target android + /// cargo mobench check --target ios + /// cargo mobench check --target android --format json + Check { + /// Target platform (android or ios) + #[arg(long, short, value_enum)] + target: SdkTarget, + /// Output format (text or json) + #[arg(long, default_value = "text")] + format: CheckOutputFormat, + }, +} + +#[derive(Subcommand, Debug)] +#[allow(clippy::large_enum_variant)] +pub(crate) enum CiCommand { + /// Generate GitHub Actions workflow + local action wrapper. + Init { + #[arg( + long, + default_value = ".github/workflows/mobile-bench.yml", + help = "Path to write the workflow file" + )] + workflow: PathBuf, + #[arg( + long, + default_value = ".github/actions/mobench", + help = "Directory to write the local GitHub Action" + )] + action_dir: PathBuf, + }, + /// Run a full CI benchmark flow with stable output contract. + Run(CiRunArgs), + /// Summarize benchmark results with device metrics. + Summarize(CiSummarizeArgs), + /// Create a GitHub Check Run with benchmark results. + CheckRun(CiCheckRunArgs), +} + +#[derive(Subcommand, Debug)] +pub(crate) enum DevicesCommand { + /// Resolve devices from a matrix deterministically for CI usage. + Resolve { + #[arg(long, value_enum)] + platform: DevicePlatform, + #[arg(long, help = "Device profile/tag to resolve (defaults to `default`)")] + profile: Option<String>, + #[arg( + long, + help = "Path to run config file (optional source for matrix/tags)" + )] + config: Option<PathBuf>, + #[arg(long, help = "Path to device matrix YAML file")] + device_matrix: Option<PathBuf>, + #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] + format: CheckOutputFormat, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum ConfigCommand { + /// Validate bench-config.toml and referenced matrix/settings. + Validate { + #[arg(long, default_value = "bench-config.toml")] + config: PathBuf, + #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] + format: CheckOutputFormat, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum FixtureCommand { + /// Create starter fixture files for CI runs. + Init { + #[arg(long, default_value = "bench-config.toml")] + config: PathBuf, + #[arg(long, default_value = "device-matrix.yaml")] + device_matrix: PathBuf, + #[arg(long, help = "Overwrite existing fixture files")] + force: bool, + }, + /// Build fixture artifacts using existing build commands. + Build { + #[arg(long, value_enum, default_value_t = SdkTarget::Both)] + target: SdkTarget, + #[arg(long, help = "Build in release mode")] + release: bool, + #[arg(long, help = "Output directory for mobile artifacts")] + output_dir: Option<PathBuf>, + #[arg(long, help = "Path to benchmark crate")] + crate_path: Option<PathBuf>, + #[arg(long, help = "Show simplified step-by-step progress output")] + progress: bool, + }, + /// Verify fixture files and optional profile filtering. + Verify { + #[arg(long, default_value = "bench-config.toml")] + config: PathBuf, + #[arg(long)] + device_matrix: Option<PathBuf>, + #[arg(long, value_enum, default_value_t = SdkTarget::Both)] + target: SdkTarget, + #[arg(long, help = "Device profile/tag to verify")] + profile: Option<String>, + #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] + format: CheckOutputFormat, + }, + /// Compute deterministic fixture cache key from config/toolchain inputs. + CacheKey { + #[arg(long, default_value = "bench-config.toml")] + config: PathBuf, + #[arg(long)] + device_matrix: Option<PathBuf>, + #[arg(long, value_enum, default_value_t = SdkTarget::Both)] + target: SdkTarget, + #[arg(long, help = "Device profile/tag for keying")] + profile: Option<String>, + #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] + format: CheckOutputFormat, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum ReportCommand { + /// Generate markdown summary from standardized output JSON. + Summarize { + #[arg(long, default_value = "target/mobench/ci/summary.json")] + summary: PathBuf, + #[arg(long, help = "Write markdown output to file")] + output: Option<PathBuf>, + #[arg(long, value_enum, default_value_t = plots::PlotMode::Auto)] + plots: plots::PlotMode, + }, + /// Generate/publish sticky GitHub PR comment from summary output. + Github { + #[arg( + long, + help = "Pull request number (auto-detected from GITHUB_REF if omitted)" + )] + pr: Option<String>, + #[arg(long, default_value = "target/mobench/ci/summary.json")] + summary: PathBuf, + #[arg(long, default_value = "<!-- mobench-report -->")] + marker: String, + #[arg(long, help = "Publish via GitHub API using GITHUB_TOKEN")] + publish: bool, + #[arg(long, help = "Write generated comment body to file")] + output: Option<PathBuf>, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum ProfileCommand { + #[command( + about = "Plan or execute a native profiling session; local android-native and ios-instruments now attempt real native capture" + )] + Run(profile::ProfileRunArgs), + /// Generate a differential flamegraph bundle from two normalized profile manifests. + Diff(profile::ProfileDiffArgs), + /// Render markdown or JSON from a normalized profile manifest. + Summarize(profile::ProfileSummarizeArgs), +} + +#[derive(Args, Debug, Clone)] +pub(crate) struct CiRunArgs { + #[arg(long, value_enum)] + pub(crate) target: CiTarget, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + pub(crate) crate_path: Option<PathBuf>, + #[arg( + long, + help = "Fully-qualified Rust function to benchmark (single function)" + )] + pub(crate) function: Option<String>, + #[arg( + long, + value_delimiter = ',', + help = "Multiple benchmark functions (comma-separated or JSON array). Runs each in sequence." + )] + pub(crate) functions: Vec<String>, + #[arg(long, default_value_t = 100)] + pub(crate) iterations: u32, + #[arg(long, default_value_t = 10)] + pub(crate) warmup: u32, + #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] + pub(crate) devices: Vec<String>, + #[arg(long, help = "Device matrix YAML file to load device names from")] + pub(crate) device_matrix: Option<PathBuf>, + #[arg( + long, + value_delimiter = ',', + help = "Device tags to select from the device matrix (comma-separated or repeatable)" + )] + pub(crate) device_tags: Vec<String>, + #[arg(long, help = "Optional path to config file")] + pub(crate) config: Option<PathBuf>, + #[arg(long, help = "Baseline summary source (path|url|artifact:<path>)")] + pub(crate) baseline: Option<String>, + #[arg( + long, + default_value_t = 5.0, + help = "Regression threshold percentage when comparing to baseline" + )] + pub(crate) regression_threshold_pct: f64, + #[arg(long, help = "Write JUnit XML report to the given path")] + pub(crate) junit: Option<PathBuf>, + #[arg(long, help = "Skip mobile builds and only run the host harness")] + pub(crate) local_only: bool, + #[arg( + long, + help = "Build in release mode (recommended for BrowserStack to reduce APK size and upload time)" + )] + pub(crate) release: bool, + #[arg( + long, + help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" + )] + pub(crate) ios_app: Option<PathBuf>, + #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] + pub(crate) ios_test_suite: Option<PathBuf>, + #[arg( + long, + hide = true, + help = "Deprecated compatibility flag for generated XCUITest harness timeout" + )] + pub(crate) ios_completion_timeout_secs: Option<u64>, + #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] + pub(crate) fetch: bool, + #[arg(long, default_value = "target/browserstack")] + pub(crate) fetch_output_dir: PathBuf, + #[arg(long, default_value_t = 5)] + pub(crate) fetch_poll_interval_secs: u64, + #[arg(long, default_value_t = 300)] + pub(crate) fetch_timeout_secs: u64, + #[arg(long, help = "Show simplified step-by-step progress output")] + pub(crate) progress: bool, + #[arg( + long, + default_value = "target/mobench/ci", + help = "Output directory for CI contract files" + )] + pub(crate) output_dir: PathBuf, + #[arg(long, help = "Metadata: user or actor that requested the run")] + pub(crate) requested_by: Option<String>, + #[arg(long, help = "Metadata: pull request number")] + pub(crate) pr_number: Option<String>, + #[arg(long, help = "Metadata: original command requested by the caller")] + pub(crate) request_command: Option<String>, + #[arg(long, help = "Metadata: git ref/sha for this mobench invocation")] + pub(crate) mobench_ref: Option<String>, + #[arg(long, value_enum, default_value_t = plots::PlotMode::Auto)] + pub(crate) plots: plots::PlotMode, +} + +#[derive(Args, Debug, Clone)] +pub(crate) struct CiSummarizeArgs { + /// BrowserStack build ID to enrich results with device metrics (requires --results-dir). + #[arg(long)] + pub(crate) build_id: Option<String>, + + /// Directory containing summary.json/CSV results (offline mode). + #[arg(long)] + pub(crate) results_dir: Option<PathBuf>, + + /// Output format: table (terminal), markdown, or json. + #[arg(long, value_enum, default_value_t = SummarizeFormat::Table)] + pub(crate) output_format: SummarizeFormat, + + /// Write output to file in addition to stdout. + #[arg(long)] + pub(crate) output_file: Option<PathBuf>, + + /// Platform filter (show only one platform). + #[arg(long, value_enum)] + pub(crate) platform: Option<MobileTarget>, +} + +#[derive(Args, Debug, Clone)] +pub(crate) struct CiCheckRunArgs { + /// Path to summary JSON with benchmark results. + #[arg(long, required_unless_present = "results_dir")] + pub(crate) results: Option<PathBuf>, + + /// Directory containing summary JSON files (processes all). + #[arg(long, required_unless_present = "results")] + pub(crate) results_dir: Option<PathBuf>, + + /// GitHub repository (owner/repo format). + #[arg(long)] + pub(crate) repo: String, + + /// Git commit SHA to annotate. + #[arg(long)] + pub(crate) sha: String, + + /// GitHub App token (from GITHUB_TOKEN env var or actions/create-github-app-token). + #[arg(long, env = "GITHUB_TOKEN", hide = true)] + pub(crate) token: String, + + /// Check Run name displayed in the PR. + #[arg(long, default_value = "Mobench")] + pub(crate) name: String, + + /// Optional baseline JSON for regression detection. + #[arg(long)] + pub(crate) baseline: Option<PathBuf>, + + /// Regression threshold percentage. + #[arg(long, default_value_t = 5.0)] + pub(crate) regression_threshold_pct: f64, + + /// File path used in Check Run annotations (relative to repo root). + #[arg(long, default_value = "src/lib.rs")] + pub(crate) annotation_path: String, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum SummarizeFormat { + Table, + Markdown, + Json, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum CiTarget { + Android, + Ios, + Both, +} + +impl CiTarget { + pub(crate) fn targets(self) -> &'static [MobileTarget] { + match self { + CiTarget::Android => &[MobileTarget::Android], + CiTarget::Ios => &[MobileTarget::Ios], + CiTarget::Both => &[MobileTarget::Android, MobileTarget::Ios], + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub(crate) enum DevicePlatform { + Android, + Ios, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub(crate) enum SummaryFormat { + Text, + Json, + Csv, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub(crate) enum CheckOutputFormat { + Text, + Json, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ContractErrorCategory { + Config, + Preflight, + Provider, + Build, + Benchmark, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +/// Mobile platform target for build/run operations. +pub enum MobileTarget { + /// Android platform. + Android, + /// iOS platform. + Ios, +} + +impl MobileTarget { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Android => "android", + Self::Ios => "ios", + } + } + + pub(crate) fn display_name(self) -> &'static str { + match self { + Self::Android => "Android", + Self::Ios => "iOS", + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub(crate) enum SdkTarget { + Android, + Ios, + Both, +} + +impl From<SdkTarget> for mobench_sdk::Target { + fn from(target: SdkTarget) -> Self { + match target { + SdkTarget::Android => mobench_sdk::Target::Android, + SdkTarget::Ios => mobench_sdk::Target::Ios, + SdkTarget::Both => mobench_sdk::Target::Both, + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub(crate) enum IosSigningMethodArg { + /// Ad-hoc signing (no Apple ID needed, works for BrowserStack) + Adhoc, + /// Development signing (requires Apple Developer account) + Development, +} + +impl From<IosSigningMethodArg> for mobench_sdk::builders::SigningMethod { + fn from(arg: IosSigningMethodArg) -> Self { + match arg { + IosSigningMethodArg::Adhoc => mobench_sdk::builders::SigningMethod::AdHoc, + IosSigningMethodArg::Development => mobench_sdk::builders::SigningMethod::Development, + } + } +} diff --git a/crates/mobench/src/doctor.rs b/crates/mobench/src/doctor.rs new file mode 100644 index 0000000..c452d52 --- /dev/null +++ b/crates/mobench/src/doctor.rs @@ -0,0 +1,736 @@ +use anyhow::{Result, bail}; +use serde::Serialize; +use serde_json::{Value, json}; +use std::env; +use std::path::{Path, PathBuf}; + +use crate::{ + BenchConfig, CheckOutputFormat, ContractErrorCategory, SdkTarget, command_version_line, + filter_devices_by_tags, load_config, load_device_matrix, resolve_browserstack_credentials, +}; + +/// Check prerequisites for building mobile artifacts. +/// +/// This validates that all required tools and configurations are in place +/// before attempting a build. +pub(crate) fn cmd_check(target: SdkTarget, format: CheckOutputFormat) -> Result<()> { + let checks = collect_prereq_checks(target); + let issues = collect_issues(&checks); + + match format { + CheckOutputFormat::Text => print_check_results_text(&checks, &issues), + CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?, + } + + if issues.is_empty() { + Ok(()) + } else { + bail!( + "{} issue(s) found. Fix them and run 'cargo mobench check --target {:?}' again.", + issues.len(), + target + ) + } +} + +pub(crate) fn cmd_config_validate(config_path: &Path, format: CheckOutputFormat) -> Result<()> { + let mut checks = Vec::new(); + let mut config: Option<BenchConfig> = None; + + match load_config(config_path) { + Ok(cfg) => { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: true, + detail: Some(config_path.display().to_string()), + fix_hint: None, + }); + config = Some(cfg); + } + Err(err) => { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!( + "Fix config file syntax/fields at {}", + config_path.display() + )), + }); + } + } + + if let Some(cfg) = &config { + match load_device_matrix(&cfg.device_matrix) { + Ok(matrix) => { + if let Some(tags) = cfg.device_tags.as_ref().filter(|tags| !tags.is_empty()) { + if let Err(err) = filter_devices_by_tags(matrix.devices, tags) { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!( + "Update tags in {} or adjust device_tags in config", + cfg.device_matrix.display() + )), + }); + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: true, + detail: Some(format!( + "{} (tags: {})", + cfg.device_matrix.display(), + tags.join(", ") + )), + fix_hint: None, + }); + } + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: true, + detail: Some(cfg.device_matrix.display().to_string()), + fix_hint: None, + }); + } + } + Err(err) => { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!( + "Fix or regenerate device matrix at {}", + cfg.device_matrix.display() + )), + }); + } + } + + match resolve_browserstack_credentials(Some(&cfg.browserstack)) { + Ok(creds) => checks.push(PrereqCheck { + name: "BrowserStack credentials".to_string(), + passed: true, + detail: Some(format!("user {}", creds.username)), + fix_hint: None, + }), + Err(err) => checks.push(PrereqCheck { + name: "BrowserStack credentials".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some("Set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY".to_string()), + }), + } + } + + let issues = collect_issues(&checks); + match format { + CheckOutputFormat::Text => print_check_results_text(&checks, &issues), + CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?, + } + + if issues.is_empty() { + Ok(()) + } else { + bail!( + "{} issue(s) found. Fix them and rerun 'cargo mobench config validate'.", + issues.len() + ) + } +} + +pub(crate) fn cmd_doctor( + target: SdkTarget, + config_path: Option<&Path>, + device_matrix_path: Option<&Path>, + device_tags: Vec<String>, + browserstack: bool, + format: CheckOutputFormat, +) -> Result<()> { + let mut checks = collect_prereq_checks(target); + + let mut config: Option<BenchConfig> = None; + if let Some(path) = config_path { + match load_config(path) { + Ok(cfg) => { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: true, + detail: Some(path.display().to_string()), + fix_hint: None, + }); + config = Some(cfg); + } + Err(err) => { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!("Fix or regenerate config at {}", path.display())), + }); + } + } + } else { + checks.push(PrereqCheck { + name: "Run config".to_string(), + passed: true, + detail: Some("skipped (no --config)".to_string()), + fix_hint: None, + }); + } + + let resolved_matrix_path = device_matrix_path + .map(PathBuf::from) + .or_else(|| config.as_ref().map(|cfg| cfg.device_matrix.clone())); + let resolved_tags = if !device_tags.is_empty() { + Some(device_tags) + } else { + config.as_ref().and_then(|cfg| cfg.device_tags.clone()) + }; + + if resolved_matrix_path.is_none() && resolved_tags.as_ref().is_some_and(|tags| !tags.is_empty()) + { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some("device tags provided without a matrix file".to_string()), + fix_hint: Some( + "Provide --device-matrix or set device_matrix in the config".to_string(), + ), + }); + } else if let Some(path) = resolved_matrix_path.as_deref() { + match load_device_matrix(path) { + Ok(matrix) => { + if let Some(tags) = resolved_tags.as_ref().filter(|tags| !tags.is_empty()) { + if let Err(err) = filter_devices_by_tags(matrix.devices, tags) { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!( + "Update tags in {} or adjust --device-tags", + path.display() + )), + }); + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: true, + detail: Some(format!("{} (tags: {})", path.display(), tags.join(", "))), + fix_hint: None, + }); + } + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: true, + detail: Some(path.display().to_string()), + fix_hint: None, + }); + } + } + Err(err) => checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some(format!( + "Fix or regenerate device matrix at {}", + path.display() + )), + }), + } + } else { + checks.push(PrereqCheck { + name: "Device matrix".to_string(), + passed: true, + detail: Some("skipped (no --device-matrix)".to_string()), + fix_hint: None, + }); + } + + if browserstack { + let cfg_ref = config.as_ref().map(|cfg| &cfg.browserstack); + match resolve_browserstack_credentials(cfg_ref) { + Ok(creds) => checks.push(PrereqCheck { + name: "BrowserStack credentials".to_string(), + passed: true, + detail: Some(format!("user {}", creds.username)), + fix_hint: None, + }), + Err(err) => checks.push(PrereqCheck { + name: "BrowserStack credentials".to_string(), + passed: false, + detail: Some(err.to_string()), + fix_hint: Some("Set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY".to_string()), + }), + } + } else { + checks.push(PrereqCheck { + name: "BrowserStack credentials".to_string(), + passed: true, + detail: Some("skipped (--browserstack=false)".to_string()), + fix_hint: None, + }); + } + + let issues = collect_issues(&checks); + match format { + CheckOutputFormat::Text => print_check_results_text(&checks, &issues), + CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?, + } + + if issues.is_empty() { + Ok(()) + } else { + bail!( + "{} issue(s) found. Fix them and rerun 'cargo mobench doctor'.", + issues.len() + ) + } +} + +pub(crate) fn collect_prereq_checks(target: SdkTarget) -> Vec<PrereqCheck> { + let mut checks: Vec<PrereqCheck> = Vec::new(); + checks.push(check_cargo()); + checks.push(check_rustup()); + checks.push(check_rustc_msrv()); + + match target { + SdkTarget::Android => { + println!("Checking prerequisites for Android...\n"); + extend_android_prereq_checks(&mut checks); + } + SdkTarget::Ios => { + println!("Checking prerequisites for iOS...\n"); + checks.push(check_xcode()); + checks.push(check_xcodegen()); + checks.push(check_rust_target("aarch64-apple-ios")); + checks.push(check_rust_target("aarch64-apple-ios-sim")); + checks.push(check_rust_target("x86_64-apple-ios")); + } + SdkTarget::Both => { + println!("Checking prerequisites for Android and iOS...\n"); + extend_android_prereq_checks(&mut checks); + checks.push(check_xcode()); + checks.push(check_xcodegen()); + checks.push(check_rust_target("aarch64-apple-ios")); + checks.push(check_rust_target("aarch64-apple-ios-sim")); + checks.push(check_rust_target("x86_64-apple-ios")); + } + } + + checks +} + +pub(crate) const DEFAULT_ANDROID_DOCTOR_RUST_TARGETS: &[&str] = &["aarch64-linux-android"]; +pub(crate) const WORKSPACE_MSRV: &str = "1.85"; + +fn extend_android_prereq_checks(checks: &mut Vec<PrereqCheck>) { + checks.push(check_android_ndk_home()); + checks.push(check_cargo_ndk()); + for target in DEFAULT_ANDROID_DOCTOR_RUST_TARGETS { + checks.push(check_rust_target(target)); + } + checks.push(check_jdk()); +} + +pub(crate) fn collect_issues(checks: &[PrereqCheck]) -> Vec<ValidationIssue> { + let mut issues = Vec::new(); + for check in checks { + if !check.passed + && let Some(ref fix) = check.fix_hint + { + issues.push(ValidationIssue { + category: issue_category_for_check(check), + check: check.name.clone(), + detail: check.detail.clone(), + fix_hint: fix.clone(), + }); + } + } + issues +} + +fn issue_category_for_check(check: &PrereqCheck) -> ContractErrorCategory { + match check.name.as_str() { + "Run config" | "Device matrix" => ContractErrorCategory::Config, + "BrowserStack credentials" => ContractErrorCategory::Provider, + _ => ContractErrorCategory::Preflight, + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct PrereqCheck { + pub(crate) name: String, + pub(crate) passed: bool, + pub(crate) detail: Option<String>, + pub(crate) fix_hint: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ValidationIssue { + pub(crate) category: ContractErrorCategory, + pub(crate) check: String, + pub(crate) detail: Option<String>, + pub(crate) fix_hint: String, +} + +pub(crate) fn print_check_results_text(checks: &[PrereqCheck], issues: &[ValidationIssue]) { + for check in checks { + let status = if check.passed { "\u{2713}" } else { "\u{2717}" }; + let detail = check.detail.as_deref().unwrap_or(""); + let category = if check.passed { + None + } else { + Some(issue_category_for_check(check)) + }; + if detail.is_empty() { + if let Some(category) = category { + println!("{} {} [{}]", status, check.name, category_slug(category)); + } else { + println!("{} {}", status, check.name); + } + } else { + if let Some(category) = category { + println!( + "{} {} [{}] ({})", + status, + check.name, + category_slug(category), + detail + ); + } else { + println!("{} {} ({})", status, check.name, detail); + } + } + } + + if !issues.is_empty() { + println!("\nTo fix:"); + for issue in issues { + println!(" * [{}] {}", category_slug(issue.category), issue.fix_hint); + } + println!(); + let failed_count = checks.iter().filter(|c| !c.passed).count(); + println!("{} issue(s) found.", failed_count); + } else { + println!("\nAll prerequisites satisfied!"); + } +} + +pub(crate) fn print_check_results_json( + checks: &[PrereqCheck], + issues: &[ValidationIssue], +) -> Result<()> { + let output = render_check_results_json(checks, issues); + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + +pub(crate) fn render_check_results_json( + checks: &[PrereqCheck], + issues: &[ValidationIssue], +) -> Value { + json!({ + "checks": checks, + "issues": issues, + "all_passed": checks.iter().all(|c| c.passed), + "passed_count": checks.iter().filter(|c| c.passed).count(), + "failed_count": checks.iter().filter(|c| !c.passed).count(), + }) +} + +pub(crate) fn category_slug(category: ContractErrorCategory) -> &'static str { + match category { + ContractErrorCategory::Config => "config_error", + ContractErrorCategory::Preflight => "preflight_error", + ContractErrorCategory::Provider => "provider_error", + ContractErrorCategory::Build => "build_error", + ContractErrorCategory::Benchmark => "benchmark_error", + } +} + +fn check_cargo() -> PrereqCheck { + let result = std::process::Command::new("cargo") + .arg("--version") + .output(); + + match result { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + PrereqCheck { + name: "cargo installed".to_string(), + passed: true, + detail: Some(version), + fix_hint: None, + } + } + _ => PrereqCheck { + name: "cargo installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install Rust: https://rustup.rs".to_string()), + }, + } +} + +fn check_rustup() -> PrereqCheck { + let result = std::process::Command::new("rustup") + .arg("--version") + .output(); + + match result { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + PrereqCheck { + name: "rustup installed".to_string(), + passed: true, + detail: Some(version), + fix_hint: None, + } + } + _ => PrereqCheck { + name: "rustup installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install rustup: https://rustup.rs".to_string()), + }, + } +} + +fn check_rustc_msrv() -> PrereqCheck { + match command_version_line("rustc", &["--version"]) { + Some(version) if rustc_version_meets_msrv(&version, WORKSPACE_MSRV) => PrereqCheck { + name: "rustc MSRV".to_string(), + passed: true, + detail: Some(format!("{version} (requires >= {WORKSPACE_MSRV})")), + fix_hint: None, + }, + Some(version) => PrereqCheck { + name: "rustc MSRV".to_string(), + passed: false, + detail: Some(format!("{version} (requires >= {WORKSPACE_MSRV})")), + fix_hint: Some(format!( + "Update Rust: rustup update stable (MSRV {WORKSPACE_MSRV})" + )), + }, + None => PrereqCheck { + name: "rustc MSRV".to_string(), + passed: false, + detail: Some("could not run rustc --version".to_string()), + fix_hint: Some("Install Rust: https://rustup.rs".to_string()), + }, + } +} + +pub(crate) fn rustc_version_meets_msrv(version_line: &str, msrv: &str) -> bool { + let Some(actual) = parse_rust_version(version_line) else { + return false; + }; + let Some(required) = parse_rust_version(msrv) else { + return false; + }; + actual >= required +} + +pub(crate) fn parse_rust_version(input: &str) -> Option<(u32, u32, u32)> { + let version = input + .split_whitespace() + .find(|part| part.chars().next().is_some_and(|ch| ch.is_ascii_digit()))?; + let mut parts = version.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts + .next() + .and_then(|part| part.split('-').next()) + .unwrap_or("0") + .parse() + .ok()?; + Some((major, minor, patch)) +} + +fn check_android_ndk_home() -> PrereqCheck { + match env::var("ANDROID_NDK_HOME") { + Ok(path) if !path.is_empty() => { + let path_exists = Path::new(&path).exists(); + if path_exists { + PrereqCheck { + name: "ANDROID_NDK_HOME set".to_string(), + passed: true, + detail: Some(path), + fix_hint: None, + } + } else { + PrereqCheck { + name: "ANDROID_NDK_HOME set".to_string(), + passed: false, + detail: Some(format!("path does not exist: {}", path)), + fix_hint: Some("Set ANDROID_NDK_HOME to a valid NDK path: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/<version>".to_string()), + } + } + } + _ => PrereqCheck { + name: "ANDROID_NDK_HOME set".to_string(), + passed: false, + detail: None, + fix_hint: Some( + "Set ANDROID_NDK_HOME: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/<version>" + .to_string(), + ), + }, + } +} + +fn check_cargo_ndk() -> PrereqCheck { + let result = std::process::Command::new("cargo") + .args(["ndk", "--version"]) + .output(); + + match result { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + PrereqCheck { + name: "cargo-ndk installed".to_string(), + passed: true, + detail: Some(version), + fix_hint: None, + } + } + _ => PrereqCheck { + name: "cargo-ndk installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install cargo-ndk: cargo install cargo-ndk".to_string()), + }, + } +} + +fn check_rust_target(target: &str) -> PrereqCheck { + let result = std::process::Command::new("rustup") + .args(["target", "list", "--installed"]) + .output(); + + match result { + Ok(output) if output.status.success() => { + let installed = String::from_utf8_lossy(&output.stdout); + let has_target = installed.lines().any(|line| line.trim() == target); + if has_target { + PrereqCheck { + name: format!("Rust target: {}", target), + passed: true, + detail: None, + fix_hint: None, + } + } else { + PrereqCheck { + name: format!("Rust target: {}", target), + passed: false, + detail: Some("not installed".to_string()), + fix_hint: Some(format!("Install target: rustup target add {}", target)), + } + } + } + _ => PrereqCheck { + name: format!("Rust target: {}", target), + passed: false, + detail: Some("could not check".to_string()), + fix_hint: Some(format!("Install target: rustup target add {}", target)), + }, + } +} + +fn check_jdk() -> PrereqCheck { + // Try java -version + let result = std::process::Command::new("java").arg("-version").output(); + + match result { + Ok(output) => { + // Java outputs version to stderr + let version_output = String::from_utf8_lossy(&output.stderr); + let version_line = version_output.lines().next().unwrap_or(""); + + if output.status.success() || !version_line.is_empty() { + PrereqCheck { + name: "JDK installed".to_string(), + passed: true, + detail: Some(version_line.trim().to_string()), + fix_hint: None, + } + } else { + PrereqCheck { + name: "JDK installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install JDK 17+: brew install openjdk@17".to_string()), + } + } + } + Err(_) => PrereqCheck { + name: "JDK installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install JDK 17+: brew install openjdk@17".to_string()), + }, + } +} + +fn check_xcode() -> PrereqCheck { + let result = std::process::Command::new("xcodebuild") + .arg("-version") + .output(); + + match result { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + PrereqCheck { + name: "Xcode installed".to_string(), + passed: true, + detail: Some(version), + fix_hint: None, + } + } + _ => PrereqCheck { + name: "Xcode installed".to_string(), + passed: false, + detail: None, + fix_hint: Some( + "Install Xcode from the App Store or run: xcode-select --install".to_string(), + ), + }, + } +} + +fn check_xcodegen() -> PrereqCheck { + let result = std::process::Command::new("xcodegen") + .arg("--version") + .output(); + + match result { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + PrereqCheck { + name: "xcodegen installed".to_string(), + passed: true, + detail: Some(version), + fix_hint: None, + } + } + _ => PrereqCheck { + name: "xcodegen installed".to_string(), + passed: false, + detail: None, + fix_hint: Some("Install xcodegen: brew install xcodegen".to_string()), + }, + } +} diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 1634b27..b673f37 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -122,7 +122,7 @@ #![cfg_attr(docsrs, feature(doc_cfg))] use anyhow::{Context, Result, anyhow, bail}; -use clap::{Args, Parser, Subcommand, ValueEnum}; +use clap::Parser; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -139,833 +139,34 @@ use tracing::{debug, info}; use tracing_subscriber::EnvFilter; use browserstack::{BrowserStackAuth, BrowserStackClient}; +#[cfg(test)] +pub(crate) use cli::CiTarget; +pub use cli::MobileTarget; +pub(crate) use cli::{ + CheckOutputFormat, CiCheckRunArgs, CiCommand, CiRunArgs, CiSummarizeArgs, Cli, Command, + ConfigCommand, ContractErrorCategory, DevicePlatform, DevicesCommand, FixtureCommand, + IosSigningMethodArg, ProfileCommand, ReportCommand, SdkTarget, SummarizeFormat, SummaryFormat, +}; +#[cfg(test)] +pub(crate) use doctor::{ + DEFAULT_ANDROID_DOCTOR_RUST_TARGETS, WORKSPACE_MSRV, category_slug, parse_rust_version, + render_check_results_json, rustc_version_meets_msrv, +}; +pub(crate) use doctor::{ + PrereqCheck, cmd_check, cmd_config_validate, cmd_doctor, collect_issues, + print_check_results_json, print_check_results_text, +}; mod browserstack; +mod cli; pub mod config; +mod doctor; mod flamegraph_viewer; mod github; mod plots; mod profile; pub(crate) mod summarize; -/// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. -#[derive(Parser, Debug)] -#[command(name = "mobench", author, version, about = "Mobile Rust benchmarking orchestrator", long_about = None)] -struct Cli { - /// Print what would be done without actually doing it - #[arg(long, global = true)] - dry_run: bool, - - /// Print verbose output including all commands - #[arg(long, short = 'v', global = true)] - verbose: bool, - - /// Assume yes to prompts and allow overwriting files - #[arg(long, global = true)] - yes: bool, - - /// Disable interactive prompts (fail instead) - #[arg(long, global = true)] - non_interactive: bool, - - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// Run benchmarks locally or on BrowserStack devices. - /// - /// This is a single-command flow that: - /// 1. Builds Rust libraries for the target platform - /// 2. Packages mobile apps (APK/IPA) automatically - /// 3. Uploads to BrowserStack when devices are requested - /// 4. Schedules the benchmark run when using BrowserStack - /// 5. Fetches results when the provider returns them - /// - /// For iOS, IPA and XCUITest packages are created automatically unless - /// you provide --ios-app and --ios-test-suite to override. - Run { - #[arg(long, value_enum)] - target: MobileTarget, - #[arg(long, help = "Fully-qualified Rust function to benchmark")] - function: String, - #[arg( - long, - help = "Project root containing mobench.toml or the Cargo workspace" - )] - project_root: Option<PathBuf>, - #[arg( - long, - help = "Path to the benchmark crate directory containing Cargo.toml" - )] - crate_path: Option<PathBuf>, - #[arg(long, default_value_t = 100)] - iterations: u32, - #[arg(long, default_value_t = 10)] - warmup: u32, - #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] - devices: Vec<String>, - #[arg(long, help = "Device matrix YAML file to load device names from")] - device_matrix: Option<PathBuf>, - #[arg( - long, - value_delimiter = ',', - help = "Device tags to select from the device matrix (comma-separated or repeatable)" - )] - device_tags: Vec<String>, - #[arg(long, help = "Optional path to config file")] - config: Option<PathBuf>, - #[arg(long, help = "Optional output path for JSON report")] - output: Option<PathBuf>, - #[arg(long, help = "Write CSV summary alongside JSON")] - summary_csv: bool, - #[arg( - long, - help = "Enable CI mode (job summary, optional JUnit, regression exit codes)" - )] - ci: bool, - #[arg(long, help = "Baseline summary source (path|url|artifact:<path>)")] - baseline: Option<String>, - #[arg( - long, - default_value_t = 5.0, - help = "Regression threshold percentage when comparing to baseline" - )] - regression_threshold_pct: f64, - #[arg(long, help = "Write JUnit XML report to the given path")] - junit: Option<PathBuf>, - #[arg(long, help = "Skip mobile builds and only run the host harness")] - local_only: bool, - #[arg( - long, - help = "Build in release mode (recommended for BrowserStack to reduce APK size and upload time)" - )] - release: bool, - #[arg( - long, - help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" - )] - ios_app: Option<PathBuf>, - #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] - ios_test_suite: Option<PathBuf>, - #[arg( - long, - hide = true, - help = "Deprecated compatibility flag for generated XCUITest harness timeout" - )] - ios_completion_timeout_secs: Option<u64>, - #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] - fetch: bool, - #[arg(long, default_value = "target/browserstack")] - fetch_output_dir: PathBuf, - #[arg(long, default_value_t = 5)] - fetch_poll_interval_secs: u64, - #[arg(long, default_value_t = 300)] - fetch_timeout_secs: u64, - #[arg(long, help = "Show simplified step-by-step progress output")] - progress: bool, - }, - /// Scaffold a base config file for the CLI. - Init { - #[arg(long, default_value = "bench-config.toml")] - output: PathBuf, - #[arg(long, value_enum, default_value_t = MobileTarget::Android)] - target: MobileTarget, - }, - /// Generate a sample device matrix file. - Plan { - #[arg(long, default_value = "device-matrix.yaml")] - output: PathBuf, - }, - /// Validate run configuration and associated files. - Config { - #[command(subcommand)] - command: ConfigCommand, - }, - /// Validate local + CI prerequisites and configuration. - Doctor { - #[arg(long, value_enum, default_value_t = SdkTarget::Both)] - target: SdkTarget, - #[arg(long, help = "Optional path to run config file to validate")] - config: Option<PathBuf>, - #[arg(long, help = "Optional path to device matrix YAML file to validate")] - device_matrix: Option<PathBuf>, - #[arg( - long, - value_delimiter = ',', - help = "Device tags to select from the device matrix (comma-separated or repeatable)" - )] - device_tags: Vec<String>, - #[arg( - long, - default_value_t = true, - action = clap::ArgAction::Set, - num_args = 0..=1, - default_missing_value = "true", - help = "Validate BrowserStack credentials" - )] - browserstack: bool, - #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] - format: CheckOutputFormat, - }, - /// CI helpers (workflow and action scaffolding). - Ci { - #[command(subcommand)] - command: CiCommand, - }, - /// Fetch BrowserStack build artifacts (logs, session JSON) for CI. - Fetch { - #[arg(long, value_enum)] - target: MobileTarget, - #[arg(long)] - build_id: String, - #[arg(long, default_value = "target/browserstack")] - output_dir: PathBuf, - #[arg(long, default_value_t = true)] - wait: bool, - #[arg(long, default_value_t = 10)] - poll_interval_secs: u64, - #[arg(long, default_value_t = 1800)] - timeout_secs: u64, - }, - /// Compare two run summaries for regressions. - Compare { - #[arg(long, help = "Baseline JSON summary to compare against")] - baseline: PathBuf, - #[arg(long, help = "Candidate JSON summary to compare")] - candidate: PathBuf, - #[arg(long, help = "Optional output path for markdown report")] - output: Option<PathBuf>, - }, - /// Initialize a new benchmark project with the SDK templates. - InitSdk { - #[arg(long, value_enum)] - target: SdkTarget, - #[arg(long, default_value = "bench-project")] - project_name: String, - #[arg(long, default_value = ".")] - output_dir: PathBuf, - #[arg(long, help = "Generate example benchmarks")] - examples: bool, - }, - /// Build mobile artifacts from the resolved benchmark crate. - Build { - #[arg(long, value_enum)] - target: SdkTarget, - #[arg(long, help = "Build in release mode")] - release: bool, - #[arg( - long, - hide = true, - help = "Deprecated compatibility flag for generated XCUITest harness timeout" - )] - ios_completion_timeout_secs: Option<u64>, - #[arg( - long, - help = "Project root containing mobench.toml or the Cargo workspace" - )] - project_root: Option<PathBuf>, - #[arg( - long, - help = "Output directory for mobile artifacts (default: target/mobench)" - )] - output_dir: Option<PathBuf>, - #[arg( - long, - help = "Path to the benchmark crate (default: auto-detect bench-mobile/ or crates/{crate})" - )] - crate_path: Option<PathBuf>, - #[arg(long, help = "Show simplified step-by-step progress output")] - progress: bool, - }, - /// Package iOS app as IPA for distribution or testing. - PackageIpa { - #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] - scheme: String, - #[arg(long, value_enum, default_value = "adhoc", help = "Signing method")] - method: IosSigningMethodArg, - #[arg( - long, - help = "Project root containing mobench.toml or the Cargo workspace" - )] - project_root: Option<PathBuf>, - #[arg( - long, - help = "Path to the benchmark crate directory containing Cargo.toml" - )] - crate_path: Option<PathBuf>, - #[arg( - long, - help = "Output directory for mobile artifacts (default: target/mobench)" - )] - output_dir: Option<PathBuf>, - }, - /// Package XCUITest runner for BrowserStack testing. - /// - /// Builds the XCUITest runner using xcodebuild and zips the resulting - /// .xctest bundle for BrowserStack upload. The output is placed at - /// `target/mobench/ios/BenchRunnerUITests.zip` by default. - PackageXcuitest { - #[arg(long, default_value = "BenchRunner", help = "Xcode scheme to build")] - scheme: String, - #[arg( - long, - help = "Project root containing mobench.toml or the Cargo workspace" - )] - project_root: Option<PathBuf>, - #[arg( - long, - help = "Path to the benchmark crate directory containing Cargo.toml" - )] - crate_path: Option<PathBuf>, - #[arg( - long, - help = "Output directory for mobile artifacts (default: target/mobench)" - )] - output_dir: Option<PathBuf>, - }, - /// List all discovered benchmark functions. - List { - #[arg( - long, - help = "Project root containing mobench.toml or the Cargo workspace" - )] - project_root: Option<PathBuf>, - #[arg( - long, - help = "Path to the benchmark crate directory containing Cargo.toml" - )] - crate_path: Option<PathBuf>, - }, - /// Verify benchmark setup: registry, spec, artifacts, and optional smoke test. - /// - /// This command validates: - /// - Registry has benchmark functions registered - /// - Spec file exists and is valid (if --spec-path provided) - /// - Artifacts are present and consistent (if --check-artifacts) - /// - Runs a local smoke test (if --smoke-test and function is specified) - Verify { - #[arg( - long, - help = "Project root containing mobench.toml or the Cargo workspace" - )] - project_root: Option<PathBuf>, - #[arg( - long, - help = "Path to the benchmark crate directory containing Cargo.toml" - )] - crate_path: Option<PathBuf>, - #[arg(long, value_enum, help = "Target platform to verify artifacts for")] - target: Option<SdkTarget>, - #[arg(long, help = "Path to bench_spec.json to validate")] - spec_path: Option<PathBuf>, - #[arg(long, help = "Check that build artifacts exist")] - check_artifacts: bool, - #[arg(long, help = "Run a local smoke test with minimal iterations")] - smoke_test: bool, - #[arg(long, help = "Function name to verify/smoke test")] - function: Option<String>, - #[arg( - long, - help = "Output directory for mobile artifacts (default: target/mobench)" - )] - output_dir: Option<PathBuf>, - }, - /// Display summary statistics from a benchmark report JSON file. - /// - /// Prints avg/min/max/median, sample count, device, and OS version - /// from the specified report file. - Summary { - #[arg(help = "Path to the benchmark report JSON file")] - report: PathBuf, - #[arg(long, help = "Output format: text (default), json, or csv")] - format: Option<SummaryFormat>, - }, - /// List available BrowserStack devices for testing. - /// - /// Fetches and displays the list of available devices from BrowserStack - /// that can be used with the --devices flag in the run command. - /// - /// Examples: - /// mobench devices # List all devices - /// mobench devices --platform android # List Android devices only - /// mobench devices --json # Output as JSON - /// mobench devices --validate "Google Pixel 7-13.0" # Validate a device spec - Devices { - #[command(subcommand)] - command: Option<DevicesCommand>, - #[arg(long, value_enum, help = "Filter by platform (android or ios)")] - platform: Option<DevicePlatform>, - #[arg(long, help = "Output as JSON")] - json: bool, - #[arg(long, help = "Validate device specs against available devices")] - validate: Vec<String>, - }, - /// Fixture lifecycle helpers for reproducible CI setup. - Fixture { - #[command(subcommand)] - command: FixtureCommand, - }, - /// Reporting helpers for CI summaries and PR comments. - Report { - #[command(subcommand)] - command: ReportCommand, - }, - /// Profiling helpers for native profile capture and summary rendering. - Profile { - #[command(subcommand)] - command: ProfileCommand, - }, - /// Check prerequisites for building mobile artifacts. - /// - /// Validates that all required tools and configurations are in place - /// before attempting a build. This includes checking for: - /// - /// - Android: ANDROID_NDK_HOME, cargo-ndk, Rust targets - /// - iOS: Xcode, xcodegen, Rust targets - /// - Both: cargo, rustup - /// - /// Examples: - /// cargo mobench check --target android - /// cargo mobench check --target ios - /// cargo mobench check --target android --format json - Check { - /// Target platform (android or ios) - #[arg(long, short, value_enum)] - target: SdkTarget, - /// Output format (text or json) - #[arg(long, default_value = "text")] - format: CheckOutputFormat, - }, -} - -#[derive(Subcommand, Debug)] -#[allow(clippy::large_enum_variant)] -enum CiCommand { - /// Generate GitHub Actions workflow + local action wrapper. - Init { - #[arg( - long, - default_value = ".github/workflows/mobile-bench.yml", - help = "Path to write the workflow file" - )] - workflow: PathBuf, - #[arg( - long, - default_value = ".github/actions/mobench", - help = "Directory to write the local GitHub Action" - )] - action_dir: PathBuf, - }, - /// Run a full CI benchmark flow with stable output contract. - Run(CiRunArgs), - /// Summarize benchmark results with device metrics. - Summarize(CiSummarizeArgs), - /// Create a GitHub Check Run with benchmark results. - CheckRun(CiCheckRunArgs), -} - -#[derive(Subcommand, Debug)] -enum DevicesCommand { - /// Resolve devices from a matrix deterministically for CI usage. - Resolve { - #[arg(long, value_enum)] - platform: DevicePlatform, - #[arg(long, help = "Device profile/tag to resolve (defaults to `default`)")] - profile: Option<String>, - #[arg( - long, - help = "Path to run config file (optional source for matrix/tags)" - )] - config: Option<PathBuf>, - #[arg(long, help = "Path to device matrix YAML file")] - device_matrix: Option<PathBuf>, - #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] - format: CheckOutputFormat, - }, -} - -#[derive(Subcommand, Debug)] -enum ConfigCommand { - /// Validate bench-config.toml and referenced matrix/settings. - Validate { - #[arg(long, default_value = "bench-config.toml")] - config: PathBuf, - #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] - format: CheckOutputFormat, - }, -} - -#[derive(Subcommand, Debug)] -enum FixtureCommand { - /// Create starter fixture files for CI runs. - Init { - #[arg(long, default_value = "bench-config.toml")] - config: PathBuf, - #[arg(long, default_value = "device-matrix.yaml")] - device_matrix: PathBuf, - #[arg(long, help = "Overwrite existing fixture files")] - force: bool, - }, - /// Build fixture artifacts using existing build commands. - Build { - #[arg(long, value_enum, default_value_t = SdkTarget::Both)] - target: SdkTarget, - #[arg(long, help = "Build in release mode")] - release: bool, - #[arg(long, help = "Output directory for mobile artifacts")] - output_dir: Option<PathBuf>, - #[arg(long, help = "Path to benchmark crate")] - crate_path: Option<PathBuf>, - #[arg(long, help = "Show simplified step-by-step progress output")] - progress: bool, - }, - /// Verify fixture files and optional profile filtering. - Verify { - #[arg(long, default_value = "bench-config.toml")] - config: PathBuf, - #[arg(long)] - device_matrix: Option<PathBuf>, - #[arg(long, value_enum, default_value_t = SdkTarget::Both)] - target: SdkTarget, - #[arg(long, help = "Device profile/tag to verify")] - profile: Option<String>, - #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] - format: CheckOutputFormat, - }, - /// Compute deterministic fixture cache key from config/toolchain inputs. - CacheKey { - #[arg(long, default_value = "bench-config.toml")] - config: PathBuf, - #[arg(long)] - device_matrix: Option<PathBuf>, - #[arg(long, value_enum, default_value_t = SdkTarget::Both)] - target: SdkTarget, - #[arg(long, help = "Device profile/tag for keying")] - profile: Option<String>, - #[arg(long, value_enum, default_value_t = CheckOutputFormat::Text)] - format: CheckOutputFormat, - }, -} - -#[derive(Subcommand, Debug)] -enum ReportCommand { - /// Generate markdown summary from standardized output JSON. - Summarize { - #[arg(long, default_value = "target/mobench/ci/summary.json")] - summary: PathBuf, - #[arg(long, help = "Write markdown output to file")] - output: Option<PathBuf>, - #[arg(long, value_enum, default_value_t = plots::PlotMode::Auto)] - plots: plots::PlotMode, - }, - /// Generate/publish sticky GitHub PR comment from summary output. - Github { - #[arg( - long, - help = "Pull request number (auto-detected from GITHUB_REF if omitted)" - )] - pr: Option<String>, - #[arg(long, default_value = "target/mobench/ci/summary.json")] - summary: PathBuf, - #[arg(long, default_value = "<!-- mobench-report -->")] - marker: String, - #[arg(long, help = "Publish via GitHub API using GITHUB_TOKEN")] - publish: bool, - #[arg(long, help = "Write generated comment body to file")] - output: Option<PathBuf>, - }, -} - -#[derive(Subcommand, Debug)] -enum ProfileCommand { - #[command( - about = "Plan or execute a native profiling session; local android-native and ios-instruments now attempt real native capture" - )] - Run(profile::ProfileRunArgs), - /// Generate a differential flamegraph bundle from two normalized profile manifests. - Diff(profile::ProfileDiffArgs), - /// Render markdown or JSON from a normalized profile manifest. - Summarize(profile::ProfileSummarizeArgs), -} - -#[derive(Args, Debug, Clone)] -struct CiRunArgs { - #[arg(long, value_enum)] - target: CiTarget, - #[arg( - long, - help = "Path to the benchmark crate directory containing Cargo.toml" - )] - crate_path: Option<PathBuf>, - #[arg( - long, - help = "Fully-qualified Rust function to benchmark (single function)" - )] - function: Option<String>, - #[arg( - long, - value_delimiter = ',', - help = "Multiple benchmark functions (comma-separated or JSON array). Runs each in sequence." - )] - functions: Vec<String>, - #[arg(long, default_value_t = 100)] - iterations: u32, - #[arg(long, default_value_t = 10)] - warmup: u32, - #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] - devices: Vec<String>, - #[arg(long, help = "Device matrix YAML file to load device names from")] - device_matrix: Option<PathBuf>, - #[arg( - long, - value_delimiter = ',', - help = "Device tags to select from the device matrix (comma-separated or repeatable)" - )] - device_tags: Vec<String>, - #[arg(long, help = "Optional path to config file")] - config: Option<PathBuf>, - #[arg(long, help = "Baseline summary source (path|url|artifact:<path>)")] - baseline: Option<String>, - #[arg( - long, - default_value_t = 5.0, - help = "Regression threshold percentage when comparing to baseline" - )] - regression_threshold_pct: f64, - #[arg(long, help = "Write JUnit XML report to the given path")] - junit: Option<PathBuf>, - #[arg(long, help = "Skip mobile builds and only run the host harness")] - local_only: bool, - #[arg( - long, - help = "Build in release mode (recommended for BrowserStack to reduce APK size and upload time)" - )] - release: bool, - #[arg( - long, - help = "Path to iOS app bundle (.ipa or zipped .app) for BrowserStack XCUITest" - )] - ios_app: Option<PathBuf>, - #[arg(long, help = "Path to iOS XCUITest test suite package (.zip or .ipa)")] - ios_test_suite: Option<PathBuf>, - #[arg( - long, - hide = true, - help = "Deprecated compatibility flag for generated XCUITest harness timeout" - )] - ios_completion_timeout_secs: Option<u64>, - #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] - fetch: bool, - #[arg(long, default_value = "target/browserstack")] - fetch_output_dir: PathBuf, - #[arg(long, default_value_t = 5)] - fetch_poll_interval_secs: u64, - #[arg(long, default_value_t = 300)] - fetch_timeout_secs: u64, - #[arg(long, help = "Show simplified step-by-step progress output")] - progress: bool, - #[arg( - long, - default_value = "target/mobench/ci", - help = "Output directory for CI contract files" - )] - output_dir: PathBuf, - #[arg(long, help = "Metadata: user or actor that requested the run")] - requested_by: Option<String>, - #[arg(long, help = "Metadata: pull request number")] - pr_number: Option<String>, - #[arg(long, help = "Metadata: original command requested by the caller")] - request_command: Option<String>, - #[arg(long, help = "Metadata: git ref/sha for this mobench invocation")] - mobench_ref: Option<String>, - #[arg(long, value_enum, default_value_t = plots::PlotMode::Auto)] - plots: plots::PlotMode, -} - -#[derive(Args, Debug, Clone)] -struct CiSummarizeArgs { - /// BrowserStack build ID to enrich results with device metrics (requires --results-dir). - #[arg(long)] - build_id: Option<String>, - - /// Directory containing summary.json/CSV results (offline mode). - #[arg(long)] - results_dir: Option<PathBuf>, - - /// Output format: table (terminal), markdown, or json. - #[arg(long, value_enum, default_value_t = SummarizeFormat::Table)] - output_format: SummarizeFormat, - - /// Write output to file in addition to stdout. - #[arg(long)] - output_file: Option<PathBuf>, - - /// Platform filter (show only one platform). - #[arg(long, value_enum)] - platform: Option<MobileTarget>, -} - -#[derive(Args, Debug, Clone)] -struct CiCheckRunArgs { - /// Path to summary JSON with benchmark results. - #[arg(long, required_unless_present = "results_dir")] - results: Option<PathBuf>, - - /// Directory containing summary JSON files (processes all). - #[arg(long, required_unless_present = "results")] - results_dir: Option<PathBuf>, - - /// GitHub repository (owner/repo format). - #[arg(long)] - repo: String, - - /// Git commit SHA to annotate. - #[arg(long)] - sha: String, - - /// GitHub App token (from GITHUB_TOKEN env var or actions/create-github-app-token). - #[arg(long, env = "GITHUB_TOKEN", hide = true)] - token: String, - - /// Check Run name displayed in the PR. - #[arg(long, default_value = "Mobench")] - name: String, - - /// Optional baseline JSON for regression detection. - #[arg(long)] - baseline: Option<PathBuf>, - - /// Regression threshold percentage. - #[arg(long, default_value_t = 5.0)] - regression_threshold_pct: f64, - - /// File path used in Check Run annotations (relative to repo root). - #[arg(long, default_value = "src/lib.rs")] - annotation_path: String, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -enum SummarizeFormat { - Table, - Markdown, - Json, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -enum CiTarget { - Android, - Ios, - Both, -} - -impl CiTarget { - fn targets(self) -> &'static [MobileTarget] { - match self { - CiTarget::Android => &[MobileTarget::Android], - CiTarget::Ios => &[MobileTarget::Ios], - CiTarget::Both => &[MobileTarget::Android, MobileTarget::Ios], - } - } -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] -#[clap(rename_all = "lowercase")] -pub(crate) enum DevicePlatform { - Android, - Ios, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] -#[clap(rename_all = "lowercase")] -enum SummaryFormat { - Text, - Json, - Csv, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] -#[clap(rename_all = "lowercase")] -enum CheckOutputFormat { - Text, - Json, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum ContractErrorCategory { - Config, - Preflight, - Provider, - Build, - Benchmark, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -/// Mobile platform target for build/run operations. -pub enum MobileTarget { - /// Android platform. - Android, - /// iOS platform. - Ios, -} - -impl MobileTarget { - fn as_str(self) -> &'static str { - match self { - Self::Android => "android", - Self::Ios => "ios", - } - } - - fn display_name(self) -> &'static str { - match self { - Self::Android => "Android", - Self::Ios => "iOS", - } - } -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] -#[clap(rename_all = "lowercase")] -enum SdkTarget { - Android, - Ios, - Both, -} - -impl From<SdkTarget> for mobench_sdk::Target { - fn from(target: SdkTarget) -> Self { - match target { - SdkTarget::Android => mobench_sdk::Target::Android, - SdkTarget::Ios => mobench_sdk::Target::Ios, - SdkTarget::Both => mobench_sdk::Target::Both, - } - } -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] -#[clap(rename_all = "lowercase")] -enum IosSigningMethodArg { - /// Ad-hoc signing (no Apple ID needed, works for BrowserStack) - Adhoc, - /// Development signing (requires Apple Developer account) - Development, -} - -impl From<IosSigningMethodArg> for mobench_sdk::builders::SigningMethod { - fn from(arg: IosSigningMethodArg) -> Self { - match arg { - IosSigningMethodArg::Adhoc => mobench_sdk::builders::SigningMethod::AdHoc, - IosSigningMethodArg::Development => mobench_sdk::builders::SigningMethod::Development, - } - } -} - #[derive(Debug, Serialize, Deserialize, Clone)] struct BrowserStackConfig { app_automate_username: String, @@ -2440,6 +1641,54 @@ pub struct RunResult { pub regression_detected: bool, } +#[cfg(feature = "bench-support")] +#[doc(hidden)] +pub mod bench_support { + use super::*; + + pub fn parse_run_config(input: &str) -> Result<()> { + let _: BenchConfig = toml::from_str(input)?; + Ok(()) + } + + pub fn parse_device_matrix(input: &str) -> Result<()> { + let _: DeviceMatrix = serde_yaml::from_str(input)?; + Ok(()) + } + + pub fn render_summary_markdown_from_json(input: &str) -> Result<String> { + let summary = summary_report_from_json(input)?; + Ok(render_markdown_summary(&summary)) + } + + pub fn render_summary_csv_from_json(input: &str) -> Result<String> { + let summary = summary_report_from_json(input)?; + Ok(render_csv_summary(&summary)) + } + + pub fn render_profile_markdown_from_json(input: &str) -> Result<String> { + let manifest: profile::ProfileManifest = serde_json::from_str(input)?; + Ok(profile::render_profile_markdown(&manifest)) + } + + pub fn extract_browserstack_results_from_logs(logs: &str) -> Result<usize> { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "benchmark".to_string(), + access_key: "benchmark".to_string(), + }, + None, + )?; + Ok(client.extract_benchmark_results(logs)?.len()) + } + + fn summary_report_from_json(input: &str) -> Result<SummaryReport> { + let value: Value = serde_json::from_str(input)?; + let summary = value.get("summary").cloned().unwrap_or(value); + Ok(serde_json::from_value(summary)?) + } +} + /// Executes a [`RunRequest`] by invoking the current `mobench` binary and normalizing outputs. /// /// This function always writes/normalizes CI output file names in `request.output_dir`: @@ -7672,726 +6921,6 @@ fn command_version_line(cmd: &str, args: &[&str]) -> Option<String> { .filter(|line| !line.is_empty()) } -/// Check prerequisites for building mobile artifacts. -/// -/// This validates that all required tools and configurations are in place -/// before attempting a build. -fn cmd_check(target: SdkTarget, format: CheckOutputFormat) -> Result<()> { - let checks = collect_prereq_checks(target); - let issues = collect_issues(&checks); - - match format { - CheckOutputFormat::Text => print_check_results_text(&checks, &issues), - CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?, - } - - if issues.is_empty() { - Ok(()) - } else { - bail!( - "{} issue(s) found. Fix them and run 'cargo mobench check --target {:?}' again.", - issues.len(), - target - ) - } -} - -fn cmd_config_validate(config_path: &Path, format: CheckOutputFormat) -> Result<()> { - let mut checks = Vec::new(); - let mut config: Option<BenchConfig> = None; - - match load_config(config_path) { - Ok(cfg) => { - checks.push(PrereqCheck { - name: "Run config".to_string(), - passed: true, - detail: Some(config_path.display().to_string()), - fix_hint: None, - }); - config = Some(cfg); - } - Err(err) => { - checks.push(PrereqCheck { - name: "Run config".to_string(), - passed: false, - detail: Some(err.to_string()), - fix_hint: Some(format!( - "Fix config file syntax/fields at {}", - config_path.display() - )), - }); - } - } - - if let Some(cfg) = &config { - match load_device_matrix(&cfg.device_matrix) { - Ok(matrix) => { - if let Some(tags) = cfg.device_tags.as_ref().filter(|tags| !tags.is_empty()) { - if let Err(err) = filter_devices_by_tags(matrix.devices, tags) { - checks.push(PrereqCheck { - name: "Device matrix".to_string(), - passed: false, - detail: Some(err.to_string()), - fix_hint: Some(format!( - "Update tags in {} or adjust device_tags in config", - cfg.device_matrix.display() - )), - }); - } else { - checks.push(PrereqCheck { - name: "Device matrix".to_string(), - passed: true, - detail: Some(format!( - "{} (tags: {})", - cfg.device_matrix.display(), - tags.join(", ") - )), - fix_hint: None, - }); - } - } else { - checks.push(PrereqCheck { - name: "Device matrix".to_string(), - passed: true, - detail: Some(cfg.device_matrix.display().to_string()), - fix_hint: None, - }); - } - } - Err(err) => { - checks.push(PrereqCheck { - name: "Device matrix".to_string(), - passed: false, - detail: Some(err.to_string()), - fix_hint: Some(format!( - "Fix or regenerate device matrix at {}", - cfg.device_matrix.display() - )), - }); - } - } - - match resolve_browserstack_credentials(Some(&cfg.browserstack)) { - Ok(creds) => checks.push(PrereqCheck { - name: "BrowserStack credentials".to_string(), - passed: true, - detail: Some(format!("user {}", creds.username)), - fix_hint: None, - }), - Err(err) => checks.push(PrereqCheck { - name: "BrowserStack credentials".to_string(), - passed: false, - detail: Some(err.to_string()), - fix_hint: Some("Set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY".to_string()), - }), - } - } - - let issues = collect_issues(&checks); - match format { - CheckOutputFormat::Text => print_check_results_text(&checks, &issues), - CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?, - } - - if issues.is_empty() { - Ok(()) - } else { - bail!( - "{} issue(s) found. Fix them and rerun 'cargo mobench config validate'.", - issues.len() - ) - } -} - -fn cmd_doctor( - target: SdkTarget, - config_path: Option<&Path>, - device_matrix_path: Option<&Path>, - device_tags: Vec<String>, - browserstack: bool, - format: CheckOutputFormat, -) -> Result<()> { - let mut checks = collect_prereq_checks(target); - - let mut config: Option<BenchConfig> = None; - if let Some(path) = config_path { - match load_config(path) { - Ok(cfg) => { - checks.push(PrereqCheck { - name: "Run config".to_string(), - passed: true, - detail: Some(path.display().to_string()), - fix_hint: None, - }); - config = Some(cfg); - } - Err(err) => { - checks.push(PrereqCheck { - name: "Run config".to_string(), - passed: false, - detail: Some(err.to_string()), - fix_hint: Some(format!("Fix or regenerate config at {}", path.display())), - }); - } - } - } else { - checks.push(PrereqCheck { - name: "Run config".to_string(), - passed: true, - detail: Some("skipped (no --config)".to_string()), - fix_hint: None, - }); - } - - let resolved_matrix_path = device_matrix_path - .map(PathBuf::from) - .or_else(|| config.as_ref().map(|cfg| cfg.device_matrix.clone())); - let resolved_tags = if !device_tags.is_empty() { - Some(device_tags) - } else { - config.as_ref().and_then(|cfg| cfg.device_tags.clone()) - }; - - if resolved_matrix_path.is_none() && resolved_tags.as_ref().is_some_and(|tags| !tags.is_empty()) - { - checks.push(PrereqCheck { - name: "Device matrix".to_string(), - passed: false, - detail: Some("device tags provided without a matrix file".to_string()), - fix_hint: Some( - "Provide --device-matrix or set device_matrix in the config".to_string(), - ), - }); - } else if let Some(path) = resolved_matrix_path.as_deref() { - match load_device_matrix(path) { - Ok(matrix) => { - if let Some(tags) = resolved_tags.as_ref().filter(|tags| !tags.is_empty()) { - if let Err(err) = filter_devices_by_tags(matrix.devices, tags) { - checks.push(PrereqCheck { - name: "Device matrix".to_string(), - passed: false, - detail: Some(err.to_string()), - fix_hint: Some(format!( - "Update tags in {} or adjust --device-tags", - path.display() - )), - }); - } else { - checks.push(PrereqCheck { - name: "Device matrix".to_string(), - passed: true, - detail: Some(format!("{} (tags: {})", path.display(), tags.join(", "))), - fix_hint: None, - }); - } - } else { - checks.push(PrereqCheck { - name: "Device matrix".to_string(), - passed: true, - detail: Some(path.display().to_string()), - fix_hint: None, - }); - } - } - Err(err) => checks.push(PrereqCheck { - name: "Device matrix".to_string(), - passed: false, - detail: Some(err.to_string()), - fix_hint: Some(format!( - "Fix or regenerate device matrix at {}", - path.display() - )), - }), - } - } else { - checks.push(PrereqCheck { - name: "Device matrix".to_string(), - passed: true, - detail: Some("skipped (no --device-matrix)".to_string()), - fix_hint: None, - }); - } - - if browserstack { - let cfg_ref = config.as_ref().map(|cfg| &cfg.browserstack); - match resolve_browserstack_credentials(cfg_ref) { - Ok(creds) => checks.push(PrereqCheck { - name: "BrowserStack credentials".to_string(), - passed: true, - detail: Some(format!("user {}", creds.username)), - fix_hint: None, - }), - Err(err) => checks.push(PrereqCheck { - name: "BrowserStack credentials".to_string(), - passed: false, - detail: Some(err.to_string()), - fix_hint: Some("Set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY".to_string()), - }), - } - } else { - checks.push(PrereqCheck { - name: "BrowserStack credentials".to_string(), - passed: true, - detail: Some("skipped (--browserstack=false)".to_string()), - fix_hint: None, - }); - } - - let issues = collect_issues(&checks); - match format { - CheckOutputFormat::Text => print_check_results_text(&checks, &issues), - CheckOutputFormat::Json => print_check_results_json(&checks, &issues)?, - } - - if issues.is_empty() { - Ok(()) - } else { - bail!( - "{} issue(s) found. Fix them and rerun 'cargo mobench doctor'.", - issues.len() - ) - } -} - -fn collect_prereq_checks(target: SdkTarget) -> Vec<PrereqCheck> { - let mut checks: Vec<PrereqCheck> = Vec::new(); - checks.push(check_cargo()); - checks.push(check_rustup()); - checks.push(check_rustc_msrv()); - - match target { - SdkTarget::Android => { - println!("Checking prerequisites for Android...\n"); - extend_android_prereq_checks(&mut checks); - } - SdkTarget::Ios => { - println!("Checking prerequisites for iOS...\n"); - checks.push(check_xcode()); - checks.push(check_xcodegen()); - checks.push(check_rust_target("aarch64-apple-ios")); - checks.push(check_rust_target("aarch64-apple-ios-sim")); - checks.push(check_rust_target("x86_64-apple-ios")); - } - SdkTarget::Both => { - println!("Checking prerequisites for Android and iOS...\n"); - extend_android_prereq_checks(&mut checks); - checks.push(check_xcode()); - checks.push(check_xcodegen()); - checks.push(check_rust_target("aarch64-apple-ios")); - checks.push(check_rust_target("aarch64-apple-ios-sim")); - checks.push(check_rust_target("x86_64-apple-ios")); - } - } - - checks -} - -const DEFAULT_ANDROID_DOCTOR_RUST_TARGETS: &[&str] = &["aarch64-linux-android"]; -const WORKSPACE_MSRV: &str = "1.85"; - -fn extend_android_prereq_checks(checks: &mut Vec<PrereqCheck>) { - checks.push(check_android_ndk_home()); - checks.push(check_cargo_ndk()); - for target in DEFAULT_ANDROID_DOCTOR_RUST_TARGETS { - checks.push(check_rust_target(target)); - } - checks.push(check_jdk()); -} - -fn collect_issues(checks: &[PrereqCheck]) -> Vec<ValidationIssue> { - let mut issues = Vec::new(); - for check in checks { - if !check.passed - && let Some(ref fix) = check.fix_hint - { - issues.push(ValidationIssue { - category: issue_category_for_check(check), - check: check.name.clone(), - detail: check.detail.clone(), - fix_hint: fix.clone(), - }); - } - } - issues -} - -fn issue_category_for_check(check: &PrereqCheck) -> ContractErrorCategory { - match check.name.as_str() { - "Run config" | "Device matrix" => ContractErrorCategory::Config, - "BrowserStack credentials" => ContractErrorCategory::Provider, - _ => ContractErrorCategory::Preflight, - } -} - -#[derive(Debug, Clone, Serialize)] -struct PrereqCheck { - name: String, - passed: bool, - detail: Option<String>, - fix_hint: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -struct ValidationIssue { - category: ContractErrorCategory, - check: String, - detail: Option<String>, - fix_hint: String, -} - -fn print_check_results_text(checks: &[PrereqCheck], issues: &[ValidationIssue]) { - for check in checks { - let status = if check.passed { "\u{2713}" } else { "\u{2717}" }; - let detail = check.detail.as_deref().unwrap_or(""); - let category = if check.passed { - None - } else { - Some(issue_category_for_check(check)) - }; - if detail.is_empty() { - if let Some(category) = category { - println!("{} {} [{}]", status, check.name, category_slug(category)); - } else { - println!("{} {}", status, check.name); - } - } else { - if let Some(category) = category { - println!( - "{} {} [{}] ({})", - status, - check.name, - category_slug(category), - detail - ); - } else { - println!("{} {} ({})", status, check.name, detail); - } - } - } - - if !issues.is_empty() { - println!("\nTo fix:"); - for issue in issues { - println!(" * [{}] {}", category_slug(issue.category), issue.fix_hint); - } - println!(); - let failed_count = checks.iter().filter(|c| !c.passed).count(); - println!("{} issue(s) found.", failed_count); - } else { - println!("\nAll prerequisites satisfied!"); - } -} - -fn print_check_results_json(checks: &[PrereqCheck], issues: &[ValidationIssue]) -> Result<()> { - let output = render_check_results_json(checks, issues); - println!("{}", serde_json::to_string_pretty(&output)?); - Ok(()) -} - -fn render_check_results_json(checks: &[PrereqCheck], issues: &[ValidationIssue]) -> Value { - json!({ - "checks": checks, - "issues": issues, - "all_passed": checks.iter().all(|c| c.passed), - "passed_count": checks.iter().filter(|c| c.passed).count(), - "failed_count": checks.iter().filter(|c| !c.passed).count(), - }) -} - -fn category_slug(category: ContractErrorCategory) -> &'static str { - match category { - ContractErrorCategory::Config => "config_error", - ContractErrorCategory::Preflight => "preflight_error", - ContractErrorCategory::Provider => "provider_error", - ContractErrorCategory::Build => "build_error", - ContractErrorCategory::Benchmark => "benchmark_error", - } -} - -fn check_cargo() -> PrereqCheck { - let result = std::process::Command::new("cargo") - .arg("--version") - .output(); - - match result { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - PrereqCheck { - name: "cargo installed".to_string(), - passed: true, - detail: Some(version), - fix_hint: None, - } - } - _ => PrereqCheck { - name: "cargo installed".to_string(), - passed: false, - detail: None, - fix_hint: Some("Install Rust: https://rustup.rs".to_string()), - }, - } -} - -fn check_rustup() -> PrereqCheck { - let result = std::process::Command::new("rustup") - .arg("--version") - .output(); - - match result { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout) - .lines() - .next() - .unwrap_or("") - .trim() - .to_string(); - PrereqCheck { - name: "rustup installed".to_string(), - passed: true, - detail: Some(version), - fix_hint: None, - } - } - _ => PrereqCheck { - name: "rustup installed".to_string(), - passed: false, - detail: None, - fix_hint: Some("Install rustup: https://rustup.rs".to_string()), - }, - } -} - -fn check_rustc_msrv() -> PrereqCheck { - match command_version_line("rustc", &["--version"]) { - Some(version) if rustc_version_meets_msrv(&version, WORKSPACE_MSRV) => PrereqCheck { - name: "rustc MSRV".to_string(), - passed: true, - detail: Some(format!("{version} (requires >= {WORKSPACE_MSRV})")), - fix_hint: None, - }, - Some(version) => PrereqCheck { - name: "rustc MSRV".to_string(), - passed: false, - detail: Some(format!("{version} (requires >= {WORKSPACE_MSRV})")), - fix_hint: Some(format!( - "Update Rust: rustup update stable (MSRV {WORKSPACE_MSRV})" - )), - }, - None => PrereqCheck { - name: "rustc MSRV".to_string(), - passed: false, - detail: Some("could not run rustc --version".to_string()), - fix_hint: Some("Install Rust: https://rustup.rs".to_string()), - }, - } -} - -fn rustc_version_meets_msrv(version_line: &str, msrv: &str) -> bool { - let Some(actual) = parse_rust_version(version_line) else { - return false; - }; - let Some(required) = parse_rust_version(msrv) else { - return false; - }; - actual >= required -} - -fn parse_rust_version(input: &str) -> Option<(u32, u32, u32)> { - let version = input - .split_whitespace() - .find(|part| part.chars().next().is_some_and(|ch| ch.is_ascii_digit()))?; - let mut parts = version.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next()?.parse().ok()?; - let patch = parts - .next() - .and_then(|part| part.split('-').next()) - .unwrap_or("0") - .parse() - .ok()?; - Some((major, minor, patch)) -} - -fn check_android_ndk_home() -> PrereqCheck { - match env::var("ANDROID_NDK_HOME") { - Ok(path) if !path.is_empty() => { - let path_exists = Path::new(&path).exists(); - if path_exists { - PrereqCheck { - name: "ANDROID_NDK_HOME set".to_string(), - passed: true, - detail: Some(path), - fix_hint: None, - } - } else { - PrereqCheck { - name: "ANDROID_NDK_HOME set".to_string(), - passed: false, - detail: Some(format!("path does not exist: {}", path)), - fix_hint: Some("Set ANDROID_NDK_HOME to a valid NDK path: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/<version>".to_string()), - } - } - } - _ => PrereqCheck { - name: "ANDROID_NDK_HOME set".to_string(), - passed: false, - detail: None, - fix_hint: Some( - "Set ANDROID_NDK_HOME: export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/<version>" - .to_string(), - ), - }, - } -} - -fn check_cargo_ndk() -> PrereqCheck { - let result = std::process::Command::new("cargo") - .args(["ndk", "--version"]) - .output(); - - match result { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - PrereqCheck { - name: "cargo-ndk installed".to_string(), - passed: true, - detail: Some(version), - fix_hint: None, - } - } - _ => PrereqCheck { - name: "cargo-ndk installed".to_string(), - passed: false, - detail: None, - fix_hint: Some("Install cargo-ndk: cargo install cargo-ndk".to_string()), - }, - } -} - -fn check_rust_target(target: &str) -> PrereqCheck { - let result = std::process::Command::new("rustup") - .args(["target", "list", "--installed"]) - .output(); - - match result { - Ok(output) if output.status.success() => { - let installed = String::from_utf8_lossy(&output.stdout); - let has_target = installed.lines().any(|line| line.trim() == target); - if has_target { - PrereqCheck { - name: format!("Rust target: {}", target), - passed: true, - detail: None, - fix_hint: None, - } - } else { - PrereqCheck { - name: format!("Rust target: {}", target), - passed: false, - detail: Some("not installed".to_string()), - fix_hint: Some(format!("Install target: rustup target add {}", target)), - } - } - } - _ => PrereqCheck { - name: format!("Rust target: {}", target), - passed: false, - detail: Some("could not check".to_string()), - fix_hint: Some(format!("Install target: rustup target add {}", target)), - }, - } -} - -fn check_jdk() -> PrereqCheck { - // Try java -version - let result = std::process::Command::new("java").arg("-version").output(); - - match result { - Ok(output) => { - // Java outputs version to stderr - let version_output = String::from_utf8_lossy(&output.stderr); - let version_line = version_output.lines().next().unwrap_or(""); - - if output.status.success() || !version_line.is_empty() { - PrereqCheck { - name: "JDK installed".to_string(), - passed: true, - detail: Some(version_line.trim().to_string()), - fix_hint: None, - } - } else { - PrereqCheck { - name: "JDK installed".to_string(), - passed: false, - detail: None, - fix_hint: Some("Install JDK 17+: brew install openjdk@17".to_string()), - } - } - } - Err(_) => PrereqCheck { - name: "JDK installed".to_string(), - passed: false, - detail: None, - fix_hint: Some("Install JDK 17+: brew install openjdk@17".to_string()), - }, - } -} - -fn check_xcode() -> PrereqCheck { - let result = std::process::Command::new("xcodebuild") - .arg("-version") - .output(); - - match result { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout) - .lines() - .next() - .unwrap_or("") - .trim() - .to_string(); - PrereqCheck { - name: "Xcode installed".to_string(), - passed: true, - detail: Some(version), - fix_hint: None, - } - } - _ => PrereqCheck { - name: "Xcode installed".to_string(), - passed: false, - detail: None, - fix_hint: Some( - "Install Xcode from the App Store or run: xcode-select --install".to_string(), - ), - }, - } -} - -fn check_xcodegen() -> PrereqCheck { - let result = std::process::Command::new("xcodegen") - .arg("--version") - .output(); - - match result { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - PrereqCheck { - name: "xcodegen installed".to_string(), - passed: true, - detail: Some(version), - fix_hint: None, - } - } - _ => PrereqCheck { - name: "xcodegen installed".to_string(), - passed: false, - detail: None, - fix_hint: Some("Install xcodegen: brew install xcodegen".to_string()), - }, - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..5b00488 --- /dev/null +++ b/deny.toml @@ -0,0 +1,40 @@ +[advisories] +db-urls = ["https://github.com/rustsec/advisory-db"] +ignore = [] +unmaintained = "workspace" +yanked = "deny" + +[licenses] +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Zlib", +] +confidence-threshold = 0.8 +exceptions = [ + { allow = ["CDDL-1.0"], crate = "inferno" }, +] +unused-allowed-license = "warn" + +[licenses.private] +ignore = true +registries = [] + +[bans] +multiple-versions = "warn" +wildcards = "allow" +highlight = "all" +workspace-default-features = "allow" +external-default-features = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docs/codebase/ARCHITECTURE.md b/docs/codebase/ARCHITECTURE.md index 887c992..7549c55 100644 --- a/docs/codebase/ARCHITECTURE.md +++ b/docs/codebase/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -Updated: 2026-04-01 +Updated: 2026-04-26 ## System shape @@ -36,7 +36,9 @@ Responsibilities: - run local native profiling and summarize profile sessions Important modules: -- `lib.rs`: command parsing and benchmark/CI orchestration +- `lib.rs`: crate facade, command dispatch, benchmark/CI orchestration, and shared report helpers +- `cli.rs`: clap command surface and target/value enums +- `doctor.rs`: prerequisite checks, config validation, and machine-readable validation issue rendering - `profile.rs`: target resolution, capture planning, capture execution, manifest/summary writing - `flamegraph_viewer.rs`: focused/full folded-stack derivation, SVG retinting, and interactive viewer document generation - `flamegraph_viewer_template.html`: browser-side flamegraph shell, timeline interactions, legend/fullscreen UX, and metadata layout diff --git a/docs/codebase/STRUCTURE.md b/docs/codebase/STRUCTURE.md index ccc276a..535a234 100644 --- a/docs/codebase/STRUCTURE.md +++ b/docs/codebase/STRUCTURE.md @@ -1,6 +1,6 @@ # Structure -Updated: 2026-04-01 +Updated: 2026-04-26 ## Workspace layout @@ -32,7 +32,9 @@ mobile-bench-rs/ ### CLI -- `crates/mobench/src/lib.rs`: clap surface and benchmark/CI command orchestration +- `crates/mobench/src/lib.rs`: crate facade, command dispatch, benchmark/CI orchestration, and shared report helpers +- `crates/mobench/src/cli.rs`: clap command surface, CLI argument structs, and target value enums +- `crates/mobench/src/doctor.rs`: prerequisite, `doctor`, `check`, and config validation checks - `crates/mobench/src/profile.rs`: local native profiling flow, manifests, summaries, artifact contracts - `crates/mobench/src/flamegraph_viewer.rs`: focused/full flamegraph generation, SVG retinting, and HTML viewer assembly - `crates/mobench/src/flamegraph_viewer_template.html`: interactive flamegraph shell, timeline mode, and keyboard/fullscreen controls @@ -85,7 +87,9 @@ target/mobench/profile/<run-id>/ ## Where to add new work -- new CLI/report/profile behavior: `crates/mobench/src/` +- new CLI arguments: `crates/mobench/src/cli.rs` +- new prerequisite/config validation behavior: `crates/mobench/src/doctor.rs` +- new CLI orchestration, report, or profile behavior: `crates/mobench/src/` - new SDK/runtime/build/codegen behavior: `crates/mobench-sdk/src/` - benchmark registration semantics: `crates/mobench-macros/src/lib.rs` - template/runtime UX changes: `templates/` first, then mirror into `crates/mobench-sdk/templates/` diff --git a/docs/specs/production-readiness-roadmap.md b/docs/specs/production-readiness-roadmap.md index f86d72f..e1c1165 100644 --- a/docs/specs/production-readiness-roadmap.md +++ b/docs/specs/production-readiness-roadmap.md @@ -107,10 +107,10 @@ announcement unless they expose real launch risk. - [ ] Profile CLI hot paths. - [ ] Improve APK/IPA build caching. - [ ] Parallelize independent build, fetch, and report steps. -- [ ] Add host benchmarks for parser, reporting, and profile code. +- [x] Add host benchmarks for parser, reporting, and profile code. - [ ] Add fuzz or property tests for config and device matrix parsing. -- [ ] Add public API compatibility checks with `cargo-semver-checks`. -- [ ] Add dependency and license policy checks with `cargo-deny`. +- [x] Add public API compatibility checks with `cargo-semver-checks`. +- [x] Add dependency and license policy checks with `cargo-deny`. - [ ] Consider narrower crate features to reduce dependency footprint. - [ ] Add machine-readable trace/event output for CI debugging. - [ ] Prepare landing-page-specific assets from README diagrams. @@ -125,13 +125,16 @@ but no dedicated `benches/`, fuzz target, `cargo-semver-checks`, or `cargo-deny` policy yet. P0 after first public promotion: -- Add `cargo-semver-checks` for `mobench-sdk` and `mobench-macros`. - This protects the library-facing surface that adopters will depend on first. -- Add `cargo-deny` with explicit license/advisory/source policy. - This is low product risk but high trust value before broader third-party use. -- Add host benchmarks for parser, reporting, profile manifest rendering, and - BrowserStack log extraction. These give a baseline before tuning or - parallelizing anything. +- Completed: add `cargo-semver-checks` for `mobench-sdk` and `mobench`. + `mobench-macros` is a proc-macro target, so `cargo-semver-checks` does not + treat it as a normal library API surface; keep it covered by rustdoc, clippy, + tests, and publish dry-runs. +- Completed: add `cargo-deny` with explicit license/advisory/source policy. + Current policy denies vulnerable and yanked crates, restricts registries, and + documents the direct Inferno `CDDL-1.0` license exception. +- Completed: add host benchmarks for parser, reporting, profile manifest + rendering, and BrowserStack log extraction. These give a baseline before + tuning or parallelizing anything. P1 once adoption feedback identifies slow paths: - Profile CLI hot paths with real examples: config resolution, template @@ -156,9 +159,9 @@ P2 launch-asset and dependency-footprint follow-up: enough for near-term human debugging. Recommended first hardening PR: -1. Add `cargo-semver-checks`, `cargo-deny`, and their CI jobs. -2. Add a small Criterion suite for config parsing, summary rendering, CSV - rendering, profile manifest loading, and BrowserStack log extraction. +1. Run the new CI policy checks and host benchmark smoke on a fresh PR. +2. Review duplicate dependency warnings from `cargo-deny`, especially `toml`, + `getrandom`, and Windows support crates. 3. Use the benchmark results to decide whether build caching or parallelization should come next. From b1c31fddeb360c672a7facd4ad5504ca3dc86e35 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Sun, 26 Apr 2026 13:25:47 +0200 Subject: [PATCH 186/196] fix: harden benchmark release paths Made-with: Cursor --- Cargo.lock | 103 +++- crates/mobench-macros/Cargo.toml | 3 + crates/mobench-macros/tests/ui.rs | 5 + .../tests/ui/async_benchmark.rs | 6 + crates/mobench-sdk/src/builders/common.rs | 56 ++ crates/mobench-sdk/src/codegen.rs | 40 ++ crates/mobench-sdk/src/timing.rs | 479 +++++++++++++----- .../src/main/java/MainActivity.kt.template | 83 ++- .../BenchRunner/BenchRunnerFFI.swift.template | 57 ++- .../BenchRunnerUITests.swift.template | 1 + crates/mobench/src/browserstack.rs | 48 +- crates/mobench/src/cli.rs | 12 +- crates/mobench/src/lib.rs | 179 +++++-- 13 files changed, 879 insertions(+), 193 deletions(-) create mode 100644 crates/mobench-macros/tests/ui.rs create mode 100644 crates/mobench-macros/tests/ui/async_benchmark.rs diff --git a/Cargo.lock b/Cargo.lock index 001a182..3068b73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1206,7 +1206,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.35" +version = "0.1.36" dependencies = [ "anyhow", "clap", @@ -1232,16 +1232,17 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.35" +version = "0.1.36" dependencies = [ "proc-macro2", "quote", "syn", + "trybuild", ] [[package]] name = "mobench-sdk" -version = "0.1.35" +version = "0.1.36" dependencies = [ "include_dir", "inventory", @@ -1815,7 +1816,7 @@ dependencies = [ [[package]] name = "sample-fns" -version = "0.1.35" +version = "0.1.36" dependencies = [ "camino", "mobench-sdk", @@ -1914,6 +1915,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2060,6 +2070,12 @@ dependencies = [ "syn", ] +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" version = "3.23.0" @@ -2073,6 +2089,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -2237,11 +2262,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -2251,6 +2291,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -2259,10 +2308,19 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.14", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.2", ] [[package]] @@ -2271,6 +2329,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.2" @@ -2383,6 +2447,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 1.1.0+spec-1.1.0", +] + [[package]] name = "typenum" version = "1.19.0" @@ -2933,6 +3012,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/crates/mobench-macros/Cargo.toml b/crates/mobench-macros/Cargo.toml index f9e61da..e4835fe 100644 --- a/crates/mobench-macros/Cargo.toml +++ b/crates/mobench-macros/Cargo.toml @@ -28,3 +28,6 @@ proc-macro = true syn.workspace = true quote.workspace = true proc-macro2.workspace = true + +[dev-dependencies] +trybuild = "1" diff --git a/crates/mobench-macros/tests/ui.rs b/crates/mobench-macros/tests/ui.rs new file mode 100644 index 0000000..72d8763 --- /dev/null +++ b/crates/mobench-macros/tests/ui.rs @@ -0,0 +1,5 @@ +#[test] +fn benchmark_macro_rejects_async_functions() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/async_benchmark.rs"); +} diff --git a/crates/mobench-macros/tests/ui/async_benchmark.rs b/crates/mobench-macros/tests/ui/async_benchmark.rs new file mode 100644 index 0000000..7502e00 --- /dev/null +++ b/crates/mobench-macros/tests/ui/async_benchmark.rs @@ -0,0 +1,6 @@ +use mobench_macros::benchmark; + +#[benchmark] +async fn async_work_is_not_a_benchmark() {} + +fn main() {} diff --git a/crates/mobench-sdk/src/builders/common.rs b/crates/mobench-sdk/src/builders/common.rs index fd14626..f8c7b49 100644 --- a/crates/mobench-sdk/src/builders/common.rs +++ b/crates/mobench-sdk/src/builders/common.rs @@ -330,6 +330,30 @@ pub fn embed_bench_spec<S: serde::Serialize>( let spec_json = serde_json::to_string_pretty(spec) .map_err(|e| BenchError::Build(format!("Failed to serialize bench spec: {}", e)))?; + // Generated Android/iOS projects include these output-local resources even + // before their app scaffolds exist, which keeps clean first runs deterministic. + for spec_path in [ + output_dir.join("target/mobile-spec/android/bench_spec.json"), + output_dir.join("target/mobile-spec/ios/bench_spec.json"), + ] { + if let Some(parent) = spec_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + BenchError::Build(format!( + "Failed to create bench spec directory at {}: {}", + parent.display(), + e + )) + })?; + } + std::fs::write(&spec_path, &spec_json).map_err(|e| { + BenchError::Build(format!( + "Failed to write bench spec to {}: {}", + spec_path.display(), + e + )) + })?; + } + // Android: Write to assets directory let android_assets_dir = output_dir.join("android/app/src/main/assets"); if output_dir.join("android").exists() { @@ -760,6 +784,38 @@ members = ["crates/*"] assert!(meta.build_time.ends_with('Z')); } + #[test] + fn embed_bench_spec_writes_first_run_mobile_spec_locations() { + let temp_dir = + std::env::temp_dir().join(format!("mobench-test-embed-spec-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let spec = EmbeddedBenchSpec { + function: "test_crate::first_run".to_string(), + iterations: 7, + warmup: 1, + }; + + embed_bench_spec(&temp_dir, &spec).expect("embed spec"); + + let android_spec = temp_dir.join("target/mobile-spec/android/bench_spec.json"); + let ios_spec = temp_dir.join("target/mobile-spec/ios/bench_spec.json"); + assert!( + android_spec.exists(), + "Android Gradle templates read this first-run spec path" + ); + assert!( + ios_spec.exists(), + "iOS project templates read this first-run spec path" + ); + + let contents = std::fs::read_to_string(android_spec).unwrap(); + assert!(contents.contains("test_crate::first_run")); + + std::fs::remove_dir_all(&temp_dir).unwrap(); + } + #[test] fn test_days_to_ymd_epoch() { // Day 0 should be January 1, 1970 diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index f46cdf4..14e7039 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -1350,6 +1350,14 @@ mod tests { build_gradle.contains("dev.world.mybenchproject"), "build.gradle should contain sanitized package name 'dev.world.mybenchproject'" ); + assert!( + !build_gradle.contains("testBuildType \"release\""), + "debug builds should be able to produce assembleDebugAndroidTest" + ); + assert!( + build_gradle.contains("mobenchTestBuildType"), + "release builds should be able to request assembleReleaseAndroidTest" + ); let manifest = fs::read_to_string(android_dir.join("app/src/main/AndroidManifest.xml")).unwrap(); @@ -1461,6 +1469,18 @@ mod tests { "Android template should not require generated bindings to expose processPeakMemoryKb" ); assert!(android.contains("optionalProcessPeakMemoryKb(sample)")); + assert!( + !android.contains("sample.cpuTimeMs"), + "Android template should tolerate BenchSample without cpuTimeMs" + ); + assert!( + !android.contains("sample.peakMemoryKb"), + "Android template should tolerate BenchSample without peakMemoryKb" + ); + assert!( + !android.contains("report.phases"), + "Android template should tolerate BenchReport without phases" + ); assert!(android.contains("ProcessMemorySampler")); assert!(android.contains("sampleIntervalMs: Long = 1000L")); assert!(android.contains("/proc/self/smaps_rollup")); @@ -1480,6 +1500,14 @@ mod tests { assert!(android_test.contains("TimeUnit.SECONDS.toMillis(10)")); assert!(android_test.contains("activity.isBenchmarkComplete()")); + let ios_test = include_str!( + "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template" + ); + assert!( + ios_test.contains("\\\"error\\\""), + "iOS XCUITest template should fail when the benchmark report is an error payload" + ); + let android_manifest = include_str!("../templates/android/app/src/main/AndroidManifest.xml"); assert!(android_manifest.contains("android.permission.FOREGROUND_SERVICE")); @@ -1499,6 +1527,18 @@ mod tests { "iOS template should not require generated bindings to expose processPeakMemoryKb" ); assert!(ios.contains("optionalProcessPeakMemoryKb(sample)")); + assert!( + !ios.contains("sample.cpuTimeMs"), + "iOS template should tolerate BenchSample without cpuTimeMs" + ); + assert!( + !ios.contains("sample.peakMemoryKb"), + "iOS template should tolerate BenchSample without peakMemoryKb" + ); + assert!( + !ios.contains("report.phases"), + "iOS template should tolerate BenchReport without phases" + ); assert!(ios.contains("compactMap { optionalProcessPeakMemoryKb($0) }")); assert!(ios.contains("ProcessMemorySampler")); assert!(ios.contains("currentProcessResidentMemoryKb")); diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index b75818d..c21a241 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -64,11 +64,7 @@ use serde::{Deserialize, Serialize}; use std::cell::RefCell; -use std::sync::{ - Arc, - atomic::{AtomicBool, AtomicU64, Ordering}, - mpsc, -}; +use std::sync::{Arc, mpsc}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; use thiserror::Error; @@ -171,8 +167,10 @@ impl BenchSpec { /// A single timing sample from a benchmark iteration. /// -/// Contains the elapsed time in nanoseconds for one execution of the -/// benchmark function. +/// Holds the elapsed wall time in nanoseconds plus optional per-iteration +/// resource metrics (CPU time, peak memory growth, and process peak memory). +/// The optional fields are only populated on platforms where the harness can +/// capture them and are skipped from the JSON output when absent. /// /// # Example /// @@ -268,9 +266,17 @@ pub struct BenchReport { pub samples: Vec<BenchSample>, /// Optional semantic phase timings captured during measured iterations. + /// + /// Defaults to an empty vector when deserializing reports produced by + /// older mobench versions that did not emit phase data. + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub phases: Vec<SemanticPhase>, /// Exact harness timeline spans in execution order. + /// + /// Defaults to an empty vector when deserializing reports produced by + /// older mobench versions that did not emit timeline data. + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub timeline: Vec<HarnessTimelineSpan>, } @@ -419,6 +425,7 @@ impl BenchReport { /// This is an explicit alias for [`BenchReport::peak_memory_kb`] to make the /// growth semantics clear while preserving the legacy wire field. #[must_use] + #[doc(alias = "peak_memory_kb")] pub fn peak_memory_growth_kb(&self) -> Option<u64> { self.peak_memory_kb() } @@ -614,7 +621,19 @@ trait ResourceMonitor { } #[derive(Default)] -struct DefaultResourceMonitor; +struct DefaultResourceMonitor { + /// Lazily-initialized long-lived sampler shared across measured iterations. + /// + /// We pay thread-spawn cost once per benchmark function rather than per + /// iteration. On constrained mobile devices (Android/Bionic) thread + /// creation is significantly more expensive than on desktop Linux, and + /// 1000+ iteration benchmarks would otherwise spawn 1000+ throwaway + /// threads. + memory_sampler: Option<PersistentMemorySampler>, + /// Set after the first attempt to start the sampler so we do not retry + /// on platforms where the sampler is not supported. + sampler_init_attempted: bool, +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ProcessCpuTimeSnapshot { @@ -638,16 +657,26 @@ impl ProcessCpuTimeSnapshot { struct DefaultResourceToken { cpu_time_start: Option<ProcessCpuTimeSnapshot>, - memory_sampler: Option<MemoryPeakSampler>, + /// True if the persistent sampler accepted a `Begin` for this iteration + /// and we therefore expect a corresponding result on `finish`. + has_memory_window: bool, } impl ResourceMonitor for DefaultResourceMonitor { type Token = DefaultResourceToken; fn start(&mut self) -> Self::Token { + if !self.sampler_init_attempted { + self.memory_sampler = PersistentMemorySampler::start(); + self.sampler_init_attempted = true; + } + let has_memory_window = self + .memory_sampler + .as_ref() + .is_some_and(PersistentMemorySampler::begin_window); Self::Token { cpu_time_start: current_process_cpu_time(), - memory_sampler: MemoryPeakSampler::start(), + has_memory_window, } } @@ -657,7 +686,13 @@ impl ResourceMonitor for DefaultResourceMonitor { .zip(current_process_cpu_time()) .and_then(|(start, end)| process_cpu_delta_ms(start, end)); - let memory_peak = token.memory_sampler.and_then(MemoryPeakSampler::stop); + let memory_peak = if token.has_memory_window { + self.memory_sampler + .as_ref() + .and_then(PersistentMemorySampler::end_window) + } else { + None + }; IterationResourceUsage { cpu_time_ms, @@ -701,11 +736,15 @@ fn timeval_to_ns(value: libc::timeval) -> Option<u64> { #[cfg(unix)] fn current_process_cpu_time() -> Option<ProcessCpuTimeSnapshot> { let mut usage = std::mem::MaybeUninit::<libc::rusage>::uninit(); + // SAFETY: `RUSAGE_SELF` is always a valid `who` value and the kernel + // writes a fully-initialized `rusage` into the provided pointer on + // success. We bail out via `rc != 0` before touching the buffer below. let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; if rc != 0 { return None; } + // SAFETY: `getrusage` returned 0, so the buffer is fully initialized. let usage = unsafe { usage.assume_init() }; ProcessCpuTimeSnapshot::from_rusage_timevals(usage.ru_utime, usage.ru_stime) } @@ -724,100 +763,149 @@ struct ProcessMemoryPeak { process_peak_kb: u64, } -struct MemoryPeakSampler { - baseline_kb: u64, - stop_flag: Arc<AtomicBool>, - peak_kb: Arc<AtomicU64>, - handle: JoinHandle<()>, +/// Long-lived memory sampler. Spawned once per benchmark function and reused +/// across every measured iteration via `begin_window` / `end_window`. +/// +/// Replaces the previous per-iteration design that spawned and joined a fresh +/// thread for every sample. On Android (Bionic) and iOS that thread-creation +/// overhead is non-trivial and would inflate harness wall time on +/// high-iteration runs. +struct PersistentMemorySampler { + cmd_tx: mpsc::SyncSender<SamplerCmd>, + result_rx: mpsc::Receiver<Option<ProcessMemoryPeak>>, + handle: Option<JoinHandle<()>>, +} + +enum SamplerCmd { + Begin(mpsc::SyncSender<bool>), + End, + Shutdown, } -impl MemoryPeakSampler { +impl PersistentMemorySampler { fn start() -> Option<Self> { Self::start_with_reader(Arc::new(current_process_memory_kb)) } fn start_with_reader(reader: MemoryReader) -> Option<Self> { - let stop_flag = Arc::new(AtomicBool::new(false)); - let peak_kb = Arc::new(AtomicU64::new(0)); - let (ready_tx, ready_rx) = mpsc::sync_channel(1); - let (baseline_tx, baseline_rx) = mpsc::sync_channel(1); - let sampler_stop = Arc::clone(&stop_flag); - let sampler_peak = Arc::clone(&peak_kb); - let sampler_reader = Arc::clone(&reader); + let (cmd_tx, cmd_rx) = mpsc::sync_channel::<SamplerCmd>(1); + let (result_tx, result_rx) = mpsc::sync_channel::<Option<ProcessMemoryPeak>>(1); + let (ready_tx, ready_rx) = mpsc::sync_channel::<()>(1); let handle = thread::Builder::new() .name("mobench-memory-sampler".to_string()) .spawn(move || { - // Touch the sampler thread's own stack and runtime state before the - // benchmark baseline is captured so its overhead is not reported as - // measured benchmark memory. - let _ = sampler_reader(); - let _ = ready_tx.send(()); - - let Some(baseline_kb) = baseline_rx.recv().ok().flatten() else { + // Touch the sampler thread's own stack and runtime state once + // before any window opens so its initialization cost cannot + // contaminate the first iteration's baseline measurement. + let _ = reader(); + if ready_tx.send(()).is_err() { return; - }; - sampler_peak.store(baseline_kb, Ordering::Release); - - while !sampler_stop.load(Ordering::Acquire) { - if let Some(current_kb) = sampler_reader() { - update_atomic_max(&sampler_peak, current_kb); - } - thread::sleep(MEMORY_SAMPLER_INTERVAL); } + drop(ready_tx); - if let Some(current_kb) = sampler_reader() { - update_atomic_max(&sampler_peak, current_kb); - } + Self::run(reader, &cmd_rx, &result_tx); }) .ok()?; if ready_rx.recv().is_err() { - stop_flag.store(true, Ordering::Release); + // Thread failed before sending readiness. Send Shutdown to make + // sure it does not get stuck on a later cmd recv, then join. + let _ = cmd_tx.send(SamplerCmd::Shutdown); let _ = handle.join(); return None; } - let baseline_kb = match reader() { - Some(value) => value, - None => { - let _ = baseline_tx.send(None); - stop_flag.store(true, Ordering::Release); - let _ = handle.join(); - return None; + Some(Self { + cmd_tx, + result_rx, + handle: Some(handle), + }) + } + + fn run( + reader: MemoryReader, + cmd_rx: &mpsc::Receiver<SamplerCmd>, + result_tx: &mpsc::SyncSender<Option<ProcessMemoryPeak>>, + ) { + while let Ok(cmd) = cmd_rx.recv() { + match cmd { + SamplerCmd::Begin(ack_tx) => { + let baseline = match reader() { + Some(v) => v, + None => { + let _ = ack_tx.send(false); + let _ = result_tx.send(None); + continue; + } + }; + if ack_tx.send(true).is_err() { + continue; + } + let mut peak = baseline; + let shutting_down = loop { + match cmd_rx.recv_timeout(MEMORY_SAMPLER_INTERVAL) { + Ok(SamplerCmd::End) => break false, + Ok(SamplerCmd::Shutdown) => break true, + // A stray Begin while a window is already open + // means the producer side desynced — preserve + // existing behavior by ignoring it. + Ok(SamplerCmd::Begin(ack_tx)) => { + let _ = ack_tx.send(false); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + if let Some(current) = reader() + && current > peak + { + peak = current; + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => break true, + } + }; + // One last sample after the window closes so a final + // allocation that happens between the last poll and the + // End command is still accounted for. + if let Some(current) = reader() + && current > peak + { + peak = current; + } + let _ = result_tx.send(Some(ProcessMemoryPeak { + growth_kb: peak.saturating_sub(baseline), + process_peak_kb: peak, + })); + if shutting_down { + return; + } + } + SamplerCmd::Shutdown => return, + // End without an active Begin — ignore. + SamplerCmd::End => {} } - }; - if baseline_tx.send(Some(baseline_kb)).is_err() { - stop_flag.store(true, Ordering::Release); - let _ = handle.join(); - return None; } + } - Some(Self { - baseline_kb, - stop_flag, - peak_kb, - handle, - }) + fn begin_window(&self) -> bool { + let (ack_tx, ack_rx) = mpsc::sync_channel(1); + self.cmd_tx + .send(SamplerCmd::Begin(ack_tx)) + .ok() + .and_then(|()| ack_rx.recv().ok()) + .unwrap_or(false) } - fn stop(self) -> Option<ProcessMemoryPeak> { - self.stop_flag.store(true, Ordering::Release); - let _ = self.handle.join(); - let peak_kb = self.peak_kb.load(Ordering::Acquire); - Some(ProcessMemoryPeak { - growth_kb: peak_kb.saturating_sub(self.baseline_kb), - process_peak_kb: peak_kb, - }) + fn end_window(&self) -> Option<ProcessMemoryPeak> { + self.cmd_tx.send(SamplerCmd::End).ok()?; + self.result_rx.recv().ok().flatten() } } -fn update_atomic_max(target: &AtomicU64, value: u64) { - let mut current = target.load(Ordering::Relaxed); - while value > current { - match target.compare_exchange_weak(current, value, Ordering::Relaxed, Ordering::Relaxed) { - Ok(_) => break, - Err(observed) => current = observed, +impl Drop for PersistentMemorySampler { + fn drop(&mut self) { + let _ = self.cmd_tx.send(SamplerCmd::Shutdown); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); } } } @@ -829,6 +917,8 @@ fn current_process_memory_kb() -> Option<u64> { .split_whitespace() .nth(1) .and_then(|value| value.parse::<u64>().ok())?; + // SAFETY: `_SC_PAGESIZE` is a valid sysconf selector on every supported + // POSIX target; sysconf has no side effects and reports failures via -1. let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; if page_size <= 0 { return None; @@ -841,7 +931,17 @@ fn current_process_memory_kb() -> Option<u64> { fn current_process_memory_kb() -> Option<u64> { let mut info = std::mem::MaybeUninit::<libc::mach_task_basic_info_data_t>::uninit(); let mut count = libc::MACH_TASK_BASIC_INFO_COUNT; + // `mach_task_self` is marked deprecated by libc in favor of + // `mach_task_self_`, but the replacement symbol is not yet exposed by the + // libc crate's iOS/macOS bindings (libc < 0.3). The deprecation is purely + // cosmetic — the function continues to be the documented way to obtain + // the current task port and is what Apple's headers expand the macro to. #[allow(deprecated)] + // SAFETY: `mach_task_self` always returns a valid task port for the + // current process. `MACH_TASK_BASIC_INFO` matches the + // `mach_task_basic_info_data_t` layout we pass; `count` carries the + // capacity in 32-bit words and is updated by the kernel on success. + // We check for `KERN_SUCCESS` before assuming the buffer is initialized. let rc = unsafe { libc::task_info( libc::mach_task_self(), @@ -854,6 +954,8 @@ fn current_process_memory_kb() -> Option<u64> { return None; } + // SAFETY: `task_info` returned `KERN_SUCCESS`, so the basic info struct + // is fully populated. let info = unsafe { info.assume_init() }; Some(info.resident_size / 1024) } @@ -1011,7 +1113,7 @@ pub fn run_closure<F>(spec: BenchSpec, f: F) -> Result<BenchReport, TimingError> where F: FnMut() -> Result<(), TimingError>, { - let mut monitor = DefaultResourceMonitor; + let mut monitor = DefaultResourceMonitor::default(); run_closure_with_monitor(spec, &mut monitor, f) } @@ -1115,7 +1217,7 @@ where S: FnOnce() -> T, F: FnMut(&T) -> Result<(), TimingError>, { - let mut monitor = DefaultResourceMonitor; + let mut monitor = DefaultResourceMonitor::default(); run_closure_with_setup_with_monitor(spec, &mut monitor, setup, move |input| f(input)) } @@ -1234,7 +1336,7 @@ where S: FnMut() -> T, F: FnMut(T) -> Result<(), TimingError>, { - let mut monitor = DefaultResourceMonitor; + let mut monitor = DefaultResourceMonitor::default(); run_closure_with_setup_per_iter_with_monitor(spec, &mut monitor, setup, f) } @@ -1364,7 +1466,7 @@ where F: FnMut(&T) -> Result<(), TimingError>, D: FnOnce(T), { - let mut monitor = DefaultResourceMonitor; + let mut monitor = DefaultResourceMonitor::default(); run_closure_with_setup_teardown_with_monitor( spec, &mut monitor, @@ -1409,44 +1511,48 @@ where None, ); - // Warmup phase - for iteration in 0..spec.warmup { - let phase_start = Instant::now(); - f(&input)?; - push_timeline_span( - &mut timeline, - harness_origin, - "warmup-benchmark", - phase_start, - Instant::now(), - Some(iteration), - ); - } + let result = (|| { + // Warmup phase + for iteration in 0..spec.warmup { + let phase_start = Instant::now(); + f(&input)?; + push_timeline_span( + &mut timeline, + harness_origin, + "warmup-benchmark", + phase_start, + Instant::now(), + Some(iteration), + ); + } - // Measurement phase - begin_semantic_phase_collection(); - let mut samples = Vec::with_capacity(spec.iterations as usize); - for iteration in 0..spec.iterations { - let (sample, start, end) = match measure_iteration(monitor, || f(&input)) { - Ok(measurement) => measurement, - Err(err) => { - let _ = finish_semantic_phase_collection(); - return Err(err); - } - }; - samples.push(sample); - push_timeline_span( - &mut timeline, - harness_origin, - "measured-benchmark", - start, - end, - Some(iteration), - ); - } - let phases = finish_semantic_phase_collection(); + // Measurement phase + begin_semantic_phase_collection(); + let mut samples = Vec::with_capacity(spec.iterations as usize); + for iteration in 0..spec.iterations { + let (sample, start, end) = match measure_iteration(monitor, || f(&input)) { + Ok(measurement) => measurement, + Err(err) => { + let _ = finish_semantic_phase_collection(); + return Err(err); + } + }; + samples.push(sample); + push_timeline_span( + &mut timeline, + harness_origin, + "measured-benchmark", + start, + end, + Some(iteration), + ); + } + let phases = finish_semantic_phase_collection(); - // Teardown phase - not timed + Ok((samples, phases)) + })(); + + // Teardown phase - not timed. It runs even when warmup/measurement fails. let teardown_start = Instant::now(); teardown(input); push_timeline_span( @@ -1458,6 +1564,7 @@ where None, ); + let (samples, phases) = result?; Ok(BenchReport { spec, samples, @@ -1750,6 +1857,24 @@ mod tests { assert_eq!(report.cpu_median_ms(), Some(6)); } + #[test] + fn setup_teardown_runs_teardown_when_warmup_fails() { + let spec = BenchSpec::new("teardown-on-error", 1, 1).unwrap(); + let mut teardown_calls = 0_u32; + + let result = run_closure_with_setup_teardown( + spec, + || vec![1_u8, 2, 3], + |_fixture| Err(TimingError::Execution("warmup failed".to_string())), + |_fixture| { + teardown_calls += 1; + }, + ); + + assert!(result.is_err()); + assert_eq!(teardown_calls, 1); + } + #[test] fn single_iteration_cpu_median_matches_the_measured_iteration() { let spec = BenchSpec::new("single", 1, 0).unwrap(); @@ -1836,6 +1961,8 @@ mod tests { use std::collections::VecDeque; use std::sync::{Arc, Mutex}; + // Queue: [80=startup warmup, 100=baseline-on-Begin, 140, 120, ...] + // After exhaustion the reader returns 120 forever, so peak stays 140. let samples = Arc::new(Mutex::new(VecDeque::from([ Some(80_u64), Some(100_u64), @@ -1851,8 +1978,9 @@ mod tests { .unwrap_or(Some(120)) }); - let sampler = MemoryPeakSampler::start_with_reader(reader).expect("sampler"); - let peak = sampler.stop().expect("peak memory"); + let sampler = PersistentMemorySampler::start_with_reader(reader).expect("sampler"); + assert!(sampler.begin_window()); + let peak = sampler.end_window().expect("peak memory"); assert_eq!( peak, @@ -1863,6 +1991,123 @@ mod tests { ); } + #[test] + fn persistent_memory_sampler_waits_for_baseline_before_begin_returns() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + + let call_count = Arc::new(Mutex::new(0_u32)); + let (baseline_entered_tx, baseline_entered_rx) = mpsc::sync_channel(1); + let (baseline_release_tx, baseline_release_rx) = mpsc::sync_channel(1); + let baseline_release_rx = Arc::new(Mutex::new(baseline_release_rx)); + let baseline_released = Arc::new(AtomicBool::new(false)); + + let reader_calls = Arc::clone(&call_count); + let reader_release = Arc::clone(&baseline_release_rx); + let reader = Arc::new(move || { + let mut calls = reader_calls.lock().expect("call count"); + *calls += 1; + let current = *calls; + drop(calls); + + if current == 2 { + baseline_entered_tx.send(()).expect("baseline entered"); + reader_release + .lock() + .expect("baseline release") + .recv() + .expect("release baseline"); + return Some(100); + } + + Some(120) + }); + + let released = Arc::clone(&baseline_released); + let release_handle = thread::spawn(move || { + baseline_entered_rx.recv().expect("baseline read started"); + thread::sleep(Duration::from_millis(20)); + released.store(true, Ordering::SeqCst); + baseline_release_tx.send(()).expect("release baseline"); + }); + + let sampler = PersistentMemorySampler::start_with_reader(reader).expect("sampler"); + assert!(sampler.begin_window()); + assert!( + baseline_released.load(Ordering::SeqCst), + "begin_window returned before the baseline sample completed" + ); + release_handle.join().expect("join release thread"); + + let peak = sampler.end_window().expect("peak memory"); + assert_eq!(peak.growth_kb, 20); + assert_eq!(peak.process_peak_kb, 120); + } + + #[test] + fn persistent_memory_sampler_supports_multiple_windows() { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + // First window baseline=200 peak=260 (growth=60). + // Second window baseline=190 peak=250 (growth=60). + let samples = Arc::new(Mutex::new(VecDeque::from([ + Some(50_u64), // startup warmup + Some(200_u64), // window 1 baseline + Some(260_u64), // window 1 peak + Some(190_u64), // window 2 baseline + Some(250_u64), // window 2 peak + ]))); + let reader_samples = Arc::clone(&samples); + let reader = Arc::new(move || { + reader_samples + .lock() + .expect("sample queue") + .pop_front() + .unwrap_or(Some(0)) + }); + + let sampler = PersistentMemorySampler::start_with_reader(reader).expect("sampler"); + + assert!(sampler.begin_window()); + let first = sampler.end_window().expect("first peak"); + assert_eq!(first.process_peak_kb, 260); + assert_eq!(first.growth_kb, 60); + + assert!(sampler.begin_window()); + let second = sampler.end_window().expect("second peak"); + assert_eq!(second.process_peak_kb, 250); + assert_eq!(second.growth_kb, 60); + } + + #[test] + fn bench_report_deserializes_legacy_payload_without_phases_or_timeline() { + // Wire format produced by mobench <= 0.1.34 (no phases / timeline / + // resource fields). Adding these fields to BenchReport must not + // break consumers that still emit the older shape. + let legacy = r#"{ + "spec": { "name": "legacy", "iterations": 2, "warmup": 0 }, + "samples": [ + { "duration_ns": 100 }, + { "duration_ns": 200 } + ] + }"#; + + let report: BenchReport = serde_json::from_str(legacy).expect("legacy report parses"); + assert_eq!(report.samples.len(), 2); + assert!(report.phases.is_empty()); + assert!(report.timeline.is_empty()); + assert!(report.samples[0].cpu_time_ms.is_none()); + assert!(report.samples[0].peak_memory_kb.is_none()); + assert!(report.samples[0].process_peak_memory_kb.is_none()); + + // Round-trip the parsed report and confirm the empty optional + // collections are skipped from the serialized output. + let json = serde_json::to_string(&report).expect("serialize"); + assert!(!json.contains("\"phases\"")); + assert!(!json.contains("\"timeline\"")); + } + #[test] fn run_with_setup_calls_setup_once() { use std::sync::atomic::{AtomicU32, Ordering}; diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index 5c4e43e..e60d924 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -506,18 +506,20 @@ private fun buildBenchReportJson(report: BenchReport, runProcessPeakMemoryKb: Lo report.samples.forEach { sample -> val sampleJson = JSONObject() sampleJson.put("duration_ns", sample.durationNs.toLong()) - sample.cpuTimeMs?.let { sampleJson.put("cpu_time_ms", it.toLong()) } - sample.peakMemoryKb?.let { sampleJson.put("peak_memory_kb", it.toLong()) } + optionalCpuTimeMs(sample)?.let { sampleJson.put("cpu_time_ms", it) } + optionalPeakMemoryKb(sample)?.let { sampleJson.put("peak_memory_kb", it) } optionalProcessPeakMemoryKb(sample)?.let { sampleJson.put("process_peak_memory_kb", it) } samples.put(sampleJson) } json.put("samples", samples) val phases = JSONArray() - report.phases.forEach { phase -> + optionalPhases(report).forEach { phase -> val phaseJson = JSONObject() - phaseJson.put("name", phase.name) - phaseJson.put("duration_ns", phase.durationNs.toLong()) + optionalStringProperty(phase, "name", "getName")?.let { phaseJson.put("name", it) } + optionalNumericProperty(phase, "durationNs", "getDurationNs")?.let { + phaseJson.put("duration_ns", it) + } phases.put(phaseJson) } json.put("phases", phases) @@ -533,8 +535,8 @@ private fun buildBenchReportJson(report: BenchReport, runProcessPeakMemoryKb: Lo json.put("stats", stats) } - val cpuSamplesMs = report.samples.mapNotNull { it.cpuTimeMs?.toLong() } - val peakSamplesKb = report.samples.mapNotNull { it.peakMemoryKb?.toLong() } + val cpuSamplesMs = report.samples.mapNotNull { optionalCpuTimeMs(it) } + val peakSamplesKb = report.samples.mapNotNull { optionalPeakMemoryKb(it) } val processPeakSamplesKb = report.samples.mapNotNull { optionalProcessPeakMemoryKb(it) } val memInfo = Debug.MemoryInfo() Debug.getMemoryInfo(memInfo) @@ -572,6 +574,50 @@ private fun optionalProcessPeakMemoryKb(sample: Any): Long? { return optionalNumericProperty(sample, "processPeakMemoryKb", "getProcessPeakMemoryKb") } +private fun optionalCpuTimeMs(sample: Any): Long? { + return optionalNumericProperty(sample, "cpuTimeMs", "getCpuTimeMs") +} + +private fun optionalPeakMemoryKb(sample: Any): Long? { + return optionalNumericProperty(sample, "peakMemoryKb", "getPeakMemoryKb") +} + +private fun optionalPhases(report: Any): List<Any> { + return optionalCollectionProperty(report, "phases", "getPhases") +} + +private fun optionalCollectionProperty(instance: Any, propertyName: String, getterName: String): List<Any> { + try { + val getter = instance.javaClass.methods.firstOrNull { + it.name == getterName && it.parameterTypes.isEmpty() + } + coerceToList(getter?.invoke(instance))?.let { return it } + } catch (e: Exception) { + android.util.Log.d("BenchRunner", "Optional collection getter unavailable: $propertyName") + } + + try { + val field = instance.javaClass.declaredFields.firstOrNull { it.name == propertyName } + if (field != null) { + field.isAccessible = true + coerceToList(field.get(instance))?.let { return it } + } + } catch (e: Exception) { + android.util.Log.d("BenchRunner", "Optional collection field unavailable: $propertyName") + } + + return emptyList() +} + +private fun coerceToList(value: Any?): List<Any>? { + return when (value) { + null -> null + is Iterable<*> -> value.filterNotNull() + is Array<*> -> value.filterNotNull() + else -> null + } +} + private fun optionalNumericProperty(instance: Any, propertyName: String, getterName: String): Long? { try { val getter = instance.javaClass.methods.firstOrNull { @@ -595,6 +641,29 @@ private fun optionalNumericProperty(instance: Any, propertyName: String, getterN return null } +private fun optionalStringProperty(instance: Any, propertyName: String, getterName: String): String? { + try { + val getter = instance.javaClass.methods.firstOrNull { + it.name == getterName && it.parameterTypes.isEmpty() + } + getter?.invoke(instance)?.toString()?.let { return it } + } catch (e: Exception) { + android.util.Log.d("BenchRunner", "Optional string getter unavailable: $propertyName") + } + + try { + val field = instance.javaClass.declaredFields.firstOrNull { it.name == propertyName } + if (field != null) { + field.isAccessible = true + field.get(instance)?.toString()?.let { return it } + } + } catch (e: Exception) { + android.util.Log.d("BenchRunner", "Optional string field unavailable: $propertyName") + } + + return null +} + private fun coerceToLong(value: Any?): Long? { return when (value) { null -> null diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index a21a35b..79f3b60 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -179,10 +179,10 @@ enum {{PROJECT_NAME_PASCAL}}FFI { // Also include samples in object format for compatibility let samplesArray = report.samples.map { sample -> [String: Any] in var sampleJson: [String: Any] = ["duration_ns": sample.durationNs] - if let cpuTimeMs = sample.cpuTimeMs { + if let cpuTimeMs = optionalCpuTimeMs(sample) { sampleJson["cpu_time_ms"] = cpuTimeMs } - if let peakMemoryKb = sample.peakMemoryKb { + if let peakMemoryKb = optionalPeakMemoryKb(sample) { sampleJson["peak_memory_kb"] = peakMemoryKb } if let processPeakMemoryKb = optionalProcessPeakMemoryKb(sample) { @@ -192,10 +192,14 @@ enum {{PROJECT_NAME_PASCAL}}FFI { } json["samples"] = samplesArray - let phases = report.phases.map { phase in + let phases = optionalPhases(report).compactMap { phase -> [String: Any]? in + guard let name = optionalStringProperty(phase, named: "name"), + let durationNs = optionalUInt64Property(phase, named: "durationNs") else { + return nil + } [ - "name": phase.name, - "duration_ns": phase.durationNs + "name": name, + "duration_ns": durationNs ] as [String: Any] } json["phases"] = phases @@ -232,8 +236,8 @@ enum {{PROJECT_NAME_PASCAL}}FFI { json["max_ns"] = maxNs } - let cpuSamplesMs = report.samples.compactMap(\.cpuTimeMs) - let peakSamplesKb = report.samples.compactMap(\.peakMemoryKb) + let cpuSamplesMs = report.samples.compactMap { optionalCpuTimeMs($0) } + let peakSamplesKb = report.samples.compactMap { optionalPeakMemoryKb($0) } let processPeakSamplesKb = report.samples.compactMap { optionalProcessPeakMemoryKb($0) } func median(_ values: [UInt64]) -> UInt64 { @@ -280,6 +284,38 @@ enum {{PROJECT_NAME_PASCAL}}FFI { return optionalUInt64Property(sample, named: "processPeakMemoryKb") } + private static func optionalCpuTimeMs(_ sample: Any) -> UInt64? { + return optionalUInt64Property(sample, named: "cpuTimeMs") + } + + private static func optionalPeakMemoryKb(_ sample: Any) -> UInt64? { + return optionalUInt64Property(sample, named: "peakMemoryKb") + } + + private static func optionalPhases(_ report: Any) -> [Any] { + for child in Mirror(reflecting: report).children where child.label == "phases" { + return coerceToArray(child.value) + } + return [] + } + + private static func coerceToArray(_ value: Any) -> [Any] { + let mirror = Mirror(reflecting: value) + if mirror.displayStyle == .optional { + guard let child = mirror.children.first else { + return [] + } + return coerceToArray(child.value) + } + if let array = value as? [Any] { + return array + } + if mirror.displayStyle == .collection { + return mirror.children.map { $0.value } + } + return [] + } + private static func optionalUInt64Property(_ instance: Any, named propertyName: String) -> UInt64? { for child in Mirror(reflecting: instance).children where child.label == propertyName { return coerceToUInt64(child.value) @@ -287,6 +323,13 @@ enum {{PROJECT_NAME_PASCAL}}FFI { return nil } + private static func optionalStringProperty(_ instance: Any, named propertyName: String) -> String? { + for child in Mirror(reflecting: instance).children where child.label == propertyName { + return String(describing: child.value) + } + return nil + } + private static func coerceToUInt64(_ value: Any) -> UInt64? { let mirror = Mirror(reflecting: value) if mirror.displayStyle == .optional { diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template index f8ca532..09630f3 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template @@ -52,6 +52,7 @@ final class {{PROJECT_NAME_PASCAL}}UITests: XCTestCase { // Verify we got valid JSON (not an error message) XCTAssertFalse(jsonString.isEmpty, "Benchmark report JSON should not be empty") XCTAssertTrue(jsonString.hasPrefix("{"), "Benchmark report should be valid JSON (starts with '{')") + XCTAssertFalse(jsonString.contains("\"error\""), "Benchmark report should not be an error payload: \(jsonString)") } // Keep the old test name for backward compatibility diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index 5e71096..c0dacc9 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result, anyhow}; +use reqwest::Url; use reqwest::blocking::multipart::Form; use reqwest::blocking::{Client, Response}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -326,9 +327,7 @@ impl BrowserStackClient { pub fn download_url(&self, url: &str, dest: &Path) -> Result<()> { let resp = self - .http - .get(url) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .asset_request(url) .send() .with_context(|| format!("downloading BrowserStack asset {}", url))?; let status = resp.status(); @@ -525,9 +524,7 @@ impl BrowserStackClient { fn download_text_url(&self, url: &str) -> Result<String> { let resp = self - .http - .get(url) - .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .asset_request(url) .send() .with_context(|| format!("downloading BrowserStack asset {}", url))?; let status = resp.status(); @@ -545,6 +542,15 @@ impl BrowserStackClient { Ok(String::from_utf8_lossy(&bytes).into_owned()) } + fn asset_request(&self, url: &str) -> reqwest::blocking::RequestBuilder { + let request = self.http.get(url); + if should_authenticate_asset_url(url) { + request.basic_auth(&self.auth.username, Some(&self.auth.access_key)) + } else { + request + } + } + /// Extract benchmark results from device logs /// Looks for JSON output matching BenchReport format /// Supports both Android (BENCH_JSON) and iOS (BENCH_REPORT_JSON_START/END) formats @@ -1630,6 +1636,20 @@ fn parse_response<T: DeserializeOwned>(resp: Response, context: &str) -> Result< .with_context(|| format!("parsing BrowserStack API response for {}", context)) } +fn should_authenticate_asset_url(url: &str) -> bool { + let Ok(parsed) = Url::parse(url) else { + return false; + }; + if parsed.scheme() != "https" { + return false; + } + let Some(host) = parsed.host_str() else { + return false; + }; + + host == "browserstack.com" || host.ends_with(".browserstack.com") +} + /// Parse a device list response from BrowserStack API. fn parse_device_list(json: Value, context: &str) -> Result<Vec<BrowserStackDevice>> { // BrowserStack returns an array of device objects @@ -2008,6 +2028,22 @@ mod tests { assert_eq!(client.base_url, "https://test.example.com"); } + #[test] + fn authenticated_asset_downloads_are_limited_to_browserstack_https_hosts() { + assert!(should_authenticate_asset_url( + "https://api-cloud.browserstack.com/app-automate/logs/123" + )); + assert!(should_authenticate_asset_url( + "https://app-automate.browserstack.com/sessions/123/logs" + )); + assert!(!should_authenticate_asset_url( + "http://api-cloud.browserstack.com/app-automate/logs/123" + )); + assert!(!should_authenticate_asset_url( + "https://evil.example.com/browserstack/logs" + )); + } + #[test] fn new_client_uses_default_base_url() { let client = BrowserStackClient::new( diff --git a/crates/mobench/src/cli.rs b/crates/mobench/src/cli.rs index 6124f36..129f4fb 100644 --- a/crates/mobench/src/cli.rs +++ b/crates/mobench/src/cli.rs @@ -43,9 +43,9 @@ pub(crate) enum Command { /// you provide --ios-app and --ios-test-suite to override. Run { #[arg(long, value_enum)] - target: MobileTarget, + target: Option<MobileTarget>, #[arg(long, help = "Fully-qualified Rust function to benchmark")] - function: String, + function: Option<String>, #[arg( long, help = "Project root containing mobench.toml or the Cargo workspace" @@ -56,10 +56,10 @@ pub(crate) enum Command { help = "Path to the benchmark crate directory containing Cargo.toml" )] crate_path: Option<PathBuf>, - #[arg(long, default_value_t = 100)] - iterations: u32, - #[arg(long, default_value_t = 10)] - warmup: u32, + #[arg(long)] + iterations: Option<u32>, + #[arg(long)] + warmup: Option<u32>, #[arg(long, help = "Device identifiers or labels (BrowserStack devices)")] devices: Vec<String>, #[arg(long, help = "Device matrix YAML file to load device names from")] diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index b673f37..e8fb844 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -802,14 +802,17 @@ pub fn run() -> Result<()> { run_summary.performance_metrics = Some(perf_metrics.into_iter().collect()); } Err(e) => { - println!("\nWarning: Failed to fetch results: {}", e); - println!("Build may still be accessible at: {}", dashboard_url); + bail!( + "failed to fetch BrowserStack benchmark results: {}. Build may still be accessible at: {}", + e, + dashboard_url + ); } } // Also save detailed artifacts to separate directory let output_root = fetch_output_dir.join(build_id); - if let Err(e) = fetch_browserstack_artifacts( + fetch_browserstack_artifacts( &client, run_summary.spec.target, build_id, @@ -817,9 +820,13 @@ pub fn run() -> Result<()> { false, // Don't wait again, we already did fetch_poll_interval_secs, fetch_timeout_secs, - ) { - println!("Warning: Failed to fetch detailed artifacts: {}", e); - } + ) + .with_context(|| { + format!( + "failed to fetch detailed BrowserStack artifacts for build {}", + build_id + ) + })?; } else if fetch { println!("No BrowserStack run to fetch (devices not provided?)"); } @@ -2731,10 +2738,10 @@ fn shorten_html_error(message: &str) -> String { #[allow(clippy::too_many_arguments)] fn resolve_run_spec( - target: MobileTarget, - function: String, - iterations: u32, - warmup: u32, + target: Option<MobileTarget>, + function: Option<String>, + iterations: Option<u32>, + warmup: Option<u32>, devices: Vec<String>, layout: &ResolvedProjectLayout, config: Option<&Path>, @@ -2752,34 +2759,56 @@ fn resolve_run_spec( let configured_ios_completion_timeout_secs = ios_completion_timeout_secs .or(cfg.browserstack.ios_completion_timeout_secs) .or(layout.ios_completion_timeout_secs); + if device_matrix.is_some() && !devices.is_empty() { + bail!( + "--device-matrix cannot be combined with --devices; choose one source for devices" + ); + } let matrix_path = device_matrix.map(Path::to_path_buf).unwrap_or_else(|| { resolve_project_relative_path( cfg_path.parent().unwrap_or_else(|| Path::new(".")), cfg.device_matrix.as_path(), ) }); - let matrix = load_device_matrix(&matrix_path)?; let resolved_tags = if !device_tags.is_empty() { Some(device_tags) } else { cfg.device_tags.clone() }; - let device_names = match resolved_tags.as_ref() { - Some(tags) if !tags.is_empty() => filter_devices_by_tags(matrix.devices, tags)?, - _ => matrix.devices.into_iter().map(|d| d.name).collect(), + let device_names = if !devices.is_empty() { + devices + } else { + let matrix = load_device_matrix(&matrix_path)?; + match resolved_tags.as_ref() { + Some(tags) if !tags.is_empty() => filter_devices_by_tags(matrix.devices, tags)?, + _ => matrix.devices.into_iter().map(|d| d.name).collect(), + } + }; + let ios_xcuitest = match (ios_app, ios_test_suite) { + (Some(app), Some(test_suite)) => Some(IosXcuitestArtifacts { app, test_suite }), + (None, None) => cfg.ios_xcuitest, + _ => bail!( + "both --ios-app and --ios-test-suite must be provided together; omit both to use config-managed iOS artifacts" + ), }; return Ok(RunSpec { - target: cfg.target, - function: cfg.function, - iterations: cfg.iterations, - warmup: cfg.warmup, + target: target.unwrap_or(cfg.target), + function: function.unwrap_or(cfg.function), + iterations: iterations.unwrap_or(cfg.iterations), + warmup: warmup.unwrap_or(cfg.warmup), devices: device_names, ios_completion_timeout_secs: configured_ios_completion_timeout_secs, browserstack: Some(cfg.browserstack), - ios_xcuitest: cfg.ios_xcuitest, + ios_xcuitest, }); } + let target = + target.context("target must be provided with --target or set in the config file")?; + let function = function.unwrap_or_default(); + let iterations = iterations.unwrap_or(100); + let warmup = warmup.unwrap_or(10); + if function.trim().is_empty() { bail!( "function must not be empty; pass --function <crate::fn> or set function in the config file" @@ -5059,7 +5088,7 @@ fn ensure_android_home() { fn load_dotenv_global() { if let Ok(root) = repo_root() { let _ = dotenvy::from_path(root.join(".env")); - let _ = dotenvy::from_path_override(root.join(".env.local")); + let _ = dotenvy::from_path(root.join(".env.local")); } } @@ -5074,7 +5103,7 @@ pub(crate) fn load_dotenv_for_layout(layout: &ResolvedProjectLayout) { for dir in directories { let _ = dotenvy::from_path(dir.join(".env")); - let _ = dotenvy::from_path_override(dir.join(".env.local")); + let _ = dotenvy::from_path(dir.join(".env.local")); } } @@ -7049,10 +7078,10 @@ pub fn bench_query_proof_generation() {} }) .unwrap(); let spec = resolve_run_spec( - MobileTarget::Android, - "sample_fns::fibonacci".into(), - 5, - 1, + Some(MobileTarget::Android), + Some("sample_fns::fibonacci".into()), + Some(5), + Some(1), vec!["pixel".into()], &layout, None, @@ -7124,10 +7153,10 @@ project = "proj" }) .unwrap(); let spec = resolve_run_spec( - MobileTarget::Android, - "ignored::value".into(), - 1, - 0, + Some(MobileTarget::Android), + Some("ignored::value".into()), + Some(1), + Some(0), Vec::new(), &layout, Some(config_path.as_path()), @@ -7145,6 +7174,74 @@ project = "proj" assert_eq!(spec.devices, vec!["CLI Device".to_string()]); } + #[test] + fn run_accepts_config_without_target_or_function_flags() { + assert!(Cli::try_parse_from(["mobench", "run", "--config", "bench-config.toml"]).is_ok()); + } + + #[test] + fn resolve_run_spec_lets_cli_values_override_config_values() { + let temp_dir = TempDir::new().expect("temp dir"); + let matrix_path = temp_dir.path().join("matrix.yml"); + let config_path = temp_dir.path().join("bench-config.toml"); + + write_file( + &matrix_path, + br#"devices: + - name: Config Device + os: android + os_version: "14" +"#, + ) + .expect("write matrix"); + let config_toml = format!( + r#"target = "android" +function = "config::function" +iterations = 10 +warmup = 2 +device_matrix = "{}" + +[browserstack] +app_automate_username = "user" +app_automate_access_key = "key" +project = "proj" +"#, + matrix_path.display() + ); + write_file(&config_path, config_toml.as_bytes()).expect("write config"); + + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: None, + config_path: None, + }) + .unwrap(); + let spec = resolve_run_spec( + Some(MobileTarget::Ios), + Some("cli::function".into()), + Some(3), + Some(1), + Vec::new(), + &layout, + Some(config_path.as_path()), + None, + Vec::new(), + None, + None, + None, + false, + false, + false, + ) + .expect("resolve spec"); + + assert_eq!(spec.target, MobileTarget::Ios); + assert_eq!(spec.function, "cli::function"); + assert_eq!(spec.iterations, 3); + assert_eq!(spec.warmup, 1); + } + #[test] fn parses_project_resolution_flags() { assert!( @@ -7348,10 +7445,10 @@ project = "proj" }) .expect("resolve layout"); let spec = resolve_run_spec( - MobileTarget::Ios, - "zk_mobile_bench::bench_query_proof_generation".into(), - 1, - 0, + Some(MobileTarget::Ios), + Some("zk_mobile_bench::bench_query_proof_generation".into()), + Some(1), + Some(0), vec!["iPhone 15".into()], &layout, None, @@ -7438,10 +7535,10 @@ project = "proj" }) .expect("resolve layout"); let spec = resolve_run_spec( - MobileTarget::Ios, - "zk_mobile_bench::bench_query_proof_generation".into(), - 1, - 0, + Some(MobileTarget::Ios), + Some("zk_mobile_bench::bench_query_proof_generation".into()), + Some(1), + Some(0), vec!["iPhone 15".into()], &layout, None, @@ -7703,10 +7800,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" }) .unwrap(); let spec = resolve_run_spec( - MobileTarget::Ios, - "ignored::value".into(), - 1, - 0, + Some(MobileTarget::Ios), + Some("ignored::value".into()), + Some(1), + Some(0), Vec::new(), &layout, Some(config_path.as_path()), From 1bc898f238584f591f3ba4594fdd75f1b081c1ac Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Mon, 27 Apr 2026 16:06:07 +0200 Subject: [PATCH 187/196] chore: prepare mobench 0.1.36 release candidate --- .github/workflows/compile-gate.yml | 2 ++ .github/workflows/reusable-bench.yml | 4 ++- .github/workflows/reusable-pr-auto.yml | 8 +++++ .github/workflows/reusable-pr-command.yml | 11 +++++- .github/workflows/rust.yml | 6 ++-- CLAUDE.md | 4 +-- Cargo.toml | 4 ++- README.md | 1 + RELEASE_NOTES.md | 17 ++++++++- SECURITY.md | 12 +++++++ android/app/build.gradle | 3 +- crates/mobench-macros/src/lib.rs | 9 +++++ .../tests/ui/async_benchmark.stderr | 5 +++ crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench-sdk/src/builders/android.rs | 13 +++---- .../templates/android/app/build.gradle | 3 +- crates/mobench/Cargo.toml | 2 +- crates/mobench/README.md | 4 +-- crates/sample-fns/Cargo.toml | 3 +- docs/codebase/PUBLIC_API.md | 4 +-- docs/guides/release.md | 35 +++++++++++++++++++ docs/guides/sdk-integration.md | 4 +-- docs/specs/production-readiness-roadmap.md | 2 +- examples/basic-benchmark/Cargo.toml | 3 +- examples/ffi-benchmark/Cargo.toml | 3 +- templates/android/app/build.gradle | 3 +- .../BenchRunnerUITests.swift.template | 1 + test_browserstack.sh | 17 +++++---- 28 files changed, 146 insertions(+), 39 deletions(-) create mode 100644 SECURITY.md create mode 100644 crates/mobench-macros/tests/ui/async_benchmark.stderr create mode 100644 docs/guides/release.md diff --git a/.github/workflows/compile-gate.yml b/.github/workflows/compile-gate.yml index b65f45e..66a5806 100644 --- a/.github/workflows/compile-gate.yml +++ b/.github/workflows/compile-gate.yml @@ -19,3 +19,5 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Compile the workspace run: cargo test --workspace --locked --no-run + - name: Run workspace tests + run: cargo test --workspace --locked diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 53e2ce8..887df87 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -66,7 +66,7 @@ on: description: "Mobench version to install" required: false type: string - default: "0.1.35" + default: "" mobench_ref: description: "Git ref for mobile-bench-rs (overrides mobench_version when set)" required: false @@ -119,6 +119,7 @@ jobs: name: iOS BrowserStack benchmark if: inputs.platform == 'ios' || inputs.platform == 'both' runs-on: macos-15 + environment: browserstack env: BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} @@ -337,6 +338,7 @@ jobs: name: Android BrowserStack benchmark if: inputs.platform == 'android' || inputs.platform == 'both' runs-on: macos-14 + environment: browserstack env: BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} diff --git a/.github/workflows/reusable-pr-auto.yml b/.github/workflows/reusable-pr-auto.yml index f43c8db..d1cead0 100644 --- a/.github/workflows/reusable-pr-auto.yml +++ b/.github/workflows/reusable-pr-auto.yml @@ -44,6 +44,7 @@ on: default: "5" permissions: + actions: write contents: read pull-requests: read checks: read @@ -85,6 +86,13 @@ jobs: fi fi + PR_HEAD_REPO=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.repo.full_name') + if [ "$PR_HEAD_REPO" != "$REPO" ]; then + echo "::warning::PR #${PR_NUMBER} comes from ${PR_HEAD_REPO}; skipping secret-bearing BrowserStack dispatch" + echo "should_dispatch=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Check if bench label is present HAS_LABEL=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/labels" \ --jq ".[].name" | grep -qx "$BENCH_LABEL" && echo "true" || echo "false") diff --git a/.github/workflows/reusable-pr-command.yml b/.github/workflows/reusable-pr-command.yml index 57622c8..9b51140 100644 --- a/.github/workflows/reusable-pr-command.yml +++ b/.github/workflows/reusable-pr-command.yml @@ -35,6 +35,7 @@ on: default: "5" permissions: + actions: write contents: read pull-requests: read @@ -118,12 +119,20 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR_URL: ${{ github.event.issue.pull_request.url }} + REPO: ${{ github.repository }} run: | head_sha=$(gh api "$PR_URL" --jq '.head.sha') + head_repo=$(gh api "$PR_URL" --jq '.head.repo.full_name') + if [ "$head_repo" != "$REPO" ]; then + echo "::warning::PR #${{ github.event.issue.number }} comes from ${head_repo}; skipping secret-bearing BrowserStack dispatch" + echo "trusted_head=false" >> "$GITHUB_OUTPUT" + exit 0 + fi echo "head_sha=${head_sha}" >> "$GITHUB_OUTPUT" + echo "trusted_head=true" >> "$GITHUB_OUTPUT" - name: Dispatch benchmark workflow - if: steps.trust.outputs.trusted == 'true' + if: steps.trust.outputs.trusted == 'true' && steps.pr.outputs.trusted_head == 'true' env: GH_TOKEN: ${{ github.token }} WORKFLOW: ${{ inputs.benchmark_workflow }} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 38476cf..239fe3b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -30,7 +30,7 @@ jobs: run: cargo fmt --all -- --check - name: Clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + run: cargo clippy --workspace --locked --all-targets --all-features -- -D warnings - name: Install cargo-deny uses: taiki-e/install-action@cargo-deny @@ -47,10 +47,10 @@ jobs: cargo semver-checks check-release --package mobench --baseline-version 0.1.35 - name: Rustdoc - run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps + run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --locked --all-features --no-deps - name: Test - run: cargo test --workspace + run: cargo test --workspace --locked - name: Host benchmark smoke run: cargo bench -p mobench --features bench-support --bench host_contracts -- --test diff --git a/CLAUDE.md b/CLAUDE.md index 332346c..b38a5ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking toolkit for Rust. It supports benchmark execution on local devices or BrowserStack and local-first native profiling on Android and iOS. It provides a library-first design with a `#[benchmark]` attribute macro plus CLI tools for building, testing, running, reporting, and profiling benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.27):** +**Release candidate workspace version: v0.1.36. Latest published supported release: v0.1.35.** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation @@ -595,7 +595,7 @@ The workflow supports manual dispatch with platform selection: ```toml [dependencies] -mobench-sdk = "0.1.27" +mobench-sdk = "0.1.36" inventory = "0.3" ``` diff --git a/Cargo.toml b/Cargo.toml index a6279bd..a2c06da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ resolver = "2" edition = "2024" license = "MIT" rust-version = "1.85" -version = "0.1.35" +version = "0.1.36" [workspace.dependencies] anyhow = "1" @@ -34,3 +34,5 @@ proc-macro2 = "1" # Phase 1: Template embedding include_dir = "0.7" +mobench-macros = { version = "0.1.36", path = "crates/mobench-macros" } +mobench-sdk = { version = "0.1.36", path = "crates/mobench-sdk", default-features = false } diff --git a/README.md b/README.md index 74e7794..eba041b 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,7 @@ CLI flags override config file values when provided. - `docs/guides/browserstack-ci.md`: BrowserStack benchmark CI setup - `docs/guides/browserstack-metrics.md`: BrowserStack metric normalization and limits - `docs/guides/fetch-results.md`: fetching and summarizing results +- `docs/guides/release.md`: preflight and publish checklist - `docs/codebase/README.md`: current codebase reference map - `docs/codebase/PUBLIC_API.md`: public API, semver, feature flag, MSRV, and release-readiness boundaries - `docs/MIGRATION_GUIDE.md`: migration notes for CI and reporting changes diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ff184a6..fb1e144 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -25,7 +25,8 @@ Crates.io release history: ## Support Policy -- `v0.1.35` is the current supported release. +- `v0.1.36` is the next production release candidate. +- `v0.1.35` is the current published supported release. - `v0.1.34` is the immediately previous supported release, superseded by `v0.1.35`. - Every earlier published version is a historical test build and should not be @@ -36,6 +37,7 @@ Crates.io release history: | Version | Published | Published crates | Status | |---------|-----------|------------------|--------| +| `v0.1.36` | Pending | `mobench 0.1.36`, `mobench-sdk 0.1.36`, `mobench-macros 0.1.36` | Release candidate | | `v0.1.35` | 2026-04-24 | `mobench 0.1.35`, `mobench-sdk 0.1.35`, `mobench-macros 0.1.35` | Current supported release | | `v0.1.34` | 2026-04-23 | `mobench 0.1.34`, `mobench-sdk 0.1.34`, `mobench-macros 0.1.34` | Superseded by `v0.1.35` | | `v0.1.33` | 2026-04-17 | `mobench 0.1.33`, `mobench-sdk 0.1.33`, `mobench-macros 0.1.33` | Superseded by `v0.1.34` | @@ -74,6 +76,19 @@ Crates.io release history: | `v0.1.1` | 2026-01-13 | `mobench 0.1.1`, `mobench-sdk 0.1.1` | Yanked test build. Do not use. | | `v0.1.0` | 2026-01-13 | `mobench 0.1.0`, `mobench-sdk 0.1.0`, `mobench-macros 0.1.0` | Yanked test build. Do not use. | +## v0.1.36 + +Status: release candidate. + +### Release hardening + +- Hardened clean first-run spec embedding for generated Android and iOS projects. +- Restricted authenticated BrowserStack artifact downloads to BrowserStack HTTPS hosts. +- Made config-file runs work without duplicate `--target` / `--function` flags and restored CLI-over-config precedence. +- Tightened generated mobile template compatibility with minimal UniFFI report types. +- Added compile-fail coverage for async benchmark functions and ensured setup teardown runs on benchmark errors. +- Added release hygiene: locked CI gates, publish protections for examples, release checklist, security reporting policy, and BrowserStack secret-bearing workflow guards. + ## v0.1.35 Status: current supported release. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f316b80 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security issues privately by emailing `security@world.org`. + +Do not open public GitHub issues for vulnerabilities, credential exposure, or +abuse paths. Include the affected crate, version or commit, reproduction steps, +and any relevant logs with secrets redacted. + +We aim to acknowledge reports within 5 business days and will coordinate fixes +and disclosure timing with the reporter. diff --git a/android/app/build.gradle b/android/app/build.gradle index 669776d..29407c7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -18,8 +18,7 @@ android { versionCode 1 versionName "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) - testBuildType "release" + testBuildType project.findProperty("mobenchTestBuildType") ?: "debug" } buildTypes { diff --git a/crates/mobench-macros/src/lib.rs b/crates/mobench-macros/src/lib.rs index 198d923..def79b9 100644 --- a/crates/mobench-macros/src/lib.rs +++ b/crates/mobench-macros/src/lib.rs @@ -277,6 +277,15 @@ pub fn benchmark(attr: TokenStream, item: TokenStream) -> TokenStream { let block = &input_fn.block; let attrs = &input_fn.attrs; + if input_fn.sig.asyncness.is_some() { + return syn::Error::new_spanned( + input_fn.sig.asyncness, + "#[benchmark] does not support async fn. Move async work into a synchronous runtime boundary so the benchmark measures execution instead of future creation.", + ) + .to_compile_error() + .into(); + } + // Validate based on whether setup is provided if args.setup.is_some() { // With setup: must have exactly one parameter diff --git a/crates/mobench-macros/tests/ui/async_benchmark.stderr b/crates/mobench-macros/tests/ui/async_benchmark.stderr new file mode 100644 index 0000000..17cf005 --- /dev/null +++ b/crates/mobench-macros/tests/ui/async_benchmark.stderr @@ -0,0 +1,5 @@ +error: #[benchmark] does not support async fn. Move async work into a synchronous runtime boundary so the benchmark measures execution instead of future creation. + --> tests/ui/async_benchmark.rs:4:1 + | +4 | async fn async_work_is_not_a_benchmark() {} + | ^^^^^ diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 240c2b2..a7a78c0 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -33,7 +33,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.35", path = "../mobench-macros", optional = true } +mobench-macros = { workspace = true, optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index bf8ae8b..6bfeed9 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -1174,9 +1174,15 @@ impl AndroidBuilder { BuildProfile::Debug => "assembleDebugAndroidTest", BuildProfile::Release => "assembleReleaseAndroidTest", }; + let profile_name = match config.profile { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + }; let mut cmd = Command::new("./gradlew"); - cmd.arg(gradle_task).current_dir(&android_dir); + cmd.arg(format!("-PmobenchTestBuildType={profile_name}")) + .arg(gradle_task) + .current_dir(&android_dir); if self.verbose { cmd.arg("--info"); @@ -1219,11 +1225,6 @@ impl AndroidBuilder { ))); } - let profile_name = match config.profile { - BuildProfile::Debug => "debug", - BuildProfile::Release => "release", - }; - let test_apk_dir = android_dir .join("app/build/outputs/apk/androidTest") .join(profile_name); diff --git a/crates/mobench-sdk/templates/android/app/build.gradle b/crates/mobench-sdk/templates/android/app/build.gradle index 81b14c4..7940cdb 100644 --- a/crates/mobench-sdk/templates/android/app/build.gradle +++ b/crates/mobench-sdk/templates/android/app/build.gradle @@ -18,8 +18,7 @@ android { versionCode 1 versionName "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) - testBuildType "release" + testBuildType project.findProperty("mobenchTestBuildType") ?: "debug" } buildTypes { diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 3cf6d47..55031fc 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -41,7 +41,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.35", path = "../mobench-sdk" } +mobench-sdk = { workspace = true, features = ["full"] } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 2ef0e97..1c84781 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -815,8 +815,8 @@ cargo mobench package-ipa --method adhoc Verify credentials: ```bash -echo $BROWSERSTACK_USERNAME -echo $BROWSERSTACK_ACCESS_KEY +test -n "$BROWSERSTACK_USERNAME" && echo "BROWSERSTACK_USERNAME is set" +test -n "$BROWSERSTACK_ACCESS_KEY" && echo "BROWSERSTACK_ACCESS_KEY is set" ``` Or check `.env.local` file exists and contains valid credentials. diff --git a/crates/sample-fns/Cargo.toml b/crates/sample-fns/Cargo.toml index 23ad1ca..1ce716f 100644 --- a/crates/sample-fns/Cargo.toml +++ b/crates/sample-fns/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition = "2021" # UniFFI 0.28 generates code for edition 2021 license.workspace = true rust-version.workspace = true +publish = false [lib] name = "sample_fns" @@ -12,7 +13,7 @@ crate-type = ["lib", "cdylib", "staticlib"] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -mobench-sdk = { path = "../mobench-sdk", default-features = false, features = ["runner-only"] } +mobench-sdk = { workspace = true, default-features = false, features = ["runner-only"] } uniffi = { workspace = true, features = ["cli"] } thiserror.workspace = true uniffi_bindgen = { version = "0.28", optional = true } diff --git a/docs/codebase/PUBLIC_API.md b/docs/codebase/PUBLIC_API.md index 914d7c2..0eba032 100644 --- a/docs/codebase/PUBLIC_API.md +++ b/docs/codebase/PUBLIC_API.md @@ -133,8 +133,8 @@ Run these before publishing the published crates: ```bash cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings -RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps -cargo test --workspace +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --locked --all-features --no-deps +cargo test --workspace --locked cargo publish --dry-run -p mobench-macros cargo publish --dry-run -p mobench-sdk cargo publish --dry-run -p mobench diff --git a/docs/guides/release.md b/docs/guides/release.md new file mode 100644 index 0000000..1bcc48d --- /dev/null +++ b/docs/guides/release.md @@ -0,0 +1,35 @@ +# Release Checklist + +Use this checklist before publishing the `mobench` crate family. + +## Preflight + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --locked --all-targets --all-features -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --locked --all-features --no-deps +cargo test --workspace --locked +cargo bench -p mobench --features bench-support --bench host_contracts -- --test +``` + +## Publish Dry Run + +Publish order matters because the SDK depends on the proc macro crate and the +CLI depends on the SDK. + +```bash +cargo publish --dry-run -p mobench-macros +cargo publish --dry-run -p mobench-sdk +cargo publish --dry-run -p mobench +``` + +## Publish + +```bash +cargo publish -p mobench-macros +cargo publish -p mobench-sdk +cargo publish -p mobench +``` + +After publishing, tag the release and update `RELEASE_NOTES.md` with the +published version and any migration notes. diff --git a/docs/guides/sdk-integration.md b/docs/guides/sdk-integration.md index 0079384..7a28aac 100644 --- a/docs/guides/sdk-integration.md +++ b/docs/guides/sdk-integration.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.35" +mobench-sdk = "0.1.36" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.35" +mobench-sdk = "0.1.36" ``` ## 3) Annotate benchmark functions diff --git a/docs/specs/production-readiness-roadmap.md b/docs/specs/production-readiness-roadmap.md index e1c1165..a37250b 100644 --- a/docs/specs/production-readiness-roadmap.md +++ b/docs/specs/production-readiness-roadmap.md @@ -4,7 +4,7 @@ Updated: 2026-04-25 ## Purpose -mobench v0.1.35 is feature-ready for production use. This roadmap tracks the +mobench v0.1.36 is feature-ready for production use. This roadmap tracks the quality, maintainability, documentation, and launch-readiness work needed before broader public promotion, including tweets, landing pages, and wider crates.io adoption. diff --git a/examples/basic-benchmark/Cargo.toml b/examples/basic-benchmark/Cargo.toml index b6d34c7..fa6822a 100644 --- a/examples/basic-benchmark/Cargo.toml +++ b/examples/basic-benchmark/Cargo.toml @@ -4,13 +4,14 @@ version = "0.1.0" edition = "2021" license.workspace = true rust-version.workspace = true +publish = false [lib] name = "basic_benchmark" crate-type = ["lib", "cdylib", "staticlib"] [dependencies] -mobench-sdk = { path = "../../crates/mobench-sdk" } +mobench-sdk = { workspace = true, features = ["full"] } inventory.workspace = true serde.workspace = true thiserror.workspace = true diff --git a/examples/ffi-benchmark/Cargo.toml b/examples/ffi-benchmark/Cargo.toml index 12306a9..35ceca3 100644 --- a/examples/ffi-benchmark/Cargo.toml +++ b/examples/ffi-benchmark/Cargo.toml @@ -4,13 +4,14 @@ version = "0.1.0" edition = "2021" license.workspace = true rust-version.workspace = true +publish = false [lib] name = "ffi_benchmark" crate-type = ["lib", "cdylib", "staticlib"] [dependencies] -mobench-sdk = { path = "../../crates/mobench-sdk" } +mobench-sdk = { workspace = true, features = ["full"] } inventory.workspace = true serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/templates/android/app/build.gradle b/templates/android/app/build.gradle index 81b14c4..7940cdb 100644 --- a/templates/android/app/build.gradle +++ b/templates/android/app/build.gradle @@ -18,8 +18,7 @@ android { versionCode 1 versionName "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - // Run instrumentation tests against release build (enables assembleReleaseAndroidTest task) - testBuildType "release" + testBuildType project.findProperty("mobenchTestBuildType") ?: "debug" } buildTypes { diff --git a/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template b/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template index f8ca532..09630f3 100644 --- a/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template +++ b/templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template @@ -52,6 +52,7 @@ final class {{PROJECT_NAME_PASCAL}}UITests: XCTestCase { // Verify we got valid JSON (not an error message) XCTAssertFalse(jsonString.isEmpty, "Benchmark report JSON should not be empty") XCTAssertTrue(jsonString.hasPrefix("{"), "Benchmark report should be valid JSON (starts with '{')") + XCTAssertFalse(jsonString.contains("\"error\""), "Benchmark report should not be an error payload: \(jsonString)") } // Keep the old test name for backward compatibility diff --git a/test_browserstack.sh b/test_browserstack.sh index 58e73fc..37c492e 100755 --- a/test_browserstack.sh +++ b/test_browserstack.sh @@ -3,13 +3,18 @@ set -e # Load credentials export $(cat .env.local | xargs) +NETRC_FILE=$(mktemp) +trap 'rm -f "$NETRC_FILE"' EXIT +chmod 600 "$NETRC_FILE" +printf 'machine api-cloud.browserstack.com login %s password %s\n' \ + "$BROWSERSTACK_USERNAME" "$BROWSERSTACK_ACCESS_KEY" > "$NETRC_FILE" echo "=== BrowserStack Manual Test Run ===" echo "" # Upload Android APK echo "1. Uploading Android APK..." -ANDROID_APP_URL=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ +ANDROID_APP_URL=$(curl -s --netrc-file "$NETRC_FILE" \ -X POST "https://api-cloud.browserstack.com/app-automate/upload" \ -F "file=@android/app/build/outputs/apk/debug/app-debug.apk" \ | jq -r '.app_url') @@ -18,7 +23,7 @@ echo "" # Upload Android test APK echo "2. Uploading Android test APK..." -ANDROID_TEST_URL=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ +ANDROID_TEST_URL=$(curl -s --netrc-file "$NETRC_FILE" \ -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite" \ -F "file=@android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk" \ | jq -r '.test_suite_url') @@ -27,7 +32,7 @@ echo "" # Trigger Android Espresso run echo "3. Triggering Android test on Pixel 7..." -ANDROID_BUILD=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ +ANDROID_BUILD=$(curl -s --netrc-file "$NETRC_FILE" \ -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/build" \ -d "{\"app\": \"$ANDROID_APP_URL\", \"testSuite\": \"$ANDROID_TEST_URL\", \"devices\": [\"Google Pixel 7-13.0\"], \"project\": \"mobench-test\", \"deviceLogs\": true}" \ -H "Content-Type: application/json") @@ -38,7 +43,7 @@ echo "" # Upload iOS app echo "4. Uploading iOS app..." -IOS_APP_URL=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ +IOS_APP_URL=$(curl -s --netrc-file "$NETRC_FILE" \ -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app" \ -F "file=@target/ios/BenchRunner.zip" \ | jq -r '.app_url') @@ -47,7 +52,7 @@ echo "" # Upload iOS test suite echo "5. Uploading iOS test suite..." -IOS_TEST_URL=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ +IOS_TEST_URL=$(curl -s --netrc-file "$NETRC_FILE" \ -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite" \ -F "file=@target/ios/BenchRunnerUITests.zip" \ | jq -r '.test_suite_url') @@ -56,7 +61,7 @@ echo "" # Trigger iOS XCUITest run echo "6. Triggering iOS test on iPhone 14..." -IOS_BUILD=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ +IOS_BUILD=$(curl -s --netrc-file "$NETRC_FILE" \ -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/build" \ -d "{\"app\": \"$IOS_APP_URL\", \"testSuite\": \"$IOS_TEST_URL\", \"devices\": [\"iPhone 14-16\"], \"project\": \"mobench-test\", \"deviceLogs\": true}" \ -H "Content-Type: application/json") From ad8ccef32fae55a54b1a3faa124edc7fd2b95be1 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Mon, 27 Apr 2026 16:40:15 +0200 Subject: [PATCH 188/196] fix: repair generated mobile harness resources --- crates/mobench-sdk/src/codegen.rs | 5 +++++ crates/mobench-sdk/templates/android/app/build.gradle | 9 +++++++-- .../BenchRunner/BenchRunnerFFI.swift.template | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 14e7039..e40b754 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -1516,6 +1516,10 @@ mod tests { assert!(android_manifest.contains("android:foregroundServiceType=\"dataSync\"")); assert!(android_manifest.contains("android:process=\":mobench_worker\"")); + let android_build_gradle = include_str!("../templates/android/app/build.gradle"); + assert!(android_build_gradle.contains("generatedMainBenchSpec")); + assert!(android_build_gradle.contains("if (!generatedMainBenchSpec.exists())")); + let ios = include_str!("../templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template"); assert!( @@ -1527,6 +1531,7 @@ mod tests { "iOS template should not require generated bindings to expose processPeakMemoryKb" ); assert!(ios.contains("optionalProcessPeakMemoryKb(sample)")); + assert!(ios.contains("return [\n \"name\": name,")); assert!( !ios.contains("sample.cpuTimeMs"), "iOS template should tolerate BenchSample without cpuTimeMs" diff --git a/crates/mobench-sdk/templates/android/app/build.gradle b/crates/mobench-sdk/templates/android/app/build.gradle index 7940cdb..3d4a369 100644 --- a/crates/mobench-sdk/templates/android/app/build.gradle +++ b/crates/mobench-sdk/templates/android/app/build.gradle @@ -7,6 +7,7 @@ android { ndkVersion "26.1.10909125" def repoRoot = rootProject.projectDir.parentFile def benchSpecDir = new File(repoRoot, "target/mobile-spec/android") + def generatedMainBenchSpec = new File(projectDir, "src/main/assets/bench_spec.json") if (!benchSpecDir.exists()) { benchSpecDir.mkdirs() } @@ -45,10 +46,14 @@ android { sourceSets { main { jniLibs.srcDirs "src/main/jniLibs" - assets.srcDirs += [benchSpecDir.absolutePath] + if (!generatedMainBenchSpec.exists()) { + assets.srcDirs += [benchSpecDir.absolutePath] + } } androidTest { - assets.srcDirs += [benchSpecDir.absolutePath] + if (!generatedMainBenchSpec.exists()) { + assets.srcDirs += [benchSpecDir.absolutePath] + } } } diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template index 79f3b60..37bbcec 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/BenchRunnerFFI.swift.template @@ -197,7 +197,7 @@ enum {{PROJECT_NAME_PASCAL}}FFI { let durationNs = optionalUInt64Property(phase, named: "durationNs") else { return nil } - [ + return [ "name": name, "duration_ns": durationNs ] as [String: Any] From c4a1737d40b9361256d2ec166fc5e818cee7a5f5 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Mon, 27 Apr 2026 18:15:48 +0200 Subject: [PATCH 189/196] fix: address release gate review findings --- .github/workflows/rust.yml | 2 ++ crates/mobench-sdk/src/timing.rs | 39 +++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 239fe3b..0496afd 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,6 +1,7 @@ name: Rust on: + workflow_dispatch: pull_request: push: branches: @@ -56,6 +57,7 @@ jobs: run: cargo bench -p mobench --features bench-support --bench host_contracts -- --test - name: Publish dry run + if: github.event_name == 'workflow_dispatch' run: | cargo publish --dry-run -p mobench-macros cargo publish --dry-run -p mobench-sdk diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index c21a241..634f855 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -835,7 +835,6 @@ impl PersistentMemorySampler { Some(v) => v, None => { let _ = ack_tx.send(false); - let _ = result_tx.send(None); continue; } }; @@ -1991,6 +1990,44 @@ mod tests { ); } + #[test] + fn persistent_memory_sampler_does_not_queue_result_when_begin_fails() { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + // Queue: [80=startup warmup, None=failed first baseline, + // 100=second baseline, 130=final sample]. + let samples = Arc::new(Mutex::new(VecDeque::from([ + Some(80_u64), + None, + Some(100_u64), + Some(130_u64), + ]))); + let reader_samples = Arc::clone(&samples); + let reader = Arc::new(move || { + reader_samples + .lock() + .expect("sample queue") + .pop_front() + .unwrap_or(Some(130)) + }); + + let sampler = PersistentMemorySampler::start_with_reader(reader).expect("sampler"); + assert!(!sampler.begin_window()); + assert!(sampler.begin_window()); + let peak = sampler + .end_window() + .expect("second window should receive its own result"); + + assert_eq!( + peak, + ProcessMemoryPeak { + growth_kb: 30, + process_peak_kb: 130, + } + ); + } + #[test] fn persistent_memory_sampler_waits_for_baseline_before_begin_returns() { use std::sync::atomic::{AtomicBool, Ordering}; From 1a3787d3a141047f0105496336dd94806a93f0d5 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Mon, 27 Apr 2026 18:18:59 +0200 Subject: [PATCH 190/196] docs: mark 0.1.36 as published --- RELEASE_NOTES.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fb1e144..c4fa4c2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -16,8 +16,8 @@ Crates.io release history: - Added production-readiness documentation for public API boundaries, semver expectations, feature flags, MSRV, release checks, examples, and launch diagrams. -- Added Rust quality CI covering rustfmt, clippy, rustdoc, tests, and publish - dry-runs for the published crates. +- Added Rust quality CI covering rustfmt, clippy, rustdoc, tests, and + manually-triggered publish dry-runs for the published crates. - Added opt-in structured CLI tracing through `--verbose` or `MOBENCH_LOG`, plus an explicit `doctor` MSRV check. - Added host-only fixture contract coverage for example summary schemas and @@ -25,8 +25,9 @@ Crates.io release history: ## Support Policy -- `v0.1.36` is the next production release candidate. -- `v0.1.35` is the current published supported release. +- `v0.1.36` is the current published supported release. +- `v0.1.35` is the immediately previous supported release, superseded by + `v0.1.36`. - `v0.1.34` is the immediately previous supported release, superseded by `v0.1.35`. - Every earlier published version is a historical test build and should not be @@ -37,8 +38,8 @@ Crates.io release history: | Version | Published | Published crates | Status | |---------|-----------|------------------|--------| -| `v0.1.36` | Pending | `mobench 0.1.36`, `mobench-sdk 0.1.36`, `mobench-macros 0.1.36` | Release candidate | -| `v0.1.35` | 2026-04-24 | `mobench 0.1.35`, `mobench-sdk 0.1.35`, `mobench-macros 0.1.35` | Current supported release | +| `v0.1.36` | 2026-04-27 | `mobench 0.1.36`, `mobench-sdk 0.1.36`, `mobench-macros 0.1.36` | Current supported release | +| `v0.1.35` | 2026-04-24 | `mobench 0.1.35`, `mobench-sdk 0.1.35`, `mobench-macros 0.1.35` | Superseded by `v0.1.36` | | `v0.1.34` | 2026-04-23 | `mobench 0.1.34`, `mobench-sdk 0.1.34`, `mobench-macros 0.1.34` | Superseded by `v0.1.35` | | `v0.1.33` | 2026-04-17 | `mobench 0.1.33`, `mobench-sdk 0.1.33`, `mobench-macros 0.1.33` | Superseded by `v0.1.34` | | `v0.1.32` | 2026-04-14 | `mobench 0.1.32`, `mobench-sdk 0.1.32`, `mobench-macros 0.1.32` | Superseded by `v0.1.33` | @@ -78,7 +79,7 @@ Crates.io release history: ## v0.1.36 -Status: release candidate. +Status: current supported release. ### Release hardening From ad053aa520458f486ab3edcb850505d52be08d02 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Mon, 27 Apr 2026 18:25:39 +0200 Subject: [PATCH 191/196] docs: sync documentation with 0.1.36 release --- RELEASE_NOTES.md | 24 +++++++++++----------- crates/mobench-macros/README.md | 4 ++-- crates/mobench-sdk/README.md | 2 +- crates/mobench-sdk/src/lib.rs | 4 ++-- crates/mobench-sdk/src/uniffi_types.rs | 2 +- docs/codebase/PUBLIC_API.md | 10 +++++---- docs/guides/release.md | 20 +++++++++++++++--- docs/specs/production-readiness-roadmap.md | 22 ++++++++++---------- 8 files changed, 52 insertions(+), 36 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c4fa4c2..1563253 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -13,23 +13,14 @@ Crates.io release history: ## Unreleased -- Added production-readiness documentation for public API boundaries, semver - expectations, feature flags, MSRV, release checks, examples, and launch - diagrams. -- Added Rust quality CI covering rustfmt, clippy, rustdoc, tests, and - manually-triggered publish dry-runs for the published crates. -- Added opt-in structured CLI tracing through `--verbose` or `MOBENCH_LOG`, plus - an explicit `doctor` MSRV check. -- Added host-only fixture contract coverage for example summary schemas and - stable Markdown/CSV rendering. +- No user-facing unreleased changes yet. ## Support Policy - `v0.1.36` is the current published supported release. - `v0.1.35` is the immediately previous supported release, superseded by `v0.1.36`. -- `v0.1.34` is the immediately previous supported release, superseded by - `v0.1.35`. +- `v0.1.34` is superseded by `v0.1.35`. - Every earlier published version is a historical test build and should not be used. - Yanked versions are explicitly called out below. @@ -83,6 +74,15 @@ Status: current supported release. ### Release hardening +- Added production-readiness documentation for public API boundaries, semver + expectations, feature flags, MSRV, release checks, examples, and launch + diagrams. +- Added Rust quality CI covering rustfmt, clippy, rustdoc, tests, and + manually-triggered publish dry-runs for the published crates. +- Added opt-in structured CLI tracing through `--verbose` or `MOBENCH_LOG`, plus + an explicit `doctor` MSRV check. +- Added host-only fixture contract coverage for example summary schemas and + stable Markdown/CSV rendering. - Hardened clean first-run spec embedding for generated Android and iOS projects. - Restricted authenticated BrowserStack artifact downloads to BrowserStack HTTPS hosts. - Made config-file runs work without duplicate `--target` / `--function` flags and restored CLI-over-config precedence. @@ -92,7 +92,7 @@ Status: current supported release. ## v0.1.35 -Status: current supported release. +Status: superseded by `v0.1.36`. - Added iOS benchmark app process peak memory reporting using Mach `task_info`, so iOS summaries now expose `process_peak_memory_kb` alongside diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index 2661bf6..618188e 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -19,8 +19,8 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -mobench-macros = "0.1" -mobench-sdk = "0.1" # For the runtime +mobench-macros = "0.1.36" +mobench-sdk = "0.1.36" # For the runtime ``` ### Basic Example diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index 90175b8..fdc9111 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -24,7 +24,7 @@ Add mobench-sdk to your project: ```toml [dependencies] -mobench-sdk = "0.1" +mobench-sdk = "0.1.36" ``` Mark functions to benchmark: diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 6f20ebf..198aedf 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -22,7 +22,7 @@ //! //! ```toml //! [dependencies] -//! mobench-sdk = "0.1" +//! mobench-sdk = "0.1.36" //! inventory = "0.3" # Required for benchmark registration //! //! [lib] @@ -65,7 +65,7 @@ //! //! ```toml //! [dependencies] -//! mobench-sdk = "0.1" +//! mobench-sdk = "0.1.36" //! inventory = "0.3" # Required for benchmark registration //! ``` //! diff --git a/crates/mobench-sdk/src/uniffi_types.rs b/crates/mobench-sdk/src/uniffi_types.rs index 8b55cb3..51471cd 100644 --- a/crates/mobench-sdk/src/uniffi_types.rs +++ b/crates/mobench-sdk/src/uniffi_types.rs @@ -13,7 +13,7 @@ //! //! ```toml //! [dependencies] -//! mobench-sdk = "0.1" +//! mobench-sdk = "0.1.36" //! uniffi = { version = "0.28", features = ["cli"] } //! //! [build-dependencies] diff --git a/docs/codebase/PUBLIC_API.md b/docs/codebase/PUBLIC_API.md index 0eba032..a5fc5ae 100644 --- a/docs/codebase/PUBLIC_API.md +++ b/docs/codebase/PUBLIC_API.md @@ -132,12 +132,14 @@ Run these before publishing the published crates: ```bash cargo fmt --all -- --check -cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo clippy --workspace --locked --all-targets --all-features -- -D warnings RUSTDOCFLAGS="-D warnings" cargo doc --workspace --locked --all-features --no-deps cargo test --workspace --locked +cargo bench -p mobench --features bench-support --bench host_contracts -- --test cargo publish --dry-run -p mobench-macros -cargo publish --dry-run -p mobench-sdk -cargo publish --dry-run -p mobench ``` -Publish order is `mobench-macros`, `mobench-sdk`, then `mobench`. +Publish order is `mobench-macros`, `mobench-sdk`, then `mobench`. Because +crates.io dry-runs resolve dependencies from the registry index, dry-run +`mobench-sdk` only after `mobench-macros` is published, and dry-run `mobench` +only after `mobench-sdk` is published. diff --git a/docs/guides/release.md b/docs/guides/release.md index 1bcc48d..d4d74f5 100644 --- a/docs/guides/release.md +++ b/docs/guides/release.md @@ -15,11 +15,24 @@ cargo bench -p mobench --features bench-support --bench host_contracts -- --test ## Publish Dry Run Publish order matters because the SDK depends on the proc macro crate and the -CLI depends on the SDK. +CLI depends on the SDK. Before the first crate is published, only the leaf +crate can complete a crates.io dry run because unpublished sibling versions are +not yet available in the registry index. ```bash cargo publish --dry-run -p mobench-macros +``` + +After `mobench-macros` is published and available from crates.io, dry-run the +SDK before publishing it: + +```bash cargo publish --dry-run -p mobench-sdk +``` + +After `mobench-sdk` is published and available from crates.io, dry-run the CLI: + +```bash cargo publish --dry-run -p mobench ``` @@ -31,5 +44,6 @@ cargo publish -p mobench-sdk cargo publish -p mobench ``` -After publishing, tag the release and update `RELEASE_NOTES.md` with the -published version and any migration notes. +Wait for each crate to become available before publishing the dependent crate. +After publishing, update `RELEASE_NOTES.md` with the published date/status, push +that docs commit, then tag the release at the published commit. diff --git a/docs/specs/production-readiness-roadmap.md b/docs/specs/production-readiness-roadmap.md index a37250b..b49b3bd 100644 --- a/docs/specs/production-readiness-roadmap.md +++ b/docs/specs/production-readiness-roadmap.md @@ -1,13 +1,12 @@ # Production Readiness Roadmap -Updated: 2026-04-25 +Updated: 2026-04-27 ## Purpose -mobench v0.1.36 is feature-ready for production use. This roadmap tracks the -quality, maintainability, documentation, and launch-readiness work needed before -broader public promotion, including tweets, landing pages, and wider crates.io -adoption. +mobench v0.1.36 is the current published supported release. This roadmap records +the quality, maintainability, documentation, and launch-readiness work completed +for that release, plus follow-up hardening after broader public promotion. ## Launch Gates @@ -29,7 +28,8 @@ Checklist: - [x] Audit crate metadata, badges, readmes, categories, and keywords. - [x] Document MSRV policy. - [x] Enforce rustfmt, clippy, and rustdoc warnings in CI. -- [x] Run `cargo publish --dry-run` for all published crates before release. +- [x] Run staged `cargo publish --dry-run` checks for all published crates + during release. Verification signals: - `cargo test --workspace` passes. @@ -117,18 +117,18 @@ announcement unless they expose real launch risk. ### Later Hardening Evaluation -Updated: 2026-04-26 +Updated: 2026-04-27 This bucket should be sequenced by launch risk rather than implemented as one -large polish pass. The current repository has CI caching and fixture cache keys, -but no dedicated `benches/`, fuzz target, `cargo-semver-checks`, or -`cargo-deny` policy yet. +large polish pass. The current repository has CI caching, fixture cache keys, +host benchmark smoke coverage, `cargo-semver-checks`, and a `cargo-deny` +policy. Fuzz/property tests and dedicated build-cache profiling remain open. P0 after first public promotion: - Completed: add `cargo-semver-checks` for `mobench-sdk` and `mobench`. `mobench-macros` is a proc-macro target, so `cargo-semver-checks` does not treat it as a normal library API surface; keep it covered by rustdoc, clippy, - tests, and publish dry-runs. + tests, and staged publish dry-runs. - Completed: add `cargo-deny` with explicit license/advisory/source policy. Current policy denies vulnerable and yanked crates, restricts registries, and documents the direct Inferno `CDDL-1.0` license exception. From 35a1f654c2320ec2ed63dd1168657073dfc52297 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Mon, 27 Apr 2026 18:27:11 +0200 Subject: [PATCH 192/196] docs: move production roadmap to issue --- README.md | 1 - crates/mobench-sdk/src/lib.rs | 2 +- crates/mobench-sdk/src/timing.rs | 2 +- docs/specs/production-readiness-roadmap.md | 173 --------------------- 4 files changed, 2 insertions(+), 176 deletions(-) delete mode 100644 docs/specs/production-readiness-roadmap.md diff --git a/README.md b/README.md index eba041b..50766b5 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,6 @@ CLI flags override config file values when provided. - `docs/codebase/README.md`: current codebase reference map - `docs/codebase/PUBLIC_API.md`: public API, semver, feature flag, MSRV, and release-readiness boundaries - `docs/MIGRATION_GUIDE.md`: migration notes for CI and reporting changes -- `docs/specs/production-readiness-roadmap.md`: production-readiness launch gates for crate quality, output contracts, diagnostics, examples, docs, and public launch assets - `docs/specs/dx-improvement-spec.md`: historical DX design spec, kept for context only - `docs/schemas/`: machine-readable CI/summary schema artifacts - `RELEASE_NOTES.md`: published release history and support status diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 198aedf..5257e0e 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -147,7 +147,7 @@ //! //! ```toml //! [dependencies] -//! mobench-sdk = { version = "0.1", default-features = false, features = ["runner-only"] } +//! mobench-sdk = { version = "0.1.36", default-features = false, features = ["runner-only"] } //! ``` //! //! ## Programmatic Usage diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index 634f855..775dac2 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -59,7 +59,7 @@ //! //! ```toml //! [dependencies] -//! mobench-sdk = { version = "0.1", default-features = false, features = ["runner-only"] } +//! mobench-sdk = { version = "0.1.36", default-features = false, features = ["runner-only"] } //! ``` use serde::{Deserialize, Serialize}; diff --git a/docs/specs/production-readiness-roadmap.md b/docs/specs/production-readiness-roadmap.md deleted file mode 100644 index b49b3bd..0000000 --- a/docs/specs/production-readiness-roadmap.md +++ /dev/null @@ -1,173 +0,0 @@ -# Production Readiness Roadmap - -Updated: 2026-04-27 - -## Purpose - -mobench v0.1.36 is the current published supported release. This roadmap records -the quality, maintainability, documentation, and launch-readiness work completed -for that release, plus follow-up hardening after broader public promotion. - -## Launch Gates - -### Gate 1: Trust The Crate - -Goal: make `mobench`, `mobench-sdk`, and `mobench-macros` feel like dependable -production Rust crates. - -Audience: library adopters, CLI users, maintainers. - -Checklist: -- [x] Audit public APIs exported from `mobench-sdk`. -- [x] Document semver and stability boundaries. -- [x] Review feature flags, especially `full` and `runner-only`. -- [x] Replace reusable-library `anyhow` surfaces with typed errors where appropriate. -- [x] Improve docs.rs module docs and examples. -- [x] Add compile-tested doc examples for core SDK usage. -- [x] Add or refine minimal library adopter examples. -- [x] Audit crate metadata, badges, readmes, categories, and keywords. -- [x] Document MSRV policy. -- [x] Enforce rustfmt, clippy, and rustdoc warnings in CI. -- [x] Run staged `cargo publish --dry-run` checks for all published crates - during release. - -Verification signals: -- `cargo test --workspace` passes. -- `cargo clippy --workspace --all-targets --all-features -- -D warnings` passes. -- `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps` passes. -- docs.rs pages render cleanly for all published crates. -- Examples build from a clean checkout. - -### Gate 2: Trust The Outputs - -Goal: make benchmark, CI, and profiling outputs dependable enough for users to -automate around. - -Audience: CLI users, CI adopters, maintainers. - -Checklist: -- [x] Add schema validation tests for `summary.json`. -- [x] Add schema validation tests for the CI contract. -- [x] Add golden fixture tests for Markdown summaries. -- [x] Add golden fixture tests for CSV outputs. -- [x] Add golden fixture tests for plots where practical. -- [x] Add golden fixture tests for profile summaries. -- [x] Add CLI snapshot tests for high-value command output. -- [x] Add BrowserStack response normalization regression tests. -- [x] Test resource metric contracts: `cpu_total_ms`, `cpu_median_ms`, `peak_memory_kb`. -- [x] Test baseline and regression comparison behavior. -- [x] Test profile manifest sections: `native_capture`, `semantic_profile`, `capture_metadata`. -- [x] Test Android and iOS template generation invariants. -- [x] Test setup, teardown, and per-iteration macro behavior. -- [x] Keep fixture verification wired into CI. -- [x] Label tests that require Android, iOS, BrowserStack, or profiling tools separately from pure host tests. - -Verification signals: -- Host-only tests run without mobile toolchains. -- Mobile/tooling tests are opt-in or clearly gated. -- Existing example fixtures validate against schemas. -- Summary, CSV, plot, and profile contracts are covered by regression tests. - -### Gate 3: Trust The Experience - -Goal: make mobench easy to adopt, debug, explain, and promote publicly. - -Audience: CLI users, library adopters, public launch readers. - -Checklist: -- [x] Add structured tracing/logging to CLI flows. -- [x] Add progress spans for build, package, upload, poll, fetch, summarize, and profile steps. -- [x] Improve human-readable diagnostics with likely fixes. -- [x] Expand `doctor` coverage for Android, iOS, BrowserStack, and profile prerequisites. -- [x] Add examples for minimal benchmark usage. -- [x] Add examples for setup/teardown benchmarks. -- [x] Add examples for FFI/custom type benchmarks. -- [x] Add examples for CI-only benchmark workflows. -- [x] Add examples for profiling workflows. -- [x] Add examples for programmatic SDK usage. -- [x] Add README graphics for crate architecture. -- [x] Add README graphics for benchmark execution lifecycle. -- [x] Add README graphics for BrowserStack CI lifecycle. -- [x] Add README graphics for local profiling artifact lifecycle. -- [x] Add README graphics for SDK versus CLI responsibility boundaries. -- [x] Write concise launch copy explaining why mobench exists. -- [x] Keep release notes and migration notes current. - -Verification signals: -- A new user can follow the README from install to first result. -- `doctor` catches common setup issues before long-running commands fail. -- Public diagrams explain the codebase and mobench workflows without reading source. -- Launch assets are reusable for README, tweets, and landing page. - -## Later Hardening - -These items are valuable but should not block the first production-ready -announcement unless they expose real launch risk. - -- [ ] Profile CLI hot paths. -- [ ] Improve APK/IPA build caching. -- [ ] Parallelize independent build, fetch, and report steps. -- [x] Add host benchmarks for parser, reporting, and profile code. -- [ ] Add fuzz or property tests for config and device matrix parsing. -- [x] Add public API compatibility checks with `cargo-semver-checks`. -- [x] Add dependency and license policy checks with `cargo-deny`. -- [ ] Consider narrower crate features to reduce dependency footprint. -- [ ] Add machine-readable trace/event output for CI debugging. -- [ ] Prepare landing-page-specific assets from README diagrams. - -### Later Hardening Evaluation - -Updated: 2026-04-27 - -This bucket should be sequenced by launch risk rather than implemented as one -large polish pass. The current repository has CI caching, fixture cache keys, -host benchmark smoke coverage, `cargo-semver-checks`, and a `cargo-deny` -policy. Fuzz/property tests and dedicated build-cache profiling remain open. - -P0 after first public promotion: -- Completed: add `cargo-semver-checks` for `mobench-sdk` and `mobench`. - `mobench-macros` is a proc-macro target, so `cargo-semver-checks` does not - treat it as a normal library API surface; keep it covered by rustdoc, clippy, - tests, and staged publish dry-runs. -- Completed: add `cargo-deny` with explicit license/advisory/source policy. - Current policy denies vulnerable and yanked crates, restricts registries, and - documents the direct Inferno `CDDL-1.0` license exception. -- Completed: add host benchmarks for parser, reporting, profile manifest - rendering, and BrowserStack log extraction. These give a baseline before - tuning or parallelizing anything. - -P1 once adoption feedback identifies slow paths: -- Profile CLI hot paths with real examples: config resolution, template - generation, report rendering, profile diff generation, and BrowserStack - artifact normalization. -- Improve APK/IPA build caching where profiling shows repeated template, - binding, or packaging work dominates local iteration time. -- Parallelize independent build, fetch, and report steps only after benchmarks - make the current bottlenecks visible. -- Add fuzz or property tests for config and device matrix parsing after the - host benchmark harness exists, so parser behavior and parser cost are both - tracked. - -P2 launch-asset and dependency-footprint follow-up: -- Prepare landing-page-specific assets from README diagrams once the landing - page visual direction is chosen. -- Consider narrower crate features after `cargo-deny` and host benchmarks show - which dependencies materially affect compile time, binary size, or policy - surface. -- Add machine-readable trace/event output for CI debugging when there is a - concrete downstream consumer for it; the current `MOBENCH_LOG` tracing is - enough for near-term human debugging. - -Recommended first hardening PR: -1. Run the new CI policy checks and host benchmark smoke on a fresh PR. -2. Review duplicate dependency warnings from `cargo-deny`, especially `toml`, - `getrandom`, and Windows support crates. -3. Use the benchmark results to decide whether build caching or parallelization - should come next. - -## Recommended Order - -1. Gate 1 crate hygiene, because this reduces adoption risk. -2. Gate 2 output contracts, because CI users need stable automation surfaces. -3. Gate 3 experience and launch assets, because promotion should point at a stable product. -4. Later hardening, prioritized by issues found during adoption. From 3a97e4c0fd5ef0683112c0385455aef5f239398f Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Mon, 27 Apr 2026 21:01:58 +0200 Subject: [PATCH 193/196] release: prepare 0.1.37 --- Cargo.lock | 95 +++++++++++++- Cargo.toml | 6 +- README.md | 6 +- RELEASE_NOTES.md | 23 +++- crates/mobench-macros/README.md | 4 +- crates/mobench-sdk/Cargo.toml | 8 +- crates/mobench-sdk/README.md | 10 +- crates/mobench-sdk/src/codegen.rs | 10 +- crates/mobench-sdk/src/ffi.rs | 2 +- crates/mobench-sdk/src/lib.rs | 85 ++++++------ crates/mobench-sdk/src/timing.rs | 2 +- crates/mobench-sdk/src/uniffi_types.rs | 4 +- crates/mobench/Cargo.toml | 1 + crates/mobench/src/lib.rs | 125 ++++++++++++++++++ crates/mobench/src/profile.rs | 138 +++++++++++++++++++- crates/mobench/tests/profile_cli.rs | 50 +++++++ docs/codebase/PUBLIC_API.md | 17 ++- docs/guides/profiling.md | 9 +- docs/guides/sdk-integration.md | 12 +- docs/schemas/trace-events-v1.schema.json | 59 +++++++++ examples/basic-benchmark/Cargo.toml | 2 +- examples/ffi-benchmark/Cargo.toml | 2 +- examples/fixtures/profile/trace-events.json | 24 ++++ 23 files changed, 614 insertions(+), 80 deletions(-) create mode 100644 docs/schemas/trace-events-v1.schema.json create mode 100644 examples/fixtures/profile/trace-events.json diff --git a/Cargo.lock b/Cargo.lock index 3068b73..b427b73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,7 +187,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -196,6 +205,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.10.0" @@ -561,7 +576,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex-automata", "regex-syntax", ] @@ -590,6 +605,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1206,7 +1227,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.36" +version = "0.1.37" dependencies = [ "anyhow", "clap", @@ -1217,6 +1238,7 @@ dependencies = [ "inventory", "jsonschema", "mobench-sdk", + "proptest", "reqwest", "sample-fns", "serde", @@ -1232,7 +1254,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.36" +version = "0.1.37" dependencies = [ "proc-macro2", "quote", @@ -1242,7 +1264,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.36" +version = "0.1.37" dependencies = [ "include_dir", "inventory", @@ -1509,6 +1531,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-xml" version = "0.39.2" @@ -1617,6 +1664,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "rayon" version = "1.12.0" @@ -1799,6 +1855,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.20" @@ -1816,7 +1884,7 @@ dependencies = [ [[package]] name = "sample-fns" -version = "0.1.36" +version = "0.1.37" dependencies = [ "camino", "mobench-sdk", @@ -2468,6 +2536,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.8.1" @@ -2680,6 +2754,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index a2c06da..a1db2ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ resolver = "2" edition = "2024" license = "MIT" rust-version = "1.85" -version = "0.1.36" +version = "0.1.37" [workspace.dependencies] anyhow = "1" @@ -34,5 +34,5 @@ proc-macro2 = "1" # Phase 1: Template embedding include_dir = "0.7" -mobench-macros = { version = "0.1.36", path = "crates/mobench-macros" } -mobench-sdk = { version = "0.1.36", path = "crates/mobench-sdk", default-features = false } +mobench-macros = { version = "0.1.37", path = "crates/mobench-macros" } +mobench-sdk = { version = "0.1.37", path = "crates/mobench-sdk", default-features = false } diff --git a/README.md b/README.md index 50766b5..a0a121a 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,8 @@ cargo mobench report github --pr 123 --summary target/mobench/ci/summary.json # Local native profiling cargo mobench profile run --target android --function sample_fns::fibonacci \ - --provider local --backend android-native + --provider local --backend android-native \ + --trace-events-output target/mobench/profile/trace-events.json cargo mobench profile summarize --profile target/mobench/profile/profile.json cargo mobench profile diff \ --baseline target/mobench/profile/android-sample_fns--fibonacci/profile.json \ @@ -209,6 +210,9 @@ writes its current manifest and summary under the latest run. Differential comparisons write to `target/mobench/profile/diff/<baseline-run-id>--vs--<candidate-run-id>/` and refresh top-level `profile-diff.json` / `summary.md` under the diff root. +Use `--trace-events-output <path>` when a downstream consumer needs stable +machine-readable harness event JSON; dry runs still write an empty trace +contract so CI can validate the integration path without native profilers. The manifest is split into three explicit sections: diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1563253..bd5f2c8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -13,12 +13,26 @@ Crates.io release history: ## Unreleased -- No user-facing unreleased changes yet. +No user-facing unreleased changes yet. + +## v0.1.37 + +Status: current supported release. + +- Added `mobench profile run --trace-events-output <path>` for downstream + consumers that need machine-readable harness trace/event JSON. +- Added `mobench-sdk`'s `registry` feature for benchmark macro, inventory + discovery, and runtime execution without builder/template dependencies. +- Generated FFI wrapper crates and example benchmark crates now use the + narrower `registry` feature instead of the full SDK build-tooling feature set. +- Added property-test coverage for run config and device matrix parsing. ## Support Policy -- `v0.1.36` is the current published supported release. -- `v0.1.35` is the immediately previous supported release, superseded by +- `v0.1.37` is the current published supported release. +- `v0.1.36` is the immediately previous supported release, superseded by + `v0.1.37`. +- `v0.1.35` is superseded by `v0.1.36`. - `v0.1.34` is superseded by `v0.1.35`. - Every earlier published version is a historical test build and should not be @@ -29,7 +43,8 @@ Crates.io release history: | Version | Published | Published crates | Status | |---------|-----------|------------------|--------| -| `v0.1.36` | 2026-04-27 | `mobench 0.1.36`, `mobench-sdk 0.1.36`, `mobench-macros 0.1.36` | Current supported release | +| `v0.1.37` | 2026-04-27 | `mobench 0.1.37`, `mobench-sdk 0.1.37`, `mobench-macros 0.1.37` | Current supported release | +| `v0.1.36` | 2026-04-27 | `mobench 0.1.36`, `mobench-sdk 0.1.36`, `mobench-macros 0.1.36` | Superseded by `v0.1.37` | | `v0.1.35` | 2026-04-24 | `mobench 0.1.35`, `mobench-sdk 0.1.35`, `mobench-macros 0.1.35` | Superseded by `v0.1.36` | | `v0.1.34` | 2026-04-23 | `mobench 0.1.34`, `mobench-sdk 0.1.34`, `mobench-macros 0.1.34` | Superseded by `v0.1.35` | | `v0.1.33` | 2026-04-17 | `mobench 0.1.33`, `mobench-sdk 0.1.33`, `mobench-macros 0.1.33` | Superseded by `v0.1.34` | diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index 618188e..3c3e94a 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -19,8 +19,8 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -mobench-macros = "0.1.36" -mobench-sdk = "0.1.36" # For the runtime +mobench-macros = "0.1.37" +mobench-sdk = "0.1.37" # For the runtime ``` ### Basic Example diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index a7a78c0..e73d1c2 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -27,7 +27,13 @@ crate-type = ["lib"] [features] default = ["full"] # Full SDK with build automation, templates, and registry -full = ["dep:mobench-macros", "dep:inventory", "dep:include_dir", "dep:toml"] +full = ["registry", "builders", "codegen"] +# Benchmark macro, inventory registry, and runtime execution without build tooling +registry = ["dep:mobench-macros", "dep:inventory"] +# Mobile build automation; depends on codegen because builders refresh app templates +builders = ["codegen", "dep:toml"] +# Project and mobile template generation +codegen = ["dep:include_dir", "dep:toml"] # Minimal timing-only mode for mobile binaries (small footprint) runner-only = [] diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index fdc9111..cf9179a 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -24,7 +24,15 @@ Add mobench-sdk to your project: ```toml [dependencies] -mobench-sdk = "0.1.36" +mobench-sdk = "0.1.37" +``` + +For benchmark crates that only need `#[benchmark]`, registry discovery, and +runtime execution, use the narrower registry feature: + +```toml +[dependencies] +mobench-sdk = { version = "0.1.37", default-features = false, features = ["registry"] } ``` Mark functions to benchmark: diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index e40b754..43d9566 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -112,7 +112,7 @@ edition = "2021" crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] -mobench-sdk = {{ path = ".." }} +mobench-sdk = {{ path = "..", default-features = false, features = ["registry"] }} uniffi = "0.28" {} = {{ path = ".." }} @@ -1273,6 +1273,14 @@ mod tests { assert!(temp_dir.join("bench-mobile/Cargo.toml").exists()); assert!(temp_dir.join("bench-mobile/src/lib.rs").exists()); assert!(temp_dir.join("bench-mobile/build.rs").exists()); + let cargo_toml = + fs::read_to_string(temp_dir.join("bench-mobile/Cargo.toml")).expect("read Cargo.toml"); + assert!( + cargo_toml.contains( + r#"mobench-sdk = { path = "..", default-features = false, features = ["registry"] }"# + ), + "generated FFI wrapper should depend on the narrow registry feature, got:\n{cargo_toml}" + ); // Cleanup fs::remove_dir_all(&temp_dir).ok(); diff --git a/crates/mobench-sdk/src/ffi.rs b/crates/mobench-sdk/src/ffi.rs index e8c1da0..64e1f08 100644 --- a/crates/mobench-sdk/src/ffi.rs +++ b/crates/mobench-sdk/src/ffi.rs @@ -246,7 +246,7 @@ where /// Run a benchmark and return FFI-ready result. /// /// This is a convenience function that wraps `run_benchmark` with FFI type conversions. -#[cfg(feature = "full")] +#[cfg(feature = "registry")] pub fn run_benchmark_ffi(spec: BenchSpecFfi) -> Result<BenchReportFfi, BenchErrorFfi> { let sdk_spec: crate::BenchSpec = spec.into(); crate::run_benchmark(sdk_spec) diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 5257e0e..15a365d 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -22,7 +22,7 @@ //! //! ```toml //! [dependencies] -//! mobench-sdk = "0.1.36" +//! mobench-sdk = "0.1.37" //! inventory = "0.3" # Required for benchmark registration //! //! [lib] @@ -65,7 +65,7 @@ //! //! ```toml //! [dependencies] -//! mobench-sdk = "0.1.36" +//! mobench-sdk = "0.1.37" //! inventory = "0.3" # Required for benchmark registration //! ``` //! @@ -120,10 +120,10 @@ //! | Module | Description | //! |--------|-------------| //! | [`timing`] | Core timing infrastructure (always available) | -//! | [`registry`] | Runtime discovery of `#[benchmark]` functions (requires `full` feature) | -//! | [`runner`] | Benchmark execution engine (requires `full` feature) | -//! | [`builders`] | Android and iOS build automation (requires `full` feature) | -//! | [`codegen`] | Mobile app template generation (requires `full` feature) | +//! | [`registry`] | Runtime discovery of `#[benchmark]` functions (requires `registry` or `full` feature) | +//! | [`runner`] | Benchmark execution engine (requires `registry` or `full` feature) | +//! | [`builders`] | Android and iOS build automation (requires `builders` or `full` feature) | +//! | [`codegen`] | Mobile app template generation (requires `codegen`, `builders`, or `full` feature) | //! | [`types`] | Common types and error definitions | //! //! ## Crate Ecosystem @@ -141,22 +141,27 @@ //! | Feature | Default | Description | //! |---------|---------|-------------| //! | `full` | Yes | Full SDK with build automation, templates, and registry | +//! | `registry` | No | Benchmark macro, inventory registry, and runtime execution without build tooling | +//! | `builders` | No | Android/iOS build automation; enables `codegen` | +//! | `codegen` | No | Project and mobile app template generation | //! | `runner-only` | No | Minimal timing-only mode for mobile binaries | //! //! For mobile binaries where binary size matters, use `runner-only`: //! //! ```toml //! [dependencies] -//! mobench-sdk = { version = "0.1.36", default-features = false, features = ["runner-only"] } +//! mobench-sdk = { version = "0.1.37", default-features = false, features = ["runner-only"] } //! ``` //! //! ## Programmatic Usage //! //! You can also use the SDK programmatically: //! -//! ### Using the Builder Pattern +//! ### Using the Benchmark Builder Pattern //! -//! ```no_run +//! Requires the `registry` or `full` feature. +//! +//! ```ignore //! use mobench_sdk::BenchmarkBuilder; //! //! fn main() -> Result<(), Box<dyn std::error::Error>> { @@ -170,9 +175,12 @@ //! } //! ``` //! -//! ### Using BenchSpec Directly +//! ### Using BenchSpec With Registry Dispatch //! -//! ```no_run +//! Requires the `registry` or `full` feature. With `runner-only`, use +//! [`run_closure`] or [`timing::run_closure`] for manual dispatch instead. +//! +//! ```ignore //! use mobench_sdk::{BenchSpec, run_benchmark}; //! //! fn main() -> Result<(), Box<dyn std::error::Error>> { @@ -186,7 +194,9 @@ //! //! ### Discovering Benchmarks //! -//! ```no_run +//! Requires the `registry` or `full` feature. +//! +//! ```ignore //! use mobench_sdk::{discover_benchmarks, list_benchmark_names}; //! //! fn main() { @@ -345,44 +355,45 @@ pub mod uniffi_types; // Unified FFI module for UniFFI integration pub mod ffi; -// Full SDK modules - only with "full" feature -#[cfg(feature = "full")] -#[cfg_attr(docsrs, doc(cfg(feature = "full")))] +// Build automation modules - only with builder/codegen features +#[cfg(feature = "builders")] +#[cfg_attr(docsrs, doc(cfg(feature = "builders")))] pub mod builders; -#[cfg(feature = "full")] -#[cfg_attr(docsrs, doc(cfg(feature = "full")))] +#[cfg(feature = "codegen")] +#[cfg_attr(docsrs, doc(cfg(feature = "codegen")))] pub mod codegen; -#[cfg(feature = "full")] -#[cfg_attr(docsrs, doc(cfg(feature = "full")))] + +// Registry runtime modules - available without build tooling +#[cfg(feature = "registry")] +#[cfg_attr(docsrs, doc(cfg(feature = "registry")))] pub mod registry; -#[cfg(feature = "full")] -#[cfg_attr(docsrs, doc(cfg(feature = "full")))] +#[cfg(feature = "registry")] +#[cfg_attr(docsrs, doc(cfg(feature = "registry")))] pub mod runner; -// Re-export the benchmark macro from bench-macros (only with full feature) -#[cfg(feature = "full")] -#[cfg_attr(docsrs, doc(cfg(feature = "full")))] +// Re-export the benchmark macro from bench-macros (only with registry feature) +#[cfg(feature = "registry")] +#[cfg_attr(docsrs, doc(cfg(feature = "registry")))] pub use mobench_macros::benchmark; // Re-export inventory so users don't need to add it as a separate dependency -#[cfg(feature = "full")] -#[cfg_attr(docsrs, doc(cfg(feature = "full")))] +#[cfg(feature = "registry")] +#[cfg_attr(docsrs, doc(cfg(feature = "registry")))] pub use inventory; -// Re-export key types for convenience (full feature) -#[cfg(feature = "full")] -#[cfg_attr(docsrs, doc(cfg(feature = "full")))] +// Re-export key registry types for convenience +#[cfg(feature = "registry")] +#[cfg_attr(docsrs, doc(cfg(feature = "registry")))] pub use registry::{BenchFunction, discover_benchmarks, find_benchmark, list_benchmark_names}; -#[cfg(feature = "full")] -#[cfg_attr(docsrs, doc(cfg(feature = "full")))] +#[cfg(feature = "registry")] +#[cfg_attr(docsrs, doc(cfg(feature = "registry")))] pub use runner::{BenchmarkBuilder, run_benchmark}; // Re-export types that are always available pub use types::{BenchError, BenchSample, BenchSpec, HarnessTimelineSpan, RunnerReport}; -// Re-export types that require full feature -#[cfg(feature = "full")] -#[cfg_attr(docsrs, doc(cfg(feature = "full")))] +// Re-export build/config types. These are plain data types and do not pull in +// build automation dependencies by themselves. pub use types::{ BuildConfig, BuildProfile, BuildResult, InitConfig, NativeLibraryArtifact, Target, }; @@ -439,8 +450,8 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// 2. Ensure functions are `pub` (public visibility) /// 3. Ensure the crate with benchmarks is linked into the binary /// 4. Check that `inventory` crate is in your dependencies -#[cfg(feature = "full")] -#[cfg_attr(docsrs, doc(cfg(feature = "full")))] +#[cfg(feature = "registry")] +#[cfg_attr(docsrs, doc(cfg(feature = "registry")))] #[macro_export] macro_rules! debug_benchmarks { () => { @@ -477,7 +488,7 @@ mod tests { assert!(!VERSION.is_empty()); } - #[cfg(feature = "full")] + #[cfg(feature = "registry")] #[test] fn test_discover_benchmarks_compiles() { // This test just ensures the function is accessible diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index 775dac2..4cce778 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -59,7 +59,7 @@ //! //! ```toml //! [dependencies] -//! mobench-sdk = { version = "0.1.36", default-features = false, features = ["runner-only"] } +//! mobench-sdk = { version = "0.1.37", default-features = false, features = ["runner-only"] } //! ``` use serde::{Deserialize, Serialize}; diff --git a/crates/mobench-sdk/src/uniffi_types.rs b/crates/mobench-sdk/src/uniffi_types.rs index 51471cd..ca56a4a 100644 --- a/crates/mobench-sdk/src/uniffi_types.rs +++ b/crates/mobench-sdk/src/uniffi_types.rs @@ -13,7 +13,7 @@ //! //! ```toml //! [dependencies] -//! mobench-sdk = "0.1.36" +//! mobench-sdk = "0.1.37" //! uniffi = { version = "0.28", features = ["cli"] } //! //! [build-dependencies] @@ -321,7 +321,7 @@ impl From<crate::timing::TimingError> for BenchErrorVariant { /// } /// } /// ``` -#[cfg(feature = "full")] +#[cfg(feature = "registry")] pub fn run_benchmark_template( spec: crate::BenchSpec, ) -> Result<BenchReportTemplate, BenchErrorVariant> { diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 55031fc..fcbad5b 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -62,6 +62,7 @@ criterion = "0.5" inventory = "0.3" sample-fns = { path = "../sample-fns" } jsonschema = "0.18" +proptest = "1" [[bench]] name = "host_contracts" diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index e8fb844..b13a973 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -6955,6 +6955,7 @@ mod tests { use super::*; use clap::CommandFactory; use jsonschema::JSONSchema; + use proptest::prelude::*; use std::path::Path; use tempfile::TempDir; @@ -8155,6 +8156,95 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert_eq!(ids, vec!["Pixel 6-12.0", "Pixel 7-13.0"]); } + fn safe_config_string() -> impl Strategy<Value = String> { + "[A-Za-z0-9_. -]{1,32}".prop_map(|s| s.trim().to_string()) + } + + fn safe_path_string() -> impl Strategy<Value = PathBuf> { + "[a-z0-9_/-]{1,32}".prop_map(PathBuf::from) + } + + fn generated_bench_config() -> impl Strategy<Value = BenchConfig> { + ( + prop_oneof![Just(MobileTarget::Android), Just(MobileTarget::Ios)], + safe_config_string(), + 1_u32..10_000, + 0_u32..1_000, + safe_path_string(), + prop::collection::vec(safe_config_string(), 0..5), + safe_config_string(), + safe_config_string(), + prop::option::of(safe_config_string()), + ) + .prop_map( + |( + target, + function, + iterations, + warmup, + device_matrix, + device_tags, + username, + access_key, + project, + )| BenchConfig { + target, + function, + iterations, + warmup, + device_matrix, + device_tags: Some(device_tags).filter(|tags| !tags.is_empty()), + browserstack: BrowserStackConfig { + app_automate_username: username, + app_automate_access_key: access_key, + project, + ios_completion_timeout_secs: None, + }, + ios_xcuitest: None, + }, + ) + } + + fn generated_device_entry() -> impl Strategy<Value = DeviceEntry> { + ( + safe_config_string(), + prop_oneof![Just("android".to_string()), Just("ios".to_string())], + "[0-9.]{1,8}", + prop::collection::vec(safe_config_string(), 0..5), + ) + .prop_map(|(name, os, os_version, tags)| DeviceEntry { + name, + os, + os_version, + tags: Some(tags).filter(|tags| !tags.is_empty()), + }) + } + + proptest! { + #[test] + fn generated_valid_run_configs_parse(config in generated_bench_config()) { + let encoded = toml::to_string(&config).expect("serialize generated run config"); + let parsed: BenchConfig = toml::from_str(&encoded).expect("parse generated run config"); + + prop_assert_eq!(parsed.target, config.target); + prop_assert_eq!(parsed.function, config.function); + prop_assert_eq!(parsed.iterations, config.iterations); + prop_assert_eq!(parsed.warmup, config.warmup); + prop_assert_eq!(parsed.device_matrix, config.device_matrix); + } + + #[test] + fn generated_valid_device_matrices_parse( + devices in prop::collection::vec(generated_device_entry(), 0..20) + ) { + let matrix = DeviceMatrix { devices }; + let encoded = serde_yaml::to_string(&matrix).expect("serialize generated device matrix"); + let parsed: DeviceMatrix = serde_yaml::from_str(&encoded).expect("parse generated device matrix"); + + prop_assert_eq!(parsed.devices.len(), matrix.devices.len()); + } + } + #[test] fn builtin_ios_low_spec_profile_uses_iphone_se_2020() { let resolved = builtin_device_for_profile(DevicePlatform::Ios, "low-spec") @@ -8685,6 +8775,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let summary_schema_path = root.join("docs/schemas/summary-v1.schema.json"); let ci_schema_path = root.join("docs/schemas/ci-contract-v1.schema.json"); + let trace_schema_path = root.join("docs/schemas/trace-events-v1.schema.json"); let summary_schema: Value = serde_json::from_str( &fs::read_to_string(&summary_schema_path).expect("read summary schema"), @@ -8693,6 +8784,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" let ci_schema: Value = serde_json::from_str(&fs::read_to_string(&ci_schema_path).expect("read ci schema")) .expect("parse ci schema"); + let trace_schema: Value = serde_json::from_str( + &fs::read_to_string(&trace_schema_path).expect("read trace schema"), + ) + .expect("parse trace schema"); JSONSchema::options() .compile(&summary_schema) @@ -8700,6 +8795,9 @@ test_suite = "target/ios/BenchRunnerUITests.zip" JSONSchema::options() .compile(&ci_schema) .expect("compile ci schema"); + JSONSchema::options() + .compile(&trace_schema) + .expect("compile trace schema"); } #[test] @@ -8820,6 +8918,33 @@ test_suite = "target/ios/BenchRunnerUITests.zip" } } + #[test] + fn example_trace_events_fixture_validates_against_trace_schema() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let trace_schema_path = root.join("docs/schemas/trace-events-v1.schema.json"); + let trace_schema: Value = serde_json::from_str( + &fs::read_to_string(&trace_schema_path).expect("read trace schema"), + ) + .expect("parse trace schema"); + let validator = JSONSchema::options() + .compile(&trace_schema) + .expect("compile trace schema"); + + let fixture_path = root.join("examples/fixtures/profile/trace-events.json"); + let value: Value = + serde_json::from_str(&fs::read_to_string(&fixture_path).expect("read trace fixture")) + .expect("parse trace fixture"); + + if let Err(errors) = validator.validate(&value) { + let messages: Vec<String> = errors.map(|e| e.to_string()).collect(); + panic!( + "{} failed trace schema validation: {}", + fixture_path.display(), + messages.join(" | ") + ); + } + } + #[test] fn basic_example_fixture_renders_stable_markdown_and_csv() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 0ffa431..4e25d2e 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -102,6 +102,11 @@ pub struct ProfileRunArgs { pub config: Option<PathBuf>, #[arg(long, default_value = "target/mobench/profile")] pub output_dir: PathBuf, + #[arg( + long, + help = "Write machine-readable trace/event JSON to this path for downstream consumers" + )] + pub trace_events_output: Option<PathBuf>, #[arg( long, help = "Explicit BrowserStack device name to resolve for this profiling request", @@ -657,6 +662,7 @@ fn write_profile_session_outputs( let run_summary_path = run_output_dir.join("summary.md"); write_semantic_phase_sidecar(manifest)?; write_harness_timeline_sidecar(manifest)?; + write_requested_trace_events_output(args, manifest)?; refresh_flamegraph_viewer_from_manifest(run_output_dir, manifest)?; write_profile_manifest(&run_profile_path, manifest)?; std::fs::write(&run_summary_path, rendered_summary.as_bytes())?; @@ -668,6 +674,29 @@ fn write_profile_session_outputs( Ok(()) } +fn write_requested_trace_events_output( + args: &ProfileRunArgs, + manifest: &ProfileManifest, +) -> Result<()> { + let Some(trace_path) = args.trace_events_output.as_ref() else { + return Ok(()); + }; + + let lanes = build_harness_only_viewer_timeline_lanes(manifest); + let total_duration_ns = compute_timeline_total_duration_ns( + &build_viewer_harness_timeline(manifest), + manifest.capture_metadata.sample_duration_secs, + ); + write_trace_events_contract( + trace_path, + manifest, + &lanes, + total_duration_ns.unwrap_or(0), + "mobench-trace-events", + )?; + Ok(()) +} + fn write_semantic_phase_sidecar(manifest: &ProfileManifest) -> Result<()> { let Some(path) = manifest.semantic_profile.spans_path.as_ref() else { return Ok(()); @@ -1451,7 +1480,38 @@ fn write_chronological_trace_sidecar( return Ok(None); } - let trace = ChronologicalTraceRecord { + let trace = chronological_trace_record(manifest, lanes, total_duration_ns, source_kind); + if let Some(parent) = trace_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(trace_path, serde_json::to_vec_pretty(&trace)?) + .with_context(|| format!("writing {}", trace_path.display()))?; + Ok(Some(trace_path.to_path_buf())) +} + +fn write_trace_events_contract( + trace_path: &Path, + manifest: &ProfileManifest, + lanes: &[ViewerTraceLane], + total_duration_ns: u64, + source_kind: &str, +) -> Result<()> { + let trace = chronological_trace_record(manifest, lanes, total_duration_ns, source_kind); + if let Some(parent) = trace_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(trace_path, serde_json::to_vec_pretty(&trace)?) + .with_context(|| format!("writing {}", trace_path.display()))?; + Ok(()) +} + +fn chronological_trace_record( + manifest: &ProfileManifest, + lanes: &[ViewerTraceLane], + total_duration_ns: u64, + source_kind: &str, +) -> ChronologicalTraceRecord { + ChronologicalTraceRecord { source: ChronologicalTraceSourceRecord { kind: source_kind.into(), profiler: manifest @@ -1471,13 +1531,7 @@ fn write_chronological_trace_sidecar( }, total_duration_ns, lanes: lanes.to_vec(), - }; - if let Some(parent) = trace_path.parent() { - std::fs::create_dir_all(parent)?; } - std::fs::write(trace_path, serde_json::to_vec_pretty(&trace)?) - .with_context(|| format!("writing {}", trace_path.display()))?; - Ok(Some(trace_path.to_path_buf())) } fn build_viewer_artifact_links( @@ -4482,6 +4536,7 @@ mod tests { crate_path: None, config: None, output_dir: PathBuf::from("target/mobench/profile"), + trace_events_output: None, device: None, os_version: None, profile: None, @@ -4662,6 +4717,7 @@ mod tests { backend: ProfileBackend::AndroidNative, format: ProfileFormat::Both, output_dir: dir.path().to_path_buf(), + trace_events_output: None, crate_path: None, device: None, os_version: None, @@ -4843,6 +4899,7 @@ mod tests { backend: ProfileBackend::IosInstruments, format: ProfileFormat::Both, output_dir: output_dir.to_path_buf(), + trace_events_output: None, crate_path: None, device: None, os_version: None, @@ -6015,6 +6072,73 @@ mod tests { assert_eq!(latest_manifest.function, "sample_fns::checksum"); } + #[test] + fn profile_run_writes_requested_machine_readable_trace_events() { + let dir = tempfile::tempdir().expect("temp dir"); + let trace_path = dir.path().join("consumer/trace-events.json"); + let mut args = sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ); + args.output_dir = dir.path().join("profiles"); + args.trace_events_output = Some(trace_path.clone()); + + run_profile_session_with_executor(&args, false, |_args, _target, manifest| { + manifest.semantic_profile.status = SemanticCaptureStatus::Captured; + manifest.semantic_profile.harness_timeline = vec![HarnessTimelineSpanRecord { + phase: "measured-benchmark".into(), + start_offset_ns: 100, + end_offset_ns: 900, + iteration: Some(0), + }]; + Ok(()) + }) + .expect("write profile session"); + + let trace: serde_json::Value = serde_json::from_slice( + &std::fs::read(&trace_path).expect("read downstream trace event output"), + ) + .expect("parse downstream trace event output"); + + assert_eq!(trace["source"]["kind"], "mobench-trace-events"); + assert_eq!(trace["source"]["origin"], "local"); + assert_eq!(trace["source"]["profiler"], "simpleperf"); + assert_eq!(trace["total_duration_ns"], 900); + assert_eq!(trace["lanes"][0]["id"], "harness"); + assert_eq!( + trace["lanes"][0]["events"][0]["phase"], + "measured-benchmark" + ); + assert_eq!(trace["lanes"][0]["events"][0]["iteration"], 0); + } + + #[test] + fn profile_dry_run_writes_empty_requested_trace_event_contract() { + let dir = tempfile::tempdir().expect("temp dir"); + let trace_path = dir.path().join("consumer/trace-events.json"); + let mut args = sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ); + args.output_dir = dir.path().join("profiles"); + args.trace_events_output = Some(trace_path.clone()); + + cmd_profile_run(&args, true).expect("write dry-run profile session"); + + let trace: serde_json::Value = serde_json::from_slice( + &std::fs::read(&trace_path).expect("read downstream trace event output"), + ) + .expect("parse downstream trace event output"); + + assert_eq!(trace["source"]["kind"], "mobench-trace-events"); + assert_eq!(trace["total_duration_ns"], 0); + assert_eq!(trace["lanes"].as_array().expect("lanes array").len(), 0); + } + #[test] fn profile_manifest_serializes_provider() { let manifest = build_capture_plan( diff --git a/crates/mobench/tests/profile_cli.rs b/crates/mobench/tests/profile_cli.rs index fe10b30..3da4445 100644 --- a/crates/mobench/tests/profile_cli.rs +++ b/crates/mobench/tests/profile_cli.rs @@ -110,6 +110,10 @@ fn profile_run_help_mentions_planned_only_or_execution_scope() { stdout.contains("--warmup-mode"), "expected help to expose warm/cold capture mode, got:\n{stdout}" ); + assert!( + stdout.contains("--trace-events-output"), + "expected help to expose downstream machine-readable trace output, got:\n{stdout}" + ); } #[test] @@ -127,6 +131,52 @@ fn profile_run_cli_surface_exposes_or_explicitly_omits_device_selection() { ); } +#[test] +fn profile_run_dry_run_writes_trace_events_output_for_downstream_consumers() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let output_dir = temp_dir.path().join("profiles"); + let trace_events_path = temp_dir.path().join("consumer/trace-events.json"); + let output = run_mobench([ + "--dry-run", + "profile", + "run", + "--target", + "android", + "--backend", + "android-native", + "--provider", + "local", + "--function", + "sample_fns::fibonacci", + "--output-dir", + output_dir.to_str().expect("utf-8 output dir"), + "--trace-events-output", + trace_events_path.to_str().expect("utf-8 trace path"), + ]); + + assert!( + output.status.success(), + "expected dry-run profile to succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + trace_events_path.exists(), + "expected trace events output at {}", + trace_events_path.display() + ); + + let trace: serde_json::Value = serde_json::from_slice( + &std::fs::read(&trace_events_path).expect("read trace events output"), + ) + .expect("parse trace events output"); + assert_eq!(trace["source"]["kind"], "mobench-trace-events"); + assert_eq!(trace["source"]["origin"], "local"); + assert_eq!(trace["source"]["profiler"], "simpleperf"); + assert_eq!(trace["total_duration_ns"], 0); + assert_eq!(trace["lanes"].as_array().expect("lanes array").len(), 0); +} + #[test] fn profile_diff_help_exposes_runtime_command_surface() { let output = run_mobench(["profile", "diff", "--help"]); diff --git a/docs/codebase/PUBLIC_API.md b/docs/codebase/PUBLIC_API.md index a5fc5ae..af55334 100644 --- a/docs/codebase/PUBLIC_API.md +++ b/docs/codebase/PUBLIC_API.md @@ -69,15 +69,22 @@ Stable public surface: ## Feature Flags -`mobench-sdk` currently exposes two features: - -- `full`: default feature. Enables the benchmark macro, inventory registry, - builders, embedded templates, codegen, and TOML-backed build automation. +`mobench-sdk` currently exposes four feature groups: + +- `full`: default feature. Enables benchmark registration/runtime plus builders, + embedded templates, codegen, and TOML-backed build automation. +- `registry`: enables `#[benchmark]`, inventory-backed discovery, and + `run_benchmark` without builder/template dependencies. +- `builders`: enables Android/iOS build automation and the codegen it depends on. +- `codegen`: enables project and mobile template generation. - `runner-only`: minimal mobile-runtime mode for generated/mobile benchmark - binaries where build automation is not needed. + binaries that manually dispatch timing closures and do not use registry + discovery. Feature policy: - keep `runner-only` free of build tooling dependencies +- prefer `registry` for generated/mobile crates that need benchmark discovery + but not build automation - keep default features convenient for normal SDK users - add narrower optional features only when they measurably reduce dependency footprint or clarify platform support diff --git a/docs/guides/profiling.md b/docs/guides/profiling.md index 0514ddb..233f6f1 100644 --- a/docs/guides/profiling.md +++ b/docs/guides/profiling.md @@ -12,15 +12,15 @@ Supported today: | Provider | Backend | What you get | |----------|---------|--------------| -| `local` | `android-native` | `simpleperf` capture, symbolized folded stacks, `native-report.txt`, optional `frame-locations.json`, full/focused flamegraph SVGs, `flamegraph.html`, and optional semantic phase summaries | -| `local` | `ios-instruments` | simulator-host `sample` capture, collapsed folded stacks, `native-report.txt`, full/focused flamegraph SVGs, `flamegraph.html`, and optional semantic phase summaries | +| `local` | `android-native` | `simpleperf` capture, symbolized folded stacks, `native-report.txt`, optional `frame-locations.json`, full/focused flamegraph SVGs, `flamegraph.html`, optional semantic phase summaries, and optional machine-readable trace/event JSON | +| `local` | `ios-instruments` | simulator-host `sample` capture, collapsed folded stacks, `native-report.txt`, full/focused flamegraph SVGs, `flamegraph.html`, optional semantic phase summaries, and optional machine-readable trace/event JSON | Not supported today: | Provider | Backend | Why | |----------|---------|-----| | `browserstack` | `android-native` / `ios-instruments` | BrowserStack benchmark runs can return timing and resource data, but not retrievable native-stack artifacts in the mobench session contract | -| `local` | `rust-tracing` | the backend is planned, but structured trace-event output is not implemented yet | +| `local` | `rust-tracing` | native rust-tracing capture is planned; use `--trace-events-output` on supported profile runs when a downstream consumer needs mobench's harness event contract | ## Quick start @@ -31,7 +31,8 @@ cargo mobench profile run \ --target android \ --provider local \ --backend android-native \ - --function sample_fns::fibonacci + --function sample_fns::fibonacci \ + --trace-events-output target/mobench/profile/trace-events.json ``` ### iOS diff --git a/docs/guides/sdk-integration.md b/docs/guides/sdk-integration.md index 7a28aac..179ad19 100644 --- a/docs/guides/sdk-integration.md +++ b/docs/guides/sdk-integration.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.36" +mobench-sdk = "0.1.37" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,15 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.36" +mobench-sdk = "0.1.37" +``` + +If the crate only needs benchmark registration and runtime execution, and does +not call SDK builders or project generators directly, use: + +```toml +[dependencies] +mobench-sdk = { version = "0.1.37", default-features = false, features = ["registry"] } ``` ## 3) Annotate benchmark functions diff --git a/docs/schemas/trace-events-v1.schema.json b/docs/schemas/trace-events-v1.schema.json new file mode 100644 index 0000000..73e866e --- /dev/null +++ b/docs/schemas/trace-events-v1.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://world.org/mobench/schemas/trace-events-v1.schema.json", + "title": "Mobench Trace Events v1", + "type": "object", + "required": ["source", "total_duration_ns", "lanes"], + "properties": { + "source": { + "type": "object", + "required": ["kind", "profiler", "origin"], + "properties": { + "kind": { "type": "string" }, + "profiler": { "type": "string" }, + "origin": { "type": "string", "enum": ["local", "browserstack"] } + }, + "additionalProperties": false + }, + "total_duration_ns": { "type": "integer", "minimum": 0 }, + "lanes": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "label", "events"], + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" }, + "events": { + "type": "array", + "items": { + "type": "object", + "required": [ + "event_kind", + "start_offset_ns", + "end_offset_ns", + "frames", + "phase", + "iteration" + ], + "properties": { + "event_kind": { "type": "string" }, + "start_offset_ns": { "type": "integer", "minimum": 0 }, + "end_offset_ns": { "type": ["integer", "null"], "minimum": 0 }, + "frames": { + "type": "array", + "items": { "type": "string" } + }, + "phase": { "type": ["string", "null"] }, + "iteration": { "type": ["integer", "null"], "minimum": 0 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/examples/basic-benchmark/Cargo.toml b/examples/basic-benchmark/Cargo.toml index fa6822a..8c54c43 100644 --- a/examples/basic-benchmark/Cargo.toml +++ b/examples/basic-benchmark/Cargo.toml @@ -11,7 +11,7 @@ name = "basic_benchmark" crate-type = ["lib", "cdylib", "staticlib"] [dependencies] -mobench-sdk = { workspace = true, features = ["full"] } +mobench-sdk = { workspace = true, default-features = false, features = ["registry"] } inventory.workspace = true serde.workspace = true thiserror.workspace = true diff --git a/examples/ffi-benchmark/Cargo.toml b/examples/ffi-benchmark/Cargo.toml index 35ceca3..9849b1d 100644 --- a/examples/ffi-benchmark/Cargo.toml +++ b/examples/ffi-benchmark/Cargo.toml @@ -11,7 +11,7 @@ name = "ffi_benchmark" crate-type = ["lib", "cdylib", "staticlib"] [dependencies] -mobench-sdk = { workspace = true, features = ["full"] } +mobench-sdk = { workspace = true, default-features = false, features = ["registry"] } inventory.workspace = true serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/examples/fixtures/profile/trace-events.json b/examples/fixtures/profile/trace-events.json new file mode 100644 index 0000000..8b34908 --- /dev/null +++ b/examples/fixtures/profile/trace-events.json @@ -0,0 +1,24 @@ +{ + "source": { + "kind": "mobench-trace-events", + "profiler": "simpleperf", + "origin": "local" + }, + "total_duration_ns": 900, + "lanes": [ + { + "id": "harness", + "label": "Harness", + "events": [ + { + "event_kind": "span", + "start_offset_ns": 100, + "end_offset_ns": 900, + "frames": [], + "phase": "measured-benchmark", + "iteration": 0 + } + ] + } + ] +} From af8cb37f50e30bdbf2c0ed2f99e43266bc2f1b33 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Mon, 27 Apr 2026 21:12:25 +0200 Subject: [PATCH 194/196] fix: gate macos-only ios test import --- crates/mobench-sdk/src/builders/ios.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 882647c..9b92fdb 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -2064,6 +2064,7 @@ impl IosBuilder { #[cfg(test)] mod tests { use super::*; + #[cfg(target_os = "macos")] use std::io::Write; #[test] From f1cd767cac0c0c064b91c301992a655e3cbbcaaa Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Mon, 27 Apr 2026 21:44:34 +0200 Subject: [PATCH 195/196] docs: move workflow diagrams near release notes --- README.md | 186 +++++++++++++++++++++++++++--------------------------- 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index a0a121a..2b45bab 100644 --- a/README.md +++ b/README.md @@ -29,99 +29,6 @@ that CI and humans can compare. - Mobile apps call `run_benchmark` via the generated bindings and return timing samples - The CLI collects results locally or from BrowserStack and writes summaries -## Workflow diagrams - -The Mermaid sources live under `docs/diagrams/` so the same diagrams can be -reused in launch posts and landing-page assets. - -### Crate architecture - -```mermaid -flowchart LR - user["Benchmark crate"] --> macro["mobench-macros\n#[benchmark]"] - macro --> registry["mobench-sdk registry\ninventory"] - registry --> runner["mobench-sdk runner\nBenchSpec -> BenchReport"] - runner --> templates["Generated Android/iOS runners"] - cli["mobench CLI"] --> builders["SDK builders"] - builders --> templates - cli --> reports["JSON / Markdown / CSV / plots"] - templates --> reports -``` - -### Benchmark lifecycle - -```mermaid -sequenceDiagram - participant Dev as Developer - participant CLI as mobench CLI - participant SDK as mobench-sdk - participant App as Generated mobile app - participant Device as Device or BrowserStack - participant Reports as Reports - - Dev->>CLI: cargo mobench run - CLI->>SDK: resolve crate and benchmark spec - SDK->>SDK: build native libraries and generate bindings - SDK->>App: embed bench_spec.json and templates - CLI->>Device: install/upload and start run - Device->>App: execute benchmark function - App->>CLI: emit BenchReport JSON - CLI->>Reports: write summary.json, summary.md, results.csv -``` - -### BrowserStack CI lifecycle - -```mermaid -flowchart TD - workflow["GitHub Actions"] --> resolve["Resolve device matrix"] - resolve --> build["Build APK or IPA/XCUITest"] - build --> upload["Upload artifacts to BrowserStack"] - upload --> run["Run benchmark on selected devices"] - run --> fetch["Fetch logs, reports, and metrics"] - fetch --> normalize["Normalize timing, CPU, and memory"] - normalize --> outputs["summary.json\nsummary.md\nresults.csv\nplots"] - outputs --> pr["Optional PR comment/check run"] -``` - -### Profiling artifact lifecycle - -```mermaid -flowchart LR - run["profile run"] --> manifest["profile.json\nnative_capture\nsemantic_profile\ncapture_metadata"] - run --> raw["raw capture\nsimpleperf or sample"] - raw --> processed["processed stacks\nstacks.folded\nnative-report.txt"] - processed --> viewer["flamegraph.html\nfull and focused SVGs"] - manifest --> summary["summary.md"] - manifest --> semantic["artifacts/semantic/phases.json"] - viewer --> diff["profile diff\nbaseline vs candidate"] - summary --> diff -``` - -### SDK versus CLI responsibilities - -```mermaid -flowchart TB - subgraph SDK["mobench-sdk"] - timing["timing harness"] - registry["benchmark registry"] - builders["Android/iOS builders"] - codegen["template/codegen"] - ffi["FFI-safe types"] - end - - subgraph CLI["mobench CLI"] - config["config and project resolution"] - orchestration["build/run/profile orchestration"] - providers["BrowserStack and local providers"] - reporting["summary, plots, PR reports"] - end - - SDK --> CLI - CLI --> SDK - user["Downstream benchmark crate"] --> SDK - ci["CI workflow"] --> CLI -``` - ## Workspace crates - `crates/mobench` ([mobench](https://crates.io/crates/mobench)): CLI tool that builds, runs, and fetches benchmarks @@ -405,6 +312,99 @@ fn db_query(db: &Database) { | `#[benchmark(setup = fn, per_iteration)]` | Benchmarks that mutate input, need fresh data each time | | `#[benchmark(setup = fn, teardown = fn)]` | Resources requiring cleanup (connections, files, etc.) | +## Workflow diagrams + +The Mermaid sources live under `docs/diagrams/` so the same diagrams can be +reused in launch posts and landing-page assets. + +### Crate architecture + +```mermaid +flowchart LR + user["Benchmark crate"] --> macro["mobench-macros\n#[benchmark]"] + macro --> registry["mobench-sdk registry\ninventory"] + registry --> runner["mobench-sdk runner\nBenchSpec -> BenchReport"] + runner --> templates["Generated Android/iOS runners"] + cli["mobench CLI"] --> builders["SDK builders"] + builders --> templates + cli --> reports["JSON / Markdown / CSV / plots"] + templates --> reports +``` + +### Benchmark lifecycle + +```mermaid +sequenceDiagram + participant Dev as Developer + participant CLI as mobench CLI + participant SDK as mobench-sdk + participant App as Generated mobile app + participant Device as Device or BrowserStack + participant Reports as Reports + + Dev->>CLI: cargo mobench run + CLI->>SDK: resolve crate and benchmark spec + SDK->>SDK: build native libraries and generate bindings + SDK->>App: embed bench_spec.json and templates + CLI->>Device: install/upload and start run + Device->>App: execute benchmark function + App->>CLI: emit BenchReport JSON + CLI->>Reports: write summary.json, summary.md, results.csv +``` + +### BrowserStack CI lifecycle + +```mermaid +flowchart TD + workflow["GitHub Actions"] --> resolve["Resolve device matrix"] + resolve --> build["Build APK or IPA/XCUITest"] + build --> upload["Upload artifacts to BrowserStack"] + upload --> run["Run benchmark on selected devices"] + run --> fetch["Fetch logs, reports, and metrics"] + fetch --> normalize["Normalize timing, CPU, and memory"] + normalize --> outputs["summary.json\nsummary.md\nresults.csv\nplots"] + outputs --> pr["Optional PR comment/check run"] +``` + +### Profiling artifact lifecycle + +```mermaid +flowchart LR + run["profile run"] --> manifest["profile.json\nnative_capture\nsemantic_profile\ncapture_metadata"] + run --> raw["raw capture\nsimpleperf or sample"] + raw --> processed["processed stacks\nstacks.folded\nnative-report.txt"] + processed --> viewer["flamegraph.html\nfull and focused SVGs"] + manifest --> summary["summary.md"] + manifest --> semantic["artifacts/semantic/phases.json"] + viewer --> diff["profile diff\nbaseline vs candidate"] + summary --> diff +``` + +### SDK versus CLI responsibilities + +```mermaid +flowchart TB + subgraph SDK["mobench-sdk"] + timing["timing harness"] + registry["benchmark registry"] + builders["Android/iOS builders"] + codegen["template/codegen"] + ffi["FFI-safe types"] + end + + subgraph CLI["mobench CLI"] + config["config and project resolution"] + orchestration["build/run/profile orchestration"] + providers["BrowserStack and local providers"] + reporting["summary, plots, PR reports"] + end + + SDK --> CLI + CLI --> SDK + user["Downstream benchmark crate"] --> SDK + ci["CI workflow"] --> CLI +``` + ## Release Notes Published release history and support status live in From 581ba614a98a624fa6bf0d27577d768b3b67de17 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" <dcbuilder@pm.me> Date: Sat, 2 May 2026 00:43:09 +0200 Subject: [PATCH 196/196] Add Android benchmark watchdog release Squash merge PR #30 after publishing mobench 0.1.39. --- Cargo.lock | 8 +- Cargo.toml | 6 +- README.md | 2 + crates/mobench-sdk/README.md | 1 + crates/mobench-sdk/src/builders/common.rs | 18 +- crates/mobench-sdk/src/builders/ios.rs | 137 +- crates/mobench-sdk/src/codegen.rs | 344 ++++- .../java/MainActivityTest.kt.template | 29 +- .../src/main/java/MainActivity.kt.template | 367 ++++- .../UIKitLegacyRunner.swift.template | 163 ++ .../ios/BenchRunner/project.yml.template | 4 +- crates/mobench/src/browserstack.rs | 375 ++++- crates/mobench/src/cli.rs | 70 + crates/mobench/src/config.rs | 14 + crates/mobench/src/lib.rs | 1353 +++++++++++++++-- crates/mobench/src/profile.rs | 10 +- crates/mobench/src/summarize.rs | 292 +++- docs/guides/browserstack-ci.md | 28 + docs/specs/mobench-device-farm-spec.md | 1084 +++++++++++++ .../UIKitLegacyRunner.swift.template | 163 ++ .../ios/BenchRunner/project.yml.template | 4 +- 21 files changed, 4284 insertions(+), 188 deletions(-) create mode 100644 crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template create mode 100644 docs/specs/mobench-device-farm-spec.md create mode 100644 templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template diff --git a/Cargo.lock b/Cargo.lock index b427b73..64d4bb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1227,7 +1227,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.37" +version = "0.1.39" dependencies = [ "anyhow", "clap", @@ -1254,7 +1254,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.37" +version = "0.1.39" dependencies = [ "proc-macro2", "quote", @@ -1264,7 +1264,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.37" +version = "0.1.39" dependencies = [ "include_dir", "inventory", @@ -1884,7 +1884,7 @@ dependencies = [ [[package]] name = "sample-fns" -version = "0.1.37" +version = "0.1.39" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index a1db2ed..75fee9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ resolver = "2" edition = "2024" license = "MIT" rust-version = "1.85" -version = "0.1.37" +version = "0.1.39" [workspace.dependencies] anyhow = "1" @@ -34,5 +34,5 @@ proc-macro2 = "1" # Phase 1: Template embedding include_dir = "0.7" -mobench-macros = { version = "0.1.37", path = "crates/mobench-macros" } -mobench-sdk = { version = "0.1.37", path = "crates/mobench-sdk", default-features = false } +mobench-macros = { version = "0.1.39", path = "crates/mobench-macros" } +mobench-sdk = { version = "0.1.39", path = "crates/mobench-sdk", default-features = false } diff --git a/README.md b/README.md index 2b45bab..faa851d 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ min_sdk = 24 [ios] bundle_id = "com.example.bench" deployment_target = "15.0" +# runner = "swiftui" # or "uikit-legacy" for legacy iOS deployment targets [benchmarks] default_function = "my_crate::my_benchmark" @@ -217,6 +218,7 @@ Resolution precedence is: explicit CLI flags (`--project-root`, `--crate-path`) CLI flags override config file values when provided. - In `cargo mobench run --config <FILE>` mode, `--device-matrix <FILE>` overrides `device_matrix` from the config file. +- iOS deployment targets below 15.0 use the UIKit legacy runner by default; forcing `runner = "swiftui"` below 15.0 fails early. Legacy targets such as iPhone 7 on iOS 10 also require an older Xcode lane capable of building for that OS. - For regression comparisons, `--baseline` should point to a previous run summary; if it resolves to the same output path, mobench snapshots the prior file before writing the candidate summary. - In the reusable GitHub workflow, the default baseline source is the latest successful run on the repository default branch when matching artifacts are available. - `cargo mobench verify --smoke-test` is only supported for benchmark crates linked into the `mobench` CLI binary. External crates discovered through `mobench.toml`, `--project-root`, or `--crate-path` should use `cargo mobench list` and `cargo mobench verify --check-artifacts`. diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index cf9179a..abcc762 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -408,6 +408,7 @@ target_sdk = 34 [ios] bundle_id = "com.example.bench" deployment_target = "15.0" +# runner = "swiftui" # or "uikit-legacy" for legacy iOS targets [benchmarks] default_function = "my_crate::my_benchmark" diff --git a/crates/mobench-sdk/src/builders/common.rs b/crates/mobench-sdk/src/builders/common.rs index f8c7b49..68a18ae 100644 --- a/crates/mobench-sdk/src/builders/common.rs +++ b/crates/mobench-sdk/src/builders/common.rs @@ -791,10 +791,21 @@ members = ["crates/*"] let _ = std::fs::remove_dir_all(&temp_dir); std::fs::create_dir_all(&temp_dir).unwrap(); - let spec = EmbeddedBenchSpec { + #[derive(serde::Serialize)] + struct AndroidSpec { + function: String, + iterations: u32, + warmup: u32, + android_benchmark_timeout_secs: Option<u64>, + android_heartbeat_interval_secs: Option<u64>, + } + + let spec = AndroidSpec { function: "test_crate::first_run".to_string(), iterations: 7, warmup: 1, + android_benchmark_timeout_secs: Some(30), + android_heartbeat_interval_secs: Some(5), }; embed_bench_spec(&temp_dir, &spec).expect("embed spec"); @@ -812,6 +823,11 @@ members = ["crates/*"] let contents = std::fs::read_to_string(android_spec).unwrap(); assert!(contents.contains("test_crate::first_run")); + assert!(contents.contains("android_benchmark_timeout_secs")); + assert!(contents.contains("android_heartbeat_interval_secs")); + let json: serde_json::Value = serde_json::from_str(&contents).unwrap(); + assert_eq!(json["android_benchmark_timeout_secs"], 30); + assert_eq!(json["android_heartbeat_interval_secs"], 5); std::fs::remove_dir_all(&temp_dir).unwrap(); } diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 9b92fdb..c8ae744 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -71,6 +71,7 @@ //! ``` use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root}; +use crate::codegen::{IosDeploymentTarget, IosProjectOptions, IosRunner, resolve_ios_runner}; use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; use std::env; use std::fs; @@ -78,6 +79,91 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; +fn resolve_ios_benchmark_timeout_secs_from_env() -> u64 { + env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS") + .ok() + .and_then(|raw| raw.parse::<u64>().ok()) + .filter(|secs| *secs > 0) + .unwrap_or(crate::codegen::DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct XcodeVersion { + pub major: u16, + pub minor: u16, + pub raw: String, +} + +fn parse_xcode_version(output: &str) -> Option<XcodeVersion> { + let line = output.lines().find(|line| line.starts_with("Xcode "))?; + let raw_version = line.trim_start_matches("Xcode ").trim(); + let mut parts = raw_version.split('.'); + let major = parts.next()?.parse::<u16>().ok()?; + let minor = parts + .next() + .and_then(|part| part.parse::<u16>().ok()) + .unwrap_or(0); + Some(XcodeVersion { + major, + minor, + raw: raw_version.to_string(), + }) +} + +fn selected_xcode_version() -> Result<XcodeVersion, BenchError> { + let output = Command::new("xcodebuild") + .arg("-version") + .output() + .map_err(|err| { + BenchError::Build(format!( + "Failed to run `xcodebuild -version`: {err}. Install/select Xcode before building iOS artifacts." + )) + })?; + + if !output.status.success() { + return Err(BenchError::Build(format!( + "`xcodebuild -version` failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_xcode_version(&stdout).ok_or_else(|| { + BenchError::Build(format!( + "Failed to parse selected Xcode version from `xcodebuild -version` output:\n{stdout}" + )) + }) +} + +fn minimum_supported_ios_deployment_target_for_xcode( + xcode: &XcodeVersion, +) -> Result<IosDeploymentTarget, BenchError> { + let floor = if xcode.major >= 15 { + "15.0" + } else if xcode.major == 14 { + "11.0" + } else { + "10.0" + }; + IosDeploymentTarget::parse(floor) +} + +pub fn validate_xcode_supports_ios_deployment_target( + deployment_target: &IosDeploymentTarget, +) -> Result<(), BenchError> { + let xcode = selected_xcode_version()?; + let supported_floor = minimum_supported_ios_deployment_target_for_xcode(&xcode)?; + if deployment_target < &supported_floor { + return Err(BenchError::Build(format!( + "iOS deployment target {deployment_target} requires an older Xcode toolchain; \ + selected Xcode {} supports iOS {}+ in mobench's supported lanes. \ + Use a legacy CI lane with an older Xcode capable of iOS 10/11/12, or raise `[ios].deployment_target`.", + xcode.raw, supported_floor + ))); + } + Ok(()) +} + /// iOS builder that handles the complete build pipeline. /// /// This builder automates the process of compiling Rust code to iOS static @@ -119,6 +205,10 @@ pub struct IosBuilder { crate_dir: Option<PathBuf>, /// Whether to run in dry-run mode (print what would be done without making changes) dry_run: bool, + /// iOS deployment target to emit into the generated Xcode project. + deployment_target: IosDeploymentTarget, + /// Optional requested runner. If omitted, the runner is selected from the deployment target. + runner: Option<IosRunner>, } impl IosBuilder { @@ -153,6 +243,8 @@ impl IosBuilder { verbose: false, crate_dir: None, dry_run: false, + deployment_target: IosDeploymentTarget::default_target(), + runner: None, } } @@ -194,6 +286,18 @@ impl IosBuilder { self } + /// Sets the iOS deployment target for generated app and XCUITest targets. + pub fn deployment_target(mut self, deployment_target: IosDeploymentTarget) -> Self { + self.deployment_target = deployment_target; + self + } + + /// Sets the iOS runner template explicitly. + pub fn runner(mut self, runner: Option<IosRunner>) -> Self { + self.runner = runner; + self + } + /// Builds the iOS app with the given configuration /// /// This performs the following steps: @@ -213,6 +317,10 @@ impl IosBuilder { if self.crate_dir.is_none() { validate_project_root(&self.project_root, &self.crate_name)?; } + let runner = resolve_ios_runner(&self.deployment_target, self.runner)?; + if !self.dry_run { + validate_xcode_supports_ios_deployment_target(&self.deployment_target)?; + } let framework_name = self.crate_name.replace("-", "_"); let ios_dir = self.output_dir.join("ios"); @@ -279,11 +387,16 @@ impl IosBuilder { // Step 0: Ensure iOS project scaffolding exists // Pass project_root and crate_dir for better benchmark function detection - crate::codegen::ensure_ios_project_with_options( + crate::codegen::ensure_ios_project_with_project_options( &self.output_dir, &self.crate_name, Some(&self.project_root), self.crate_dir.as_deref(), + IosProjectOptions { + deployment_target: self.deployment_target.clone(), + runner, + ios_benchmark_timeout_secs: resolve_ios_benchmark_timeout_secs_from_env(), + }, )?; // Step 1: Build Rust libraries @@ -1494,6 +1607,7 @@ impl IosBuilder { /// # Ok::<(), mobench_sdk::BenchError>(()) /// ``` pub fn package_ipa(&self, scheme: &str, method: SigningMethod) -> Result<PathBuf, BenchError> { + validate_xcode_supports_ios_deployment_target(&self.deployment_target)?; // For repository structure: ios/BenchRunner/BenchRunner.xcodeproj // The directory and scheme happen to have the same name let ios_dir = self.output_dir.join("ios").join(scheme); @@ -1782,6 +1896,7 @@ impl IosBuilder { /// This requires the app project to be generated first with `build()`. /// The resulting zip can be supplied to BrowserStack as the test suite. pub fn package_xcuitest(&self, scheme: &str) -> Result<PathBuf, BenchError> { + validate_xcode_supports_ios_deployment_target(&self.deployment_target)?; let ios_dir = self.output_dir.join("ios").join(scheme); let project_path = ios_dir.join(format!("{}.xcodeproj", scheme)); @@ -2090,6 +2205,26 @@ mod tests { assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); } + #[test] + fn parse_xcode_version_reads_major_minor() { + let parsed = parse_xcode_version("Xcode 16.2\nBuild version 16C5032a\n").unwrap(); + assert_eq!(parsed.major, 16); + assert_eq!(parsed.minor, 2); + assert_eq!(parsed.raw, "16.2"); + } + + #[test] + fn xcode_floor_rejects_legacy_target_on_current_lane() { + let xcode = XcodeVersion { + major: 16, + minor: 2, + raw: "16.2".to_string(), + }; + let floor = minimum_supported_ios_deployment_target_for_xcode(&xcode).unwrap(); + assert_eq!(floor.to_string(), "15.0"); + assert!(IosDeploymentTarget::parse("10.0").unwrap() < floor); + } + #[cfg(target_os = "macos")] #[test] fn test_validate_ipa_archive_rejects_missing_info_plist() { diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 43d9566..45ededf 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -12,7 +12,161 @@ use include_dir::{Dir, DirEntry, include_dir}; const ANDROID_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/android"); const IOS_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/ios"); -const DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300; +pub const DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS: u64 = 300; +pub const DEFAULT_IOS_DEPLOYMENT_TARGET: &str = "15.0"; +pub const SWIFTUI_RUNNER_MIN_IOS: &str = "15.0"; + +/// Supported generated iOS application runner templates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IosRunner { + /// Current SwiftUI runner. This is the default for iOS 15+. + Swiftui, + /// UIKit-based runner for legacy deployment targets. + UikitLegacy, +} + +impl IosRunner { + pub fn parse(value: &str) -> Result<Self, BenchError> { + match value.trim().to_ascii_lowercase().as_str() { + "swiftui" => Ok(Self::Swiftui), + "uikit-legacy" | "uikit_legacy" => Ok(Self::UikitLegacy), + other => Err(BenchError::Build(format!( + "Unsupported iOS runner `{other}`. Supported values: swiftui, uikit-legacy" + ))), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Swiftui => "swiftui", + Self::UikitLegacy => "uikit-legacy", + } + } +} + +/// Parsed iOS deployment target used for explicit compatibility decisions. +#[derive(Debug, Clone, Eq)] +pub struct IosDeploymentTarget { + major: u16, + minor: u16, + patch: u16, + raw: String, +} + +impl PartialEq for IosDeploymentTarget { + fn eq(&self, other: &Self) -> bool { + (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch) + } +} + +impl Ord for IosDeploymentTarget { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)) + } +} + +impl PartialOrd for IosDeploymentTarget { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } +} + +impl IosDeploymentTarget { + pub fn parse(value: &str) -> Result<Self, BenchError> { + let raw = value.trim(); + if raw.is_empty() { + return Err(BenchError::Build( + "iOS deployment target must not be empty".to_string(), + )); + } + + let parts = raw.split('.').collect::<Vec<_>>(); + if parts.len() > 3 { + return Err(BenchError::Build(format!( + "Invalid iOS deployment target `{raw}`. Expected VERSION like 15.0" + ))); + } + + let major = parse_ios_version_part(raw, parts[0], "major")?; + let minor = parts + .get(1) + .map(|part| parse_ios_version_part(raw, part, "minor")) + .transpose()? + .unwrap_or(0); + let patch = parts + .get(2) + .map(|part| parse_ios_version_part(raw, part, "patch")) + .transpose()? + .unwrap_or(0); + + Ok(Self { + major, + minor, + patch, + raw: raw.to_string(), + }) + } + + pub fn default_target() -> Self { + Self::parse(DEFAULT_IOS_DEPLOYMENT_TARGET) + .expect("default iOS deployment target should be valid") + } +} + +fn parse_ios_version_part(raw: &str, part: &str, label: &str) -> Result<u16, BenchError> { + if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) { + return Err(BenchError::Build(format!( + "Invalid iOS deployment target `{raw}`: {label} version component must be numeric" + ))); + } + part.parse::<u16>().map_err(|err| { + BenchError::Build(format!( + "Invalid iOS deployment target `{raw}`: failed to parse {label} component: {err}" + )) + }) +} + +impl std::fmt::Display for IosDeploymentTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.raw) + } +} + +/// Fully resolved iOS project generation options. +#[derive(Debug, Clone)] +pub struct IosProjectOptions { + pub deployment_target: IosDeploymentTarget, + pub runner: IosRunner, + pub ios_benchmark_timeout_secs: u64, +} + +impl Default for IosProjectOptions { + fn default() -> Self { + Self { + deployment_target: IosDeploymentTarget::default_target(), + runner: IosRunner::Swiftui, + ios_benchmark_timeout_secs: DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS, + } + } +} + +pub fn resolve_ios_runner( + deployment_target: &IosDeploymentTarget, + requested_runner: Option<IosRunner>, +) -> Result<IosRunner, BenchError> { + let swiftui_floor = IosDeploymentTarget::parse(SWIFTUI_RUNNER_MIN_IOS)?; + match requested_runner { + Some(IosRunner::Swiftui) if deployment_target < &swiftui_floor => { + Err(BenchError::Build(format!( + "iOS runner `swiftui` requires deployment target {SWIFTUI_RUNNER_MIN_IOS}+; \ + requested deployment target is {deployment_target}. Use `uikit-legacy` or raise the deployment target." + ))) + } + Some(runner) => Ok(runner), + None if deployment_target < &swiftui_floor => Ok(IosRunner::UikitLegacy), + None => Ok(IosRunner::Swiftui), + } +} /// Template variable that can be replaced in template files #[derive(Debug, Clone)] @@ -555,16 +709,21 @@ pub fn generate_ios_project( .ok() .as_deref(), ); - generate_ios_project_with_timeout( + generate_ios_project_with_options( output_dir, project_slug, project_pascal, bundle_prefix, default_function, - ios_benchmark_timeout_secs, + IosProjectOptions { + ios_benchmark_timeout_secs, + ..IosProjectOptions::default() + }, ) } +#[cfg(test)] +#[allow(dead_code)] fn generate_ios_project_with_timeout( output_dir: &Path, project_slug: &str, @@ -573,6 +732,28 @@ fn generate_ios_project_with_timeout( default_function: &str, ios_benchmark_timeout_secs: u64, ) -> Result<(), BenchError> { + generate_ios_project_with_options( + output_dir, + project_slug, + project_pascal, + bundle_prefix, + default_function, + IosProjectOptions { + ios_benchmark_timeout_secs, + ..IosProjectOptions::default() + }, + ) +} + +pub fn generate_ios_project_with_options( + output_dir: &Path, + project_slug: &str, + project_pascal: &str, + bundle_prefix: &str, + default_function: &str, + options: IosProjectOptions, +) -> Result<(), BenchError> { + let runner = resolve_ios_runner(&options.deployment_target, Some(options.runner))?; let target_dir = output_dir.join("ios"); let preserved_resources = collect_preserved_ios_resources(&target_dir)?; reset_generated_project_dir(&target_dir)?; @@ -612,10 +793,18 @@ fn generate_ios_project_with_timeout( }, TemplateVar { name: "IOS_BENCHMARK_TIMEOUT_SECS", - value: ios_benchmark_timeout_secs.to_string(), + value: options.ios_benchmark_timeout_secs.to_string(), + }, + TemplateVar { + name: "IOS_DEPLOYMENT_TARGET", + value: options.deployment_target.to_string(), + }, + TemplateVar { + name: "IOS_RUNNER", + value: runner.as_str().to_string(), }, ]; - render_dir(&IOS_TEMPLATES, &target_dir, &vars)?; + render_ios_dir(&IOS_TEMPLATES, &target_dir, &vars, runner)?; restore_preserved_ios_resources(&target_dir, &preserved_resources)?; Ok(()) } @@ -737,6 +926,32 @@ const TEMPLATE_EXTENSIONS: &[&str] = &[ ]; fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), BenchError> { + render_dir_filtered(dir, out_root, vars, &|_| false) +} + +fn render_ios_dir( + dir: &Dir, + out_root: &Path, + vars: &[TemplateVar], + runner: IosRunner, +) -> Result<(), BenchError> { + render_dir_filtered(dir, out_root, vars, &|path| match runner { + IosRunner::Swiftui => { + path == Path::new("BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template") + } + IosRunner::UikitLegacy => { + path == Path::new("BenchRunner/BenchRunner/BenchRunnerApp.swift.template") + || path == Path::new("BenchRunner/BenchRunner/ContentView.swift.template") + } + }) +} + +fn render_dir_filtered( + dir: &Dir, + out_root: &Path, + vars: &[TemplateVar], + skip_file: &dyn Fn(&Path) -> bool, +) -> Result<(), BenchError> { for entry in dir.entries() { match entry { DirEntry::Dir(sub) => { @@ -744,12 +959,15 @@ fn render_dir(dir: &Dir, out_root: &Path, vars: &[TemplateVar]) -> Result<(), Be if sub.path().components().any(|c| c.as_os_str() == ".gradle") { continue; } - render_dir(sub, out_root, vars)?; + render_dir_filtered(sub, out_root, vars, skip_file)?; } DirEntry::File(file) => { if file.path().components().any(|c| c.as_os_str() == ".gradle") { continue; } + if skip_file(file.path()) { + continue; + } // file.path() returns the full relative path from the embedded dir root let mut relative = file.path().to_path_buf(); let mut contents = file.contents().to_vec(); @@ -1219,6 +1437,22 @@ pub fn ensure_ios_project_with_options( crate_name: &str, project_root: Option<&Path>, crate_dir: Option<&Path>, +) -> Result<(), BenchError> { + ensure_ios_project_with_project_options( + output_dir, + crate_name, + project_root, + crate_dir, + IosProjectOptions::default(), + ) +} + +pub fn ensure_ios_project_with_project_options( + output_dir: &Path, + crate_name: &str, + project_root: Option<&Path>, + crate_dir: Option<&Path>, + options: IosProjectOptions, ) -> Result<(), BenchError> { let library_name = crate_name.replace('-', "_"); let project_exists = ios_project_exists(output_dir); @@ -1244,12 +1478,13 @@ pub fn ensure_ios_project_with_options( let effective_root = project_root.unwrap_or_else(|| output_dir.parent().unwrap_or(output_dir)); let default_function = resolve_default_function(effective_root, crate_name, crate_dir); - generate_ios_project( + generate_ios_project_with_options( output_dir, &library_name, project_pascal, &bundle_prefix, &default_function, + options, )?; println!(" Generated iOS project at {:?}", output_dir.join("ios")); println!(" Default benchmark function: {}", default_function); @@ -1497,6 +1732,13 @@ mod tests { assert!(android.contains("startForegroundService(intent)")); assert!(android.contains("startForeground(FOREGROUND_NOTIFICATION_ID")); assert!(android.contains("fun isBenchmarkComplete()")); + assert!(android.contains("BENCH_JSON ${json}")); + assert!(android.contains("BENCH_HEARTBEAT_JSON $json")); + assert!(android.contains("BENCH_FAILURE_JSON $encoded")); + assert!(android.contains("getHistoricalProcessExitReasons")); + assert!(android.contains("ApplicationExitInfo.REASON_LOW_MEMORY")); + assert!(android.contains("android_benchmark_timeout_secs")); + assert!(android.contains("android_heartbeat_interval_secs")); assert!(!android.contains("resultLatch.await")); assert!(android.contains("memory_process\", \"isolated_worker\"")); @@ -1504,9 +1746,13 @@ mod tests { "../templates/android/app/src/androidTest/java/MainActivityTest.kt.template" ); assert!(android_test.contains("Log.i(\"BenchRunnerTest\"")); - assert!(android_test.contains("Thread.sleep(heartbeatMs)")); + assert!(android_test.contains("Thread.sleep(pollMs)")); assert!(android_test.contains("TimeUnit.SECONDS.toMillis(10)")); assert!(android_test.contains("activity.isBenchmarkComplete()")); + assert!(android_test.contains("activity.isBenchmarkFailed()")); + assert!(android_test.contains("activity.emitTimeoutFailureFromTest()")); + assert!(android_test.contains("activity.checkWorkerExit()")); + assert!(android_test.contains("Benchmark failed before BENCH_JSON")); let ios_test = include_str!( "../templates/ios/BenchRunner/BenchRunnerUITests/BenchRunnerUITests.swift.template" @@ -1559,6 +1805,45 @@ mod tests { assert!(ios.contains("\"memory_process\": \"benchmark_app\"")); assert!(ios.contains("generateJSONReport(report, runProcessPeakMemoryKb:")); assert!(ios.contains("processPeakSamplesKb.max() ?? runProcessPeakMemoryKb")); + + let legacy = include_str!( + "../templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template" + ); + assert!(legacy.contains("import UIKit")); + assert!(!legacy.contains("import SwiftUI")); + assert!(!legacy.contains("Task.detached")); + assert!(!legacy.contains("Task.sleep")); + assert!(!legacy.contains("MainActor")); + assert!(legacy.contains("DispatchQueue.global(qos: .userInitiated)")); + assert!(legacy.contains("DispatchQueue.main.async")); + assert!(legacy.contains("textColor = .clear")); + assert!(!legacy.contains(".alpha = 0")); + assert!(legacy.contains("benchmarkReport")); + assert!(legacy.contains("benchmarkCompleted")); + assert!(legacy.contains("benchmarkReportJSON")); + assert!(legacy.contains("BENCH_REPORT_JSON_START")); + assert!(legacy.contains("BENCH_REPORT_JSON_END")); + } + + #[test] + fn test_ios_deployment_target_and_runner_selection() { + let ios15 = IosDeploymentTarget::parse("15.0").unwrap(); + let ios10 = IosDeploymentTarget::parse("10.0").unwrap(); + + assert_eq!(IosDeploymentTarget::parse("10").unwrap(), ios10); + assert_eq!( + resolve_ios_runner(&ios15, None).unwrap(), + IosRunner::Swiftui + ); + assert_eq!( + resolve_ios_runner(&ios10, None).unwrap(), + IosRunner::UikitLegacy + ); + assert!(resolve_ios_runner(&ios10, Some(IosRunner::Swiftui)).is_err()); + assert_eq!( + resolve_ios_runner(&ios15, Some(IosRunner::UikitLegacy)).unwrap(), + IosRunner::UikitLegacy + ); } #[test] @@ -1910,6 +2195,49 @@ pub fn public_bench() { fs::remove_dir_all(&temp_dir).ok(); } + #[test] + fn test_generate_ios_project_uses_configured_deployment_target() { + let temp_dir = env::temp_dir().join("mobench-sdk-ios-deployment-target-test"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + generate_ios_project_with_options( + &temp_dir, + "sample_fns", + "BenchRunner", + "dev.world.samplefns", + "sample_fns::example_benchmark", + IosProjectOptions { + deployment_target: IosDeploymentTarget::parse("10.0").unwrap(), + runner: IosRunner::UikitLegacy, + ios_benchmark_timeout_secs: 300, + }, + ) + .expect("generate legacy iOS project"); + + let project_yml = fs::read_to_string(temp_dir.join("ios/BenchRunner/project.yml")).unwrap(); + assert!(project_yml.contains("deploymentTarget: \"10.0\"")); + assert!(!project_yml.contains("deploymentTarget: \"15.0\"")); + + let runner = fs::read_to_string( + temp_dir.join("ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift"), + ) + .unwrap(); + assert!(runner.contains("import UIKit")); + assert!( + !temp_dir + .join("ios/BenchRunner/BenchRunner/ContentView.swift") + .exists() + ); + assert!( + !temp_dir + .join("ios/BenchRunner/BenchRunner/BenchRunnerApp.swift") + .exists() + ); + + fs::remove_dir_all(&temp_dir).ok(); + } + #[test] fn test_resolve_ios_benchmark_timeout_secs_defaults_invalid_values() { assert_eq!(resolve_ios_benchmark_timeout_secs(None), 300); diff --git a/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template b/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template index 24205e0..490f58d 100644 --- a/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/androidTest/java/MainActivityTest.kt.template @@ -25,28 +25,47 @@ class MainActivityTest { @Test fun showsBenchOutput() { - val deadline = System.currentTimeMillis() + benchmarkTimeoutMs + var timeoutMs = benchmarkTimeoutMs + var pollMs = heartbeatMs + activityRule.scenario.onActivity { activity -> + timeoutMs = TimeUnit.SECONDS.toMillis(activity.benchmarkTimeoutSecs()) + pollMs = TimeUnit.SECONDS.toMillis(activity.heartbeatIntervalSecs()).coerceAtLeast(1000L) + } + + val deadline = System.currentTimeMillis() + timeoutMs var completed = false + var failed = false + var failureJson: String? = null while (System.currentTimeMillis() < deadline) { activityRule.scenario.onActivity { activity -> + activity.checkWorkerExit() completed = activity.isBenchmarkComplete() + failed = activity.isBenchmarkFailed() + failureJson = activity.getBenchmarkFailureJson() } - if (completed) { + if (completed || failed) { break } Log.i("BenchRunnerTest", "Waiting for benchmark output...") try { - Thread.sleep(heartbeatMs) + Thread.sleep(pollMs) } catch (e: InterruptedException) { Thread.currentThread().interrupt() fail("Interrupted while waiting for benchmark output") } } - if (!completed) { - fail("Timed out waiting for benchmark output") + if (!completed && !failed) { + activityRule.scenario.onActivity { activity -> + failureJson = activity.emitTimeoutFailureFromTest() + failed = true + } + } + + if (failed) { + fail("Benchmark failed before BENCH_JSON: ${failureJson ?: "missing failure payload"}") } onView(withId(R.id.result_text)) diff --git a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template index e60d924..1cd444a 100644 --- a/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template +++ b/crates/mobench-sdk/templates/android/app/src/main/java/MainActivity.kt.template @@ -3,7 +3,11 @@ package {{PACKAGE_NAME}} import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.ActivityManager +import android.app.Application +import android.app.ApplicationExitInfo import android.app.Service +import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle @@ -11,6 +15,7 @@ import android.os.Debug import android.os.Handler import android.os.IBinder import android.os.Looper +import android.os.Process import android.os.ResultReceiver import android.widget.TextView import androidx.appcompat.app.AppCompatActivity @@ -24,28 +29,48 @@ import uniffi.{{UNIFFI_NAMESPACE}}.runBenchmark private const val DEFAULT_FUNCTION = "{{DEFAULT_FUNCTION}}" private const val DEFAULT_ITERATIONS = 20u private const val DEFAULT_WARMUP = 3u +private const val DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS = 1800L +private const val DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS = 10L private const val FUNCTION_EXTRA = "bench_function" private const val ITERATIONS_EXTRA = "bench_iterations" private const val WARMUP_EXTRA = "bench_warmup" +private const val TIMEOUT_EXTRA = "bench_timeout_secs" +private const val HEARTBEAT_EXTRA = "bench_heartbeat_secs" private const val SPEC_ASSET = "bench_spec.json" private const val RUN_BENCHMARK_ACTION = "{{PACKAGE_NAME}}.RUN_BENCHMARK" private const val RESULT_RECEIVER_EXTRA = "bench_result_receiver" private const val RESULT_DISPLAY_EXTRA = "bench_display" private const val RESULT_ERROR_EXTRA = "bench_error" +private const val RESULT_FAILURE_JSON_EXTRA = "bench_failure_json" +private const val RESULT_HEARTBEAT_JSON_EXTRA = "bench_heartbeat_json" +private const val RESULT_PID_EXTRA = "bench_pid" +private const val RESULT_PROCESS_NAME_EXTRA = "bench_process_name" private const val RESULT_OK = 1 private const val RESULT_ERROR = 2 +private const val RESULT_HEARTBEAT = 3 private const val FOREGROUND_NOTIFICATION_ID = 1001 private const val FOREGROUND_CHANNEL_ID = "mobench_benchmark" private const val FOREGROUND_CHANNEL_NAME = "Mobench benchmark" +private const val WORKER_PROCESS_SUFFIX = ":mobench_worker" +private const val FAILURE_SCHEMA_VERSION = 1 class MainActivity : AppCompatActivity() { @Volatile private var benchmarkComplete = false - - private data class BenchParams( + @Volatile private var benchmarkFailed = false + @Volatile private var failureJson: String? = null + @Volatile private var workerPid: Int? = null + @Volatile private var workerProcessName: String? = null + @Volatile private var lastProgressAtMs: Long? = null + @Volatile private var startedAtMs: Long = 0L + private lateinit var params: BenchParams + + data class BenchParams( val function: String, val iterations: UInt, val warmup: UInt, + val timeoutSecs: Long, + val heartbeatIntervalSecs: Long, ) override fun onCreate(savedInstanceState: Bundle?) { @@ -55,19 +80,38 @@ class MainActivity : AppCompatActivity() { val resultText = findViewById<TextView>(R.id.result_text) resultText?.text = "Running benchmark..." - val params = resolveBenchParams() + params = resolveBenchParams() + startedAtMs = android.os.SystemClock.elapsedRealtime() + lastProgressAtMs = startedAtMs val resultReceiver = object : ResultReceiver(Handler(Looper.getMainLooper())) { override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { - val display = resultData?.getString(RESULT_DISPLAY_EXTRA) - ?: resultData?.getString(RESULT_ERROR_EXTRA) - ?: "Benchmark worker returned no result" - resultText?.text = display - benchmarkComplete = true - - if (resultCode == RESULT_OK) { - android.util.Log.i("BenchRunner", "Benchmark worker completed") - } else { - android.util.Log.e("BenchRunner", display) + when (resultCode) { + RESULT_HEARTBEAT -> { + workerPid = resultData?.getInt(RESULT_PID_EXTRA)?.takeIf { it > 0 } + workerProcessName = resultData?.getString(RESULT_PROCESS_NAME_EXTRA) + lastProgressAtMs = android.os.SystemClock.elapsedRealtime() + } + RESULT_OK -> { + val display = resultData?.getString(RESULT_DISPLAY_EXTRA) + ?: "Benchmark worker completed" + resultText?.text = display + benchmarkComplete = true + android.util.Log.i("BenchRunner", "Benchmark worker completed") + } + else -> { + val payload = resultData?.getString(RESULT_FAILURE_JSON_EXTRA) + val display = resultData?.getString(RESULT_DISPLAY_EXTRA) + ?: resultData?.getString(RESULT_ERROR_EXTRA) + ?: "Benchmark worker returned no result" + resultText?.text = display + benchmarkFailed = true + benchmarkComplete = true + failureJson = payload + if (payload == null) { + emitFailure("unknown", display) + } + android.util.Log.e("BenchRunner", display) + } } } } @@ -78,6 +122,8 @@ class MainActivity : AppCompatActivity() { putExtra(FUNCTION_EXTRA, params.function) putExtra(ITERATIONS_EXTRA, params.iterations.toInt()) putExtra(WARMUP_EXTRA, params.warmup.toInt()) + putExtra(TIMEOUT_EXTRA, params.timeoutSecs) + putExtra(HEARTBEAT_EXTRA, params.heartbeatIntervalSecs) putExtra(RESULT_RECEIVER_EXTRA, resultReceiver) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -87,8 +133,9 @@ class MainActivity : AppCompatActivity() { } } catch (e: Exception) { android.util.Log.e("BenchRunner", "Failed to run benchmark worker", e) - resultText?.text = "Failed to run benchmark worker: ${e.message}" - benchmarkComplete = true + val message = "Failed to run benchmark worker: ${e.message}" + resultText?.text = message + emitFailure("exception", message) } } @@ -96,12 +143,79 @@ class MainActivity : AppCompatActivity() { return benchmarkComplete } + fun isBenchmarkFailed(): Boolean { + return benchmarkFailed + } + + fun getBenchmarkFailureJson(): String? { + return failureJson + } + + fun benchmarkTimeoutSecs(): Long { + return params.timeoutSecs + } + + fun heartbeatIntervalSecs(): Long { + return params.heartbeatIntervalSecs + } + + fun checkWorkerExit(): String? { + if (benchmarkComplete || workerPid == null) { + return failureJson + } + + val pid = workerPid ?: return failureJson + val processName = workerProcessName ?: workerProcessName() + val alive = runningAppProcesses().any { + it.pid == pid || it.processName == processName + } + val graceMs = params.heartbeatIntervalSecs.coerceAtLeast(1L) * 3_000L + val stale = android.os.SystemClock.elapsedRealtime() - (lastProgressAtMs ?: startedAtMs) > graceMs + if (!alive && stale) { + emitFailure("worker_exit", "Benchmark worker process exited before BENCH_JSON was emitted") + } + return failureJson + } + + fun emitTimeoutFailureFromTest(): String { + checkWorkerExit() + if (failureJson == null) { + emitFailure("timeout", "Timed out waiting ${params.timeoutSecs}s for benchmark completion") + } + return failureJson ?: "{}" + } + + private fun emitFailure(kind: String, message: String) { + if (failureJson != null) { + benchmarkFailed = true + benchmarkComplete = true + return + } + val payload = buildFailureJson( + this, + params.function, + kind, + message, + startedAtMs, + lastProgressAtMs, + workerPid, + workerProcessName ?: workerProcessName(), + ) + val encoded = payload.toString() + failureJson = encoded + benchmarkFailed = true + benchmarkComplete = true + android.util.Log.e("BenchRunner", "BENCH_FAILURE_JSON $encoded") + } + private fun resolveBenchParams(): BenchParams { val assetParams = loadBenchParamsFromAssets() val defaults = assetParams ?: BenchParams( DEFAULT_FUNCTION, DEFAULT_ITERATIONS, - DEFAULT_WARMUP + DEFAULT_WARMUP, + DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS, + DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS, ) // Check for intent extras used by local automation, smoke tests, and provider-driven runs. @@ -119,6 +233,9 @@ class MainActivity : AppCompatActivity() { val fn = intentFunction ?: defaults.function val iterations = intentIterations ?: defaults.iterations val warmup = intentWarmup ?: defaults.warmup + val timeoutSecs = intent?.getLongExtra(TIMEOUT_EXTRA, defaults.timeoutSecs) ?: defaults.timeoutSecs + val heartbeatSecs = intent?.getLongExtra(HEARTBEAT_EXTRA, defaults.heartbeatIntervalSecs) + ?: defaults.heartbeatIntervalSecs // Log the resolution source for debugging if (assetParams == null && intentFunction == null && intentIterations == null && intentWarmup == null) { @@ -136,7 +253,7 @@ class MainActivity : AppCompatActivity() { android.util.Log.i("BenchRunner", "Resolved params: function=$fn, iterations=$iterations, warmup=$warmup (sources: ${sources.joinToString(", ")})") } - return BenchParams(fn, iterations, warmup) + return BenchParams(fn, iterations, warmup, timeoutSecs, heartbeatSecs) } private fun loadBenchParamsFromAssets(): BenchParams? { @@ -179,9 +296,13 @@ class MainActivity : AppCompatActivity() { android.util.Log.w("BenchRunner", "Config missing 'warmup' key, using default: $DEFAULT_WARMUP") DEFAULT_WARMUP } + val timeoutSecs = json.optLong("android_benchmark_timeout_secs", DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS) + .takeIf { it > 0L } ?: DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS + val heartbeatSecs = json.optLong("android_heartbeat_interval_secs", DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS) + .takeIf { it > 0L } ?: DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS - android.util.Log.i("BenchRunner", "Loaded config from bench_spec.json: function=$function, iterations=$iterations, warmup=$warmup") - BenchParams(function, iterations, warmup) + android.util.Log.i("BenchRunner", "Loaded config from bench_spec.json: function=$function, iterations=$iterations, warmup=$warmup, timeout=${timeoutSecs}s, heartbeat=${heartbeatSecs}s") + BenchParams(function, iterations, warmup, timeoutSecs, heartbeatSecs) } } catch (e: java.io.FileNotFoundException) { android.util.Log.d("BenchRunner", "No bench_spec.json in assets, will use intent extras or defaults") @@ -195,10 +316,12 @@ class MainActivity : AppCompatActivity() { class BenchmarkWorkerService : Service() { - private data class BenchParams( + data class BenchParams( val function: String, val iterations: UInt, val warmup: UInt, + val timeoutSecs: Long, + val heartbeatIntervalSecs: Long, ) override fun onBind(intent: Intent?): IBinder? = null @@ -214,19 +337,26 @@ class BenchmarkWorkerService : Service() { function = intent.getStringExtra(FUNCTION_EXTRA)?.takeUnless { it.isBlank() } ?: DEFAULT_FUNCTION, iterations = intent.getIntExtra(ITERATIONS_EXTRA, DEFAULT_ITERATIONS.toInt()).toUInt(), warmup = intent.getIntExtra(WARMUP_EXTRA, DEFAULT_WARMUP.toInt()).toUInt(), + timeoutSecs = intent.getLongExtra(TIMEOUT_EXTRA, DEFAULT_ANDROID_BENCHMARK_TIMEOUT_SECS), + heartbeatIntervalSecs = intent.getLongExtra(HEARTBEAT_EXTRA, DEFAULT_ANDROID_HEARTBEAT_INTERVAL_SECS), ) startBenchmarkForeground() Thread { + val startMs = android.os.SystemClock.elapsedRealtime() + val heartbeat = WorkerHeartbeat(resultReceiver, params, startMs) + heartbeat.start() try { val result = runBenchmarkInWorker(params) val bundle = Bundle().apply { putString(RESULT_DISPLAY_EXTRA, result.displayText) result.errorMessage?.let { putString(RESULT_ERROR_EXTRA, it) } + result.failureJson?.let { putString(RESULT_FAILURE_JSON_EXTRA, it) } } resultReceiver?.send(if (result.errorMessage == null) RESULT_OK else RESULT_ERROR, bundle) } finally { + heartbeat.stop() stopBenchmarkForeground() stopSelf(startId) } @@ -284,6 +414,7 @@ class BenchmarkWorkerService : Service() { private fun runBenchmarkInWorker(params: BenchParams): WorkerBenchmarkResult { BenchNativeLibrary.ensureLoaded() + val startMs = android.os.SystemClock.elapsedRealtime() val display = try { val spec = BenchSpec( name = params.function, @@ -307,27 +438,102 @@ class BenchmarkWorkerService : Service() { formatBenchReport(report) } catch (e: BenchException) { android.util.Log.e("BenchRunner", "Benchmark error: ${e.message}", e) + val payload = buildFailureJson( + this, + params.function, + "exception", + "Benchmark error: ${e.message}", + startMs, + android.os.SystemClock.elapsedRealtime(), + Process.myPid(), + currentProcessName() + ).toString() + android.util.Log.e("BenchRunner", "BENCH_FAILURE_JSON $payload") return WorkerBenchmarkResult( displayText = "Benchmark error: ${e.message}", - errorMessage = "Benchmark error: ${e.message}" + errorMessage = "Benchmark error: ${e.message}", + failureJson = payload ) } catch (e: Exception) { android.util.Log.e("BenchRunner", "Unexpected error during benchmark execution", e) + val payload = buildFailureJson( + this, + params.function, + "exception", + "Unexpected error: ${e.message}", + startMs, + android.os.SystemClock.elapsedRealtime(), + Process.myPid(), + currentProcessName() + ).toString() + android.util.Log.e("BenchRunner", "BENCH_FAILURE_JSON $payload") return WorkerBenchmarkResult( displayText = "Unexpected error: ${e.message}", - errorMessage = "Unexpected error: ${e.message}" + errorMessage = "Unexpected error: ${e.message}", + failureJson = payload ) } - return WorkerBenchmarkResult(displayText = display, errorMessage = null) + return WorkerBenchmarkResult(displayText = display, errorMessage = null, failureJson = null) } } private data class WorkerBenchmarkResult( val displayText: String, val errorMessage: String?, + val failureJson: String?, ) +private class WorkerHeartbeat( + private val receiver: ResultReceiver?, + private val params: BenchmarkWorkerService.BenchParams, + private val startMs: Long, +) { + @Volatile private var running = false + private var thread: Thread? = null + + fun start() { + if (running) { + return + } + running = true + thread = Thread { + while (running) { + val now = android.os.SystemClock.elapsedRealtime() + val json = JSONObject() + .put("schema_version", FAILURE_SCHEMA_VERSION) + .put("platform", "android") + .put("function_name", params.function) + .put("elapsed_ms", now - startMs) + .put("pid", Process.myPid()) + .put("process_name", currentProcessName()) + .put("memory", currentMemoryJson()) + android.util.Log.i("BenchRunner", "BENCH_HEARTBEAT_JSON $json") + receiver?.send(RESULT_HEARTBEAT, Bundle().apply { + putString(RESULT_HEARTBEAT_JSON_EXTRA, json.toString()) + putInt(RESULT_PID_EXTRA, Process.myPid()) + putString(RESULT_PROCESS_NAME_EXTRA, currentProcessName()) + }) + try { + Thread.sleep(params.heartbeatIntervalSecs.coerceAtLeast(1L) * 1000L) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + break + } + } + }.apply { + name = "mobench-worker-heartbeat" + isDaemon = true + start() + } + } + + fun stop() { + running = false + thread?.interrupt() + } +} + private object BenchNativeLibrary { init { System.loadLibrary("{{LIBRARY_NAME}}") @@ -336,6 +542,121 @@ private object BenchNativeLibrary { fun ensureLoaded() = Unit } +private fun buildFailureJson( + context: Context, + functionName: String, + kind: String, + message: String, + startedAtMs: Long, + lastProgressAtMs: Long?, + pid: Int?, + processName: String?, +): JSONObject { + val elapsedNow = android.os.SystemClock.elapsedRealtime() + val json = JSONObject() + json.put("schema_version", FAILURE_SCHEMA_VERSION) + json.put("platform", "android") + json.put("device", "${Build.MANUFACTURER} ${Build.MODEL}".trim()) + json.put("function_name", functionName) + json.put("kind", kind) + json.put("message", message) + json.put("elapsed_ms", elapsedNow - startedAtMs) + if (pid != null) { + json.put("pid", pid) + } else { + json.put("pid", JSONObject.NULL) + } + json.put("process_name", processName ?: JSONObject.NULL) + json.put("last_progress_at_ms", lastProgressAtMs ?: JSONObject.NULL) + json.put("memory", currentMemoryJson()) + json.put("android_exit_info", androidExitInfoJson(context, pid, processName) ?: JSONObject.NULL) + return json +} + +private fun androidExitInfoJson(context: Context, pid: Int?, processName: String?): JSONObject? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return null + } + return try { + val manager = context.getSystemService(ActivityManager::class.java) ?: return null + val reasons = manager.getHistoricalProcessExitReasons(context.packageName, pid ?: 0, 10) + val match = reasons.firstOrNull { + (pid != null && it.pid == pid) || (processName != null && it.processName == processName) + } ?: reasons.firstOrNull() + match?.let { info -> + JSONObject() + .put("reason", exitReasonName(info.reason)) + .put("raw_reason", info.reason) + .put("description", info.description ?: JSONObject.NULL) + .put("importance", info.importance) + .put("timestamp", info.timestamp) + .put("pid", info.pid) + .put("process_name", info.processName ?: JSONObject.NULL) + } + } catch (e: Exception) { + android.util.Log.d("BenchRunner", "Unable to read historical process exit reasons", e) + null + } +} + +private fun exitReasonName(reason: Int): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return "unknown" + } + return when (reason) { + ApplicationExitInfo.REASON_ANR -> "anr" + ApplicationExitInfo.REASON_CRASH -> "crash" + ApplicationExitInfo.REASON_CRASH_NATIVE -> "crash_native" + ApplicationExitInfo.REASON_DEPENDENCY_DIED -> "dependency_died" + ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE -> "excessive_resource_usage" + ApplicationExitInfo.REASON_EXIT_SELF -> "exit_self" + ApplicationExitInfo.REASON_INITIALIZATION_FAILURE -> "initialization_failure" + ApplicationExitInfo.REASON_LOW_MEMORY -> "low_memory" + ApplicationExitInfo.REASON_OTHER -> "other" + ApplicationExitInfo.REASON_PERMISSION_CHANGE -> "permission_change" + ApplicationExitInfo.REASON_SIGNALED -> "signaled" + ApplicationExitInfo.REASON_USER_REQUESTED -> "user_requested" + else -> "unknown" + } +} + +private fun currentMemoryJson(): JSONObject { + val memInfo = Debug.MemoryInfo() + return try { + Debug.getMemoryInfo(memInfo) + JSONObject() + .put("total_pss_kb", memInfo.totalPss) + .put("private_dirty_kb", memInfo.totalPrivateDirty) + .put("native_heap_kb", Debug.getNativeHeapAllocatedSize() / 1024) + .put("java_heap_kb", (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / 1024) + .put("process_pss_kb", currentProcessPssKb() ?: JSONObject.NULL) + } catch (e: Exception) { + JSONObject() + } +} + +private fun Context.runningAppProcesses(): List<ActivityManager.RunningAppProcessInfo> { + return try { + val manager = getSystemService(ActivityManager::class.java) + manager?.runningAppProcesses ?: emptyList() + } catch (e: Exception) { + emptyList() + } +} + +private fun Context.workerProcessName(): String = "$packageName$WORKER_PROCESS_SUFFIX" + +private fun currentProcessName(): String { + if (Build.VERSION.SDK_INT >= 28) { + return Application.getProcessName() + } + return try { + java.io.File("/proc/self/cmdline").readText().trim('\u0000', ' ', '\n') + } catch (e: Exception) { + "unknown" + } +} + @Suppress("DEPRECATION") private fun Intent.resultReceiverExtra(): ResultReceiver? { return if (Build.VERSION.SDK_INT >= 33) { diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template new file mode 100644 index 0000000..7797c53 --- /dev/null +++ b/crates/mobench-sdk/templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template @@ -0,0 +1,163 @@ +import UIKit + +private struct ProfileLaunchOptions { + let benchDelayMs: UInt64 + let resultHoldMs: UInt64 + let repeatUntilMs: UInt64 + let warmupOnly: Bool + + static func resolved() -> ProfileLaunchOptions { + let info = ProcessInfo.processInfo + + var benchDelayMs = UInt64(info.environment["MOBENCH_BENCH_DELAY_MS"] ?? "0") ?? 0 + var resultHoldMs = UInt64( + info.environment["MOBENCH_PROFILE_RESULT_HOLD_MS"] ?? "5000" + ) ?? 5000 + var repeatUntilMs = UInt64( + info.environment["MOBENCH_PROFILE_REPEAT_UNTIL_MS"] ?? "0" + ) ?? 0 + var warmupOnly = info.environment["MOBENCH_PROFILE_WARMUP_ONLY"] == "1" + + for arg in info.arguments { + if arg.hasPrefix("--mobench-profile-bench-delay-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + benchDelayMs = parsed + } else if arg.hasPrefix("--mobench-profile-result-hold-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + resultHoldMs = parsed + } else if arg.hasPrefix("--mobench-profile-repeat-until-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + repeatUntilMs = parsed + } else if arg == "--mobench-profile-warmup-only" + || arg == "--mobench-profile-warmup-only=1" { + warmupOnly = true + } + } + + NSLog( + "[BenchRunner] Profile launch options: delayMs=%llu, repeatUntilMs=%llu, resultHoldMs=%llu, warmupOnly=%@", + benchDelayMs, + repeatUntilMs, + resultHoldMs, + warmupOnly ? "true" : "false" + ) + + return ProfileLaunchOptions( + benchDelayMs: benchDelayMs, + resultHoldMs: resultHoldMs, + repeatUntilMs: repeatUntilMs, + warmupOnly: warmupOnly + ) + } +} + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = BenchmarkViewController() + window.makeKeyAndVisible() + self.window = window + return true + } +} + +final class BenchmarkViewController: UIViewController { + private let reportView = UITextView() + private let completionLabel = UILabel() + private let jsonLabel = UILabel() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + + reportView.translatesAutoresizingMaskIntoConstraints = false + reportView.isEditable = false + reportView.font = UIFont(name: "Menlo", size: 14) ?? UIFont.systemFont(ofSize: 14) + reportView.text = "Running benchmarks..." + reportView.accessibilityIdentifier = "benchmarkReport" + view.addSubview(reportView) + + completionLabel.translatesAutoresizingMaskIntoConstraints = false + completionLabel.text = "" + completionLabel.accessibilityIdentifier = "benchmarkCompleted" + completionLabel.isAccessibilityElement = false + completionLabel.textColor = .clear + view.addSubview(completionLabel) + + jsonLabel.translatesAutoresizingMaskIntoConstraints = false + jsonLabel.text = "" + jsonLabel.accessibilityIdentifier = "benchmarkReportJSON" + jsonLabel.isAccessibilityElement = true + jsonLabel.textColor = .clear + view.addSubview(jsonLabel) + + NSLayoutConstraint.activate([ + reportView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + reportView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + reportView.topAnchor.constraint(equalTo: view.topAnchor, constant: 16), + reportView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16), + completionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor), + completionLabel.topAnchor.constraint(equalTo: view.topAnchor), + completionLabel.widthAnchor.constraint(equalToConstant: 1), + completionLabel.heightAnchor.constraint(equalToConstant: 1), + jsonLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor), + jsonLabel.topAnchor.constraint(equalTo: completionLabel.bottomAnchor), + jsonLabel.widthAnchor.constraint(equalToConstant: 1), + jsonLabel.heightAnchor.constraint(equalToConstant: 1), + ]) + + runBenchmark() + } + + private func runBenchmark() { + DispatchQueue.global(qos: .userInitiated).async { + let options = ProfileLaunchOptions.resolved() + if options.benchDelayMs > 0 { + Thread.sleep(forTimeInterval: Double(options.benchDelayMs) / 1_000.0) + } + + let repeatDeadline = Date().addingTimeInterval( + Double(options.repeatUntilMs) / 1_000.0 + ) + var repeatedRuns = 1 + var result = {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + while !options.warmupOnly && options.repeatUntilMs > 0 && Date() < repeatDeadline { + result = {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + repeatedRuns += 1 + } + + DispatchQueue.main.async { + self.reportView.text = result.displayText + self.jsonLabel.text = result.jsonReport + self.jsonLabel.accessibilityLabel = result.jsonReport + self.completionLabel.text = "completed" + self.completionLabel.isAccessibilityElement = true + } + + NSLog("BENCH_REPORT_JSON_START") + NSLog("%@", result.jsonReport) + NSLog("BENCH_REPORT_JSON_END") + if repeatedRuns > 1 { + NSLog("Repeated benchmark %d time(s) during profile capture", repeatedRuns) + } + + if options.warmupOnly { + NSLog("Warmup-only profile run complete") + return + } + + NSLog("Displaying results for \(options.resultHoldMs) ms for capture output...") + Thread.sleep(forTimeInterval: Double(options.resultHoldMs) / 1_000.0) + NSLog("Display hold complete") + } + } +} diff --git a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template index 7ccad1a..cdf35c5 100644 --- a/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template +++ b/crates/mobench-sdk/templates/ios/BenchRunner/project.yml.template @@ -8,7 +8,7 @@ targets: {{PROJECT_NAME_PASCAL}}: type: application platform: iOS - deploymentTarget: "15.0" + deploymentTarget: "{{IOS_DEPLOYMENT_TARGET}}" sources: - path: {{PROJECT_NAME_PASCAL}} resources: @@ -33,7 +33,7 @@ targets: {{PROJECT_NAME_PASCAL}}UITests: type: bundle.ui-testing platform: iOS - deploymentTarget: "15.0" + deploymentTarget: "{{IOS_DEPLOYMENT_TARGET}}" sources: - path: {{PROJECT_NAME_PASCAL}}UITests info: diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index c0dacc9..949b723 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -422,12 +422,30 @@ impl BrowserStackClient { /// * `platform` - "espresso" or "xcuitest" /// * `timeout_secs` - Maximum time to wait in seconds (default: 600) /// * `poll_interval_secs` - How often to check status in seconds (default: 10) + #[allow(dead_code)] pub fn poll_build_completion( &self, build_id: &str, platform: &str, timeout_secs: u64, poll_interval_secs: u64, + ) -> Result<BuildStatus> { + self.poll_build_completion_with_terminal_failures( + build_id, + platform, + timeout_secs, + poll_interval_secs, + false, + ) + } + + fn poll_build_completion_with_terminal_failures( + &self, + build_id: &str, + platform: &str, + timeout_secs: u64, + poll_interval_secs: u64, + allow_terminal_failure_status: bool, ) -> Result<BuildStatus> { use std::time::{Duration, Instant}; @@ -445,6 +463,9 @@ impl BrowserStackClient { match status.status.to_lowercase().as_str() { "done" | "passed" | "completed" => return Ok(status), "failed" | "error" | "timeout" => { + if allow_terminal_failure_status { + return Ok(status); + } return Err(anyhow!( "Build {} failed with status: {}", build_id, @@ -596,6 +617,26 @@ impl BrowserStackClient { } } + /// Extract structured Android benchmark failures from logs. + pub fn extract_benchmark_failures(&self, logs: &str) -> Result<Vec<Value>> { + let mut failures = Vec::new(); + let failure_marker = "BENCH_FAILURE_JSON "; + for line in logs.lines() { + if let Some(idx) = line.find(failure_marker) { + let json_part = &line[idx + failure_marker.len()..]; + if let Ok(json) = serde_json::from_str::<Value>(json_part) { + Self::extend_unique_results(&mut failures, vec![json]); + } + } + } + + if failures.is_empty() { + Err(anyhow!("No benchmark failures found in device logs")) + } else { + Ok(failures) + } + } + pub(crate) fn extract_benchmark_results_from_artifact( &self, contents: &str, @@ -656,6 +697,36 @@ impl BrowserStackClient { } } + pub(crate) fn extract_failures_from_session_artifacts<F>( + &self, + session_json: &Value, + mut fetch_text: F, + ) -> Result<Vec<Value>> + where + F: FnMut(&str) -> Result<String>, + { + let artifact_urls = Self::collect_text_artifact_urls(session_json); + if artifact_urls.is_empty() { + return Err(anyhow!("No text artifact URLs found in session response")); + } + + let mut failures = Vec::new(); + for (_, url) in artifact_urls { + let Ok(contents) = fetch_text(&url) else { + continue; + }; + if let Ok(mut artifact_failures) = self.extract_benchmark_failures(&contents) { + failures.append(&mut artifact_failures); + } + } + + if failures.is_empty() { + Err(anyhow!("No benchmark failures found in session artifacts")) + } else { + Ok(failures) + } + } + /// Extract benchmark JSON from iOS logs using START/END markers. /// iOS uses NSLog which may split the JSON across multiple log lines. fn extract_ios_bench_json(logs: &str) -> Option<Value> { @@ -825,8 +896,13 @@ impl BrowserStackClient { "Waiting for build {} to complete (timeout: {}s, poll: {}s)...", build_id, timeout, poll_interval ); - let build_status = - self.poll_build_completion(build_id, platform, timeout, poll_interval)?; + let build_status = self.poll_build_completion_with_terminal_failures( + build_id, + platform, + timeout, + poll_interval, + true, + )?; println!("Build completed with status: {}", build_status.status); println!( @@ -836,6 +912,7 @@ impl BrowserStackClient { let mut benchmark_results = std::collections::HashMap::new(); let mut performance_metrics = std::collections::HashMap::new(); + let mut benchmark_failures = std::collections::HashMap::new(); for device in &build_status.devices { println!( @@ -845,6 +922,7 @@ impl BrowserStackClient { let mut device_benchmark_results: Option<Vec<Value>> = None; let mut device_performance_metrics = PerformanceMetrics::default(); + let mut device_failures: Option<Vec<Value>> = None; match self.get_device_logs(build_id, &device.session_id, platform) { Ok(logs) => { @@ -859,6 +937,16 @@ impl BrowserStackClient { } } + match self.extract_benchmark_failures(&logs) { + Ok(failures) => { + println!(" Found {} benchmark failure marker(s)", failures.len()); + device_failures = Some(failures); + } + Err(e) => { + println!(" No benchmark failures in live logs: {}", e); + } + } + // Extract performance metrics match self.extract_performance_metrics(&logs) { Ok(perf_metrics) if perf_metrics.sample_count > 0 => { @@ -882,33 +970,49 @@ impl BrowserStackClient { } if device_benchmark_results.is_none() { - match self - .get_session_json(build_id, &device.session_id, platform) - .and_then(|session_json| { - self.extract_results_from_session_artifacts(&session_json, |url| { + match self.get_session_json(build_id, &device.session_id, platform) { + Ok(session_json) => { + match self.extract_results_from_session_artifacts(&session_json, |url| { self.download_text_url(url) - }) - }) { - Ok((results, perf_metrics)) => { - println!( - " Found {} benchmark result(s) from session artifacts", - results.len() - ); - if device_performance_metrics.sample_count == 0 - && perf_metrics.sample_count > 0 + }) { + Ok((results, perf_metrics)) => { + println!( + " Found {} benchmark result(s) from session artifacts", + results.len() + ); + if device_performance_metrics.sample_count == 0 + && perf_metrics.sample_count > 0 + { + println!( + " Found {} performance metric snapshot(s) from session artifacts", + perf_metrics.sample_count + ); + device_performance_metrics = perf_metrics; + } + device_benchmark_results = Some(results); + } + Err(e) => { + println!( + " Warning: Failed to fetch results from session artifacts: {e}" + ); + } + } + + if device_failures.is_none() + && let Ok(failures) = self + .extract_failures_from_session_artifacts(&session_json, |url| { + self.download_text_url(url) + }) { println!( - " Found {} performance metric snapshot(s) from session artifacts", - perf_metrics.sample_count + " Found {} benchmark failure marker(s) from session artifacts", + failures.len() ); - device_performance_metrics = perf_metrics; + device_failures = Some(failures); } - device_benchmark_results = Some(results); } Err(e) => { - println!( - " Warning: Failed to fetch results from session artifacts: {e}" - ); + println!(" Warning: Failed to fetch session artifacts metadata: {e}"); } } } @@ -927,12 +1031,34 @@ impl BrowserStackClient { if let Some(results) = device_benchmark_results { benchmark_results.insert(device.device.clone(), results); } + if let Some(failures) = device_failures { + benchmark_failures.insert(device.device.clone(), failures); + } if device_performance_metrics.sample_count > 0 { performance_metrics.insert(device.device.clone(), device_performance_metrics); } } if benchmark_results.is_empty() { + if let Some((device, failures)) = benchmark_failures.iter().next() + && let Some(failure) = failures.first() + { + let function = failure + .get("function_name") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let kind = failure + .get("kind") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let message = failure + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("no message"); + return Err(anyhow!( + "Android benchmark failure on {device} for {function}: {kind}: {message}" + )); + } Err(anyhow!("No benchmark results found from any device")) } else { Ok((benchmark_results, performance_metrics)) @@ -1998,6 +2124,45 @@ mod tests { (format!("http://{addr}"), paths, handle) } + fn spawn_browserstack_path_json_server( + expected_path: &'static str, + payload: Value, + ) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let addr = listener.local_addr().expect("read test server address"); + let body = payload.to_string(); + + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut buf = [0_u8; 4096]; + let bytes_read = stream.read(&mut buf).expect("read request"); + let request = String::from_utf8_lossy(&buf[..bytes_read]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/"); + let (status, response_body) = if path == expected_path { + ("200 OK", body) + } else { + ( + "404 Not Found", + format!("{{\"error\":\"unexpected path: {path}\"}}"), + ) + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + }); + + (format!("http://{addr}"), handle) + } + #[test] fn rejects_missing_artifact() { let client = BrowserStackClient::new( @@ -2358,6 +2523,57 @@ Test completed assert_eq!(status.devices.len(), 0); } + #[test] + fn public_poll_build_completion_errors_on_terminal_failure_status() { + let payload = json!({ + "build_id": "build123", + "status": "failed", + "devices": [{ + "device": "Google Pixel 8-14.0", + "sessionId": "session123", + "status": "failed" + }] + }); + let (base_url, handle) = spawn_browserstack_path_json_server( + "/app-automate/espresso/v2/builds/build123", + payload, + ); + let client = test_client_with_base_url(base_url); + + let error = client + .poll_build_completion("build123", "espresso", 1, 1) + .expect_err("public poll should preserve failure-status errors"); + handle.join().expect("join test server"); + + assert!(error.to_string().contains("failed with status: failed")); + } + + #[test] + fn internal_poll_can_return_terminal_failure_status_for_log_fetching() { + let payload = json!({ + "build_id": "build123", + "status": "failed", + "devices": [{ + "device": "Google Pixel 8-14.0", + "sessionId": "session123", + "status": "failed" + }] + }); + let (base_url, handle) = spawn_browserstack_path_json_server( + "/app-automate/espresso/v2/builds/build123", + payload, + ); + let client = test_client_with_base_url(base_url); + + let status = client + .poll_build_completion_with_terminal_failures("build123", "espresso", 1, 1, true) + .expect("internal poll should allow log-fetching from failed builds"); + handle.join().expect("join test server"); + + assert_eq!(status.status, "failed"); + assert_eq!(status.devices[0].session_id, "session123"); + } + #[test] fn device_session_deserializes_from_json() { let json = r#"{ @@ -2710,6 +2926,30 @@ BENCH_REPORT_JSON_END ); } + #[test] + fn extract_benchmark_results_handles_legacy_uikit_ios_logs() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +2026-05-01 10:00:00.000 BenchRunner[42:7] BENCH_REPORT_JSON_START +2026-05-01 10:00:00.001 BenchRunner[42:7] {"function":"legacy::bench","samples":[{"duration_ns":100},{"duration_ns":200}],"samples_ns":[100,200],"mean_ns":150,"resources":{"platform":"ios","memory_process":"benchmark_app"}} +2026-05-01 10:00:00.002 BenchRunner[42:7] BENCH_REPORT_JSON_END + "#; + + let results = client.extract_benchmark_results(logs).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["function"], "legacy::bench"); + assert_eq!(results[0]["mean_ns"], 150); + assert_eq!(results[0]["samples"].as_array().unwrap().len(), 2); + } + #[test] fn extract_benchmark_results_handles_android_bench_json_marker() { let client = BrowserStackClient::new( @@ -2735,6 +2975,97 @@ BENCH_REPORT_JSON_END .any(|r| r.get("function").and_then(|f| f.as_str()) == Some("sample_fns::checksum"))); } + #[test] + fn extract_benchmark_failures_handles_failure_only_logs() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +2026-01-20 12:34:57 E/BenchRunner: BENCH_FAILURE_JSON {"schema_version":1,"platform":"android","function_name":"sample_fns::sleep","kind":"timeout","message":"Timed out","elapsed_ms":30000,"android_exit_info":null} + "#; + + let failures = client.extract_benchmark_failures(logs).unwrap(); + assert_eq!(failures.len(), 1); + assert_eq!( + failures[0].get("kind").and_then(|kind| kind.as_str()), + Some("timeout") + ); + assert!(client.extract_benchmark_results(logs).is_err()); + } + + #[test] + fn extract_benchmark_failures_ignores_heartbeat_and_reads_failure() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let logs = r#" +I/BenchRunner: BENCH_HEARTBEAT_JSON {"schema_version":1,"platform":"android","function_name":"sample_fns::sleep","elapsed_ms":10000} +E/BenchRunner: BENCH_FAILURE_JSON {"schema_version":1,"platform":"android","function_name":"sample_fns::sleep","kind":"worker_exit","message":"worker exited","elapsed_ms":12000,"android_exit_info":{"reason":"low_memory","raw_reason":3}} + "#; + + let failures = client.extract_benchmark_failures(logs).unwrap(); + assert_eq!(failures.len(), 1); + assert_eq!( + failures[0] + .get("android_exit_info") + .and_then(|info| info.get("reason")) + .and_then(|reason| reason.as_str()), + Some("low_memory") + ); + } + + #[test] + fn extract_failures_from_session_artifacts_reads_failure_marker() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + let session_json = serde_json::json!({ + "automation_session": { + "device_log_url": "https://example.invalid/device.log" + } + }); + let logs = r#" +I/BenchRunner: BENCH_HEARTBEAT_JSON {"schema_version":1,"platform":"android","function_name":"sample_fns::sleep","elapsed_ms":10000} +E/BenchRunner: BENCH_FAILURE_JSON {"schema_version":1,"platform":"android","device":"Vivo Y21-11.0","function_name":"sample_fns::sleep","kind":"timeout","message":"Timed out","elapsed_ms":30000,"android_exit_info":null} + "#; + + let failures = client + .extract_failures_from_session_artifacts(&session_json, |url| { + assert_eq!(url, "https://example.invalid/device.log"); + Ok(logs.to_string()) + }) + .unwrap(); + + assert_eq!(failures.len(), 1); + assert_eq!( + failures[0] + .get("function_name") + .and_then(|value| value.as_str()), + Some("sample_fns::sleep") + ); + assert_eq!( + failures[0].get("kind").and_then(|value| value.as_str()), + Some("timeout") + ); + } + #[test] fn extract_ios_bench_json_finds_last_occurrence() { // Test that we find the last occurrence of markers (in case of multiple runs) diff --git a/crates/mobench/src/cli.rs b/crates/mobench/src/cli.rs index 129f4fb..8c419df 100644 --- a/crates/mobench/src/cli.rs +++ b/crates/mobench/src/cli.rs @@ -111,6 +111,27 @@ pub(crate) enum Command { help = "Deprecated compatibility flag for generated XCUITest harness timeout" )] ios_completion_timeout_secs: Option<u64>, + #[arg( + long, + help = "iOS deployment target for generated app and XCUITest targets" + )] + ios_deployment_target: Option<String>, + #[arg( + long, + value_enum, + help = "iOS runner template (swiftui or uikit-legacy)" + )] + ios_runner: Option<IosRunnerArg>, + #[arg( + long, + help = "Android benchmark watchdog timeout in seconds for the generated harness" + )] + android_benchmark_timeout_secs: Option<u64>, + #[arg( + long, + help = "Android benchmark heartbeat interval in seconds for the generated harness" + )] + android_heartbeat_interval_secs: Option<u64>, #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] fetch: bool, #[arg(long, default_value = "target/browserstack")] @@ -217,6 +238,17 @@ pub(crate) enum Command { help = "Deprecated compatibility flag for generated XCUITest harness timeout" )] ios_completion_timeout_secs: Option<u64>, + #[arg( + long, + help = "iOS deployment target for generated app and XCUITest targets" + )] + ios_deployment_target: Option<String>, + #[arg( + long, + value_enum, + help = "iOS runner template (swiftui or uikit-legacy)" + )] + ios_runner: Option<IosRunnerArg>, #[arg( long, help = "Project root containing mobench.toml or the Cargo workspace" @@ -612,6 +644,27 @@ pub(crate) struct CiRunArgs { help = "Deprecated compatibility flag for generated XCUITest harness timeout" )] pub(crate) ios_completion_timeout_secs: Option<u64>, + #[arg( + long, + help = "iOS deployment target for generated app and XCUITest targets" + )] + pub(crate) ios_deployment_target: Option<String>, + #[arg( + long, + value_enum, + help = "iOS runner template (swiftui or uikit-legacy)" + )] + pub(crate) ios_runner: Option<IosRunnerArg>, + #[arg( + long, + help = "Android benchmark watchdog timeout in seconds for the generated harness" + )] + pub(crate) android_benchmark_timeout_secs: Option<u64>, + #[arg( + long, + help = "Android benchmark heartbeat interval in seconds for the generated harness" + )] + pub(crate) android_heartbeat_interval_secs: Option<u64>, #[arg(long, help = "Fetch BrowserStack artifacts after the run completes")] pub(crate) fetch: bool, #[arg(long, default_value = "target/browserstack")] @@ -821,3 +874,20 @@ impl From<IosSigningMethodArg> for mobench_sdk::builders::SigningMethod { } } } + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum, Serialize, Deserialize)] +#[clap(rename_all = "kebab-case")] +#[serde(rename_all = "kebab-case")] +pub enum IosRunnerArg { + Swiftui, + UikitLegacy, +} + +impl From<IosRunnerArg> for mobench_sdk::codegen::IosRunner { + fn from(arg: IosRunnerArg) -> Self { + match arg { + IosRunnerArg::Swiftui => mobench_sdk::codegen::IosRunner::Swiftui, + IosRunnerArg::UikitLegacy => mobench_sdk::codegen::IosRunner::UikitLegacy, + } + } +} diff --git a/crates/mobench/src/config.rs b/crates/mobench/src/config.rs index 910deda..7cb3bc4 100644 --- a/crates/mobench/src/config.rs +++ b/crates/mobench/src/config.rs @@ -24,6 +24,7 @@ //! [ios] //! bundle_id = "com.example.bench" //! deployment_target = "15.0" +//! runner = "swiftui" //! //! [benchmarks] //! default_function = "my_crate::my_benchmark" @@ -380,6 +381,10 @@ bundle_id = "{package}" # iOS deployment target version (default: 15.0) deployment_target = "15.0" +# iOS app runner: swiftui (iOS 15+) or uikit-legacy (legacy targets). +# If omitted, mobench chooses uikit-legacy for deployment targets below 15.0. +# runner = "swiftui" + # Development team ID for code signing (optional, uses ad-hoc signing if not set) # team_id = "YOUR_TEAM_ID" @@ -396,6 +401,12 @@ default_warmup = 10 [browserstack] # Timeout in seconds for the generated iOS XCUITest harness to wait for completion # ios_completion_timeout_secs = 1200 + +# Timeout in seconds for the generated Android benchmark watchdog +# android_benchmark_timeout_secs = 1800 + +# Heartbeat interval in seconds for Android benchmark progress logging +# android_heartbeat_interval_secs = 10 "#, crate_name = crate_name, library_name = library_name, @@ -647,9 +658,12 @@ crate = "discovered-bench" assert!(toml.contains("min_sdk = 24")); assert!(toml.contains("target_sdk = 34")); assert!(toml.contains("deployment_target = \"15.0\"")); + assert!(toml.contains("runner = \"swiftui\"")); assert!(toml.contains("default_iterations = 100")); assert!(toml.contains("default_warmup = 10")); assert!(toml.contains("[browserstack]")); assert!(toml.contains("ios_completion_timeout_secs")); + assert!(toml.contains("android_benchmark_timeout_secs")); + assert!(toml.contains("android_heartbeat_interval_secs")); } } diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index b13a973..e7a52a9 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -145,7 +145,8 @@ pub use cli::MobileTarget; pub(crate) use cli::{ CheckOutputFormat, CiCheckRunArgs, CiCommand, CiRunArgs, CiSummarizeArgs, Cli, Command, ConfigCommand, ContractErrorCategory, DevicePlatform, DevicesCommand, FixtureCommand, - IosSigningMethodArg, ProfileCommand, ReportCommand, SdkTarget, SummarizeFormat, SummaryFormat, + IosRunnerArg, IosSigningMethodArg, ProfileCommand, ReportCommand, SdkTarget, SummarizeFormat, + SummaryFormat, }; #[cfg(test)] pub(crate) use doctor::{ @@ -174,6 +175,10 @@ struct BrowserStackConfig { project: Option<String>, #[serde(skip_serializing_if = "Option::is_none", default)] ios_completion_timeout_secs: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none", default)] + android_benchmark_timeout_secs: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none", default)] + android_heartbeat_interval_secs: Option<u64>, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -200,6 +205,7 @@ struct BenchConfig { struct DeviceEntry { name: String, os: String, + #[serde(default)] os_version: String, tags: Option<Vec<String>>, } @@ -218,6 +224,14 @@ pub(crate) struct RunSpec { pub(crate) devices: Vec<String>, #[serde(skip_serializing_if = "Option::is_none", default)] pub(crate) ios_completion_timeout_secs: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub(crate) ios_deployment_target: Option<String>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub(crate) ios_runner: Option<String>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub(crate) android_benchmark_timeout_secs: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub(crate) android_heartbeat_interval_secs: Option<u64>, #[serde(skip_serializing, skip_deserializing, default)] pub(crate) browserstack: Option<BrowserStackConfig>, #[serde(skip_serializing_if = "Option::is_none", default)] @@ -250,6 +264,8 @@ struct RunSummary { #[serde(skip_serializing_if = "Option::is_none")] benchmark_results: Option<BTreeMap<String, Vec<Value>>>, #[serde(skip_serializing_if = "Option::is_none")] + benchmark_failures: Option<BTreeMap<String, Vec<Value>>>, + #[serde(skip_serializing_if = "Option::is_none")] performance_metrics: Option<BTreeMap<String, browserstack::PerformanceMetrics>>, } @@ -261,6 +277,10 @@ pub(crate) struct ResolvedProjectLayout { pub(crate) library_name: String, pub(crate) android_abis: Option<Vec<String>>, pub(crate) ios_completion_timeout_secs: Option<u64>, + pub(crate) ios_deployment_target: String, + pub(crate) ios_runner: Option<String>, + pub(crate) android_benchmark_timeout_secs: Option<u64>, + pub(crate) android_heartbeat_interval_secs: Option<u64>, pub(crate) config_path: Option<PathBuf>, pub(crate) output_dir: PathBuf, pub(crate) default_function: Option<String>, @@ -315,6 +335,18 @@ struct BenchmarkStats { max_ns: Option<u64>, #[serde(skip_serializing_if = "Option::is_none")] resource_usage: Option<BenchmarkResourceUsage>, + #[serde(skip_serializing_if = "Option::is_none")] + failure: Option<BenchmarkFailureStats>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct BenchmarkFailureStats { + kind: String, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + elapsed_ms: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none")] + exit_reason: Option<String>, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -405,6 +437,10 @@ pub fn run() -> Result<()> { ios_app, ios_test_suite, ios_completion_timeout_secs, + ios_deployment_target, + ios_runner, + android_benchmark_timeout_secs, + android_heartbeat_interval_secs, fetch, fetch_output_dir, fetch_poll_interval_secs, @@ -431,6 +467,10 @@ pub fn run() -> Result<()> { ios_app, ios_test_suite, ios_completion_timeout_secs, + ios_deployment_target, + ios_runner, + android_benchmark_timeout_secs, + android_heartbeat_interval_secs, local_only, release, cli.dry_run, @@ -498,6 +538,17 @@ pub fn run() -> Result<()> { spec.devices.len() ); } + if spec.target == MobileTarget::Ios + && let Some(deployment_target) = spec.ios_deployment_target.as_deref() + { + let parsed_deployment_target = + mobench_sdk::codegen::IosDeploymentTarget::parse(deployment_target) + .map_err(|err| anyhow!("config_error: {err}"))?; + validate_ios_device_specs_support_deployment_target( + &validation.valid, + &parsed_deployment_target, + )?; + } println!( " All {} device(s) validated successfully.", validation.valid.len() @@ -648,6 +699,8 @@ pub fn run() -> Result<()> { release, cli.dry_run, spec.ios_completion_timeout_secs, + spec.ios_deployment_target.as_deref(), + spec.ios_runner.as_deref(), )?; if !progress { println!("\u{2713} Built iOS xcframework at {:?}", xcframework); @@ -674,6 +727,8 @@ pub fn run() -> Result<()> { &layout, release, spec.ios_completion_timeout_secs, + spec.ios_deployment_target.as_deref(), + spec.ios_runner.as_deref(), )?; println!(" ✓ IPA: {}", packaged.app.display()); println!(" ✓ XCUITest: {}", packaged.test_suite.display()); @@ -707,6 +762,7 @@ pub fn run() -> Result<()> { remote_run, summary: summary_placeholder, benchmark_results: None, + benchmark_failures: None, performance_metrics: None, }; @@ -716,6 +772,7 @@ pub fn run() -> Result<()> { return Ok(()); } + let mut pending_browserstack_error: Option<String> = None; if fetch && let Some(remote) = &run_summary.remote_run { let build_id = match remote { RemoteRun::Android { build_id, .. } => build_id, @@ -744,6 +801,7 @@ pub fn run() -> Result<()> { println!("Waiting for build {} to complete...", build_id); println!("Dashboard: {}", dashboard_url); + let mut browserstack_artifacts_fetched = false; match client.wait_and_fetch_all_results_with_poll( build_id, platform, @@ -802,31 +860,51 @@ pub fn run() -> Result<()> { run_summary.performance_metrics = Some(perf_metrics.into_iter().collect()); } Err(e) => { - bail!( + let output_root = fetch_output_dir.join(build_id); + if let Err(fetch_err) = fetch_browserstack_artifacts( + &client, + run_summary.spec.target, + build_id, + &output_root, + false, + fetch_poll_interval_secs, + fetch_timeout_secs, + ) { + eprintln!( + "Warning: failed to fetch detailed BrowserStack artifacts after benchmark failure: {fetch_err}" + ); + } else if let Ok(failures) = load_browserstack_failure_reports(&output_root) + && !failures.is_empty() + { + run_summary.benchmark_failures = Some(failures); + } + browserstack_artifacts_fetched = true; + pending_browserstack_error = Some(format!( "failed to fetch BrowserStack benchmark results: {}. Build may still be accessible at: {}", - e, - dashboard_url - ); + e, dashboard_url + )); } } // Also save detailed artifacts to separate directory let output_root = fetch_output_dir.join(build_id); - fetch_browserstack_artifacts( - &client, - run_summary.spec.target, - build_id, - &output_root, - false, // Don't wait again, we already did - fetch_poll_interval_secs, - fetch_timeout_secs, - ) - .with_context(|| { - format!( - "failed to fetch detailed BrowserStack artifacts for build {}", - build_id + if !browserstack_artifacts_fetched { + fetch_browserstack_artifacts( + &client, + run_summary.spec.target, + build_id, + &output_root, + false, // Don't wait again, we already did + fetch_poll_interval_secs, + fetch_timeout_secs, ) - })?; + .with_context(|| { + format!( + "failed to fetch detailed BrowserStack artifacts for build {}", + build_id + ) + })?; + } } else if fetch { println!("No BrowserStack run to fetch (devices not provided?)"); } @@ -908,6 +986,17 @@ pub fn run() -> Result<()> { write_junit_report(junit_path, &run_summary.summary, ®ression_findings)?; } + if let Some(error) = pending_browserstack_error { + println!(); + println!("Results saved to:"); + println!(" * {} (machine-readable)", summary_paths.json.display()); + println!(" * {} (human-readable)", summary_paths.markdown.display()); + if summary_csv { + println!(" * {} (spreadsheet)", summary_paths.csv.display()); + } + bail!("{error}"); + } + // Print clear completion summary println!(); println!("\u{2713} Benchmark complete!"); @@ -1034,6 +1123,8 @@ pub fn run() -> Result<()> { target, release, ios_completion_timeout_secs, + ios_deployment_target, + ios_runner, project_root, output_dir, crate_path, @@ -1043,6 +1134,8 @@ pub fn run() -> Result<()> { target, release, ios_completion_timeout_secs, + ios_deployment_target, + ios_runner, project_root, output_dir, crate_path, @@ -1270,6 +1363,29 @@ fn load_layout_config( config::MobenchConfig::discover_from(&discovery_base) } +fn load_toml_config_value(path: &Path) -> Result<toml::Value> { + let contents = + fs::read_to_string(path).with_context(|| format!("reading config {}", path.display()))?; + toml::from_str(&contents).with_context(|| format!("parsing config {}", path.display())) +} + +fn toml_path<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Value> { + path.iter() + .try_fold(value, |current, key| current.get(*key)) +} + +fn toml_string(value: &toml::Value, path: &[&str]) -> Option<String> { + toml_path(value, path) + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) +} + +fn toml_u64(value: &toml::Value, path: &[&str]) -> Option<u64> { + toml_path(value, path) + .and_then(|value| value.as_integer()) + .and_then(|value| u64::try_from(value).ok()) +} + fn resolve_project_root_for_layout( start_dir: &Path, explicit_project_root: Option<PathBuf>, @@ -1390,6 +1506,10 @@ pub(crate) fn resolve_project_layout( Some((config, path)) => (Some(config), Some(path)), None => (None, None), }; + let raw_config = config_path + .as_deref() + .map(load_toml_config_value) + .transpose()?; let project_root = resolve_project_root_for_layout( &start_dir, @@ -1429,6 +1549,19 @@ pub(crate) fn resolve_project_layout( let ios_completion_timeout_secs = config .as_ref() .and_then(|cfg| cfg.browserstack.ios_completion_timeout_secs); + let ios_deployment_target = config + .as_ref() + .map(|cfg| cfg.ios.deployment_target.clone()) + .unwrap_or_else(|| mobench_sdk::codegen::DEFAULT_IOS_DEPLOYMENT_TARGET.to_string()); + let ios_runner = raw_config + .as_ref() + .and_then(|cfg| toml_string(cfg, &["ios", "runner"])); + let android_benchmark_timeout_secs = raw_config + .as_ref() + .and_then(|cfg| toml_u64(cfg, &["browserstack", "android_benchmark_timeout_secs"])); + let android_heartbeat_interval_secs = raw_config + .as_ref() + .and_then(|cfg| toml_u64(cfg, &["browserstack", "android_heartbeat_interval_secs"])); let output_dir = config .as_ref() .and_then(|cfg| cfg.project.output_dir.clone()) @@ -1451,6 +1584,10 @@ pub(crate) fn resolve_project_layout( library_name, android_abis, ios_completion_timeout_secs, + ios_deployment_target, + ios_runner, + android_benchmark_timeout_secs, + android_heartbeat_interval_secs, config_path, output_dir, default_function, @@ -1493,6 +1630,58 @@ fn configured_ios_completion_timeout_secs( ios_completion_timeout_secs.or(layout.ios_completion_timeout_secs) } +fn configured_ios_deployment_target( + layout: &ResolvedProjectLayout, + ios_deployment_target: Option<&str>, +) -> Result<mobench_sdk::codegen::IosDeploymentTarget> { + let raw = ios_deployment_target.unwrap_or(&layout.ios_deployment_target); + mobench_sdk::codegen::IosDeploymentTarget::parse(raw) + .map_err(|err| anyhow!("config_error: {err}")) +} + +fn configured_ios_runner( + layout: &ResolvedProjectLayout, + deployment_target: &mobench_sdk::codegen::IosDeploymentTarget, + ios_runner: Option<&str>, +) -> Result<mobench_sdk::codegen::IosRunner> { + let requested = if let Some(raw_runner) = ios_runner { + Some( + mobench_sdk::codegen::IosRunner::parse(raw_runner) + .map_err(|err| anyhow!("config_error: {err}"))?, + ) + } else { + layout + .ios_runner + .as_deref() + .map(mobench_sdk::codegen::IosRunner::parse) + .transpose() + .map_err(|err| anyhow!("config_error: {err}"))? + }; + mobench_sdk::codegen::resolve_ios_runner(deployment_target, requested) + .map_err(|err| anyhow!("config_error: {err}")) +} + +fn ios_runner_arg_name(runner: IosRunnerArg) -> &'static str { + match runner { + IosRunnerArg::Swiftui => "swiftui", + IosRunnerArg::UikitLegacy => "uikit-legacy", + } +} + +fn configured_android_benchmark_timeout_secs( + layout: &ResolvedProjectLayout, + android_benchmark_timeout_secs: Option<u64>, +) -> Option<u64> { + android_benchmark_timeout_secs.or(layout.android_benchmark_timeout_secs) +} + +fn configured_android_heartbeat_interval_secs( + layout: &ResolvedProjectLayout, + android_heartbeat_interval_secs: Option<u64>, +) -> Option<u64> { + android_heartbeat_interval_secs.or(layout.android_heartbeat_interval_secs) +} + fn write_config_template(path: &Path, target: MobileTarget, overwrite: bool) -> Result<()> { ensure_can_write(path, overwrite)?; @@ -1517,6 +1706,8 @@ fn write_config_template(path: &Path, target: MobileTarget, overwrite: bool) -> app_automate_access_key: "${BROWSERSTACK_ACCESS_KEY}".into(), project: Some("mobile-bench-rs".into()), ios_completion_timeout_secs: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, }, ios_xcuitest, }; @@ -1705,6 +1896,10 @@ pub mod bench_support { /// /// Returns [`RunResult`] containing file paths and process exit semantics. pub fn run_request(request: &RunRequest) -> Result<RunResult> { + run_request_with_extra_args(request, &[]) +} + +fn run_request_with_extra_args(request: &RunRequest, extra_args: &[String]) -> Result<RunResult> { fs::create_dir_all(&request.output_dir) .with_context(|| format!("creating output dir {}", request.output_dir.display()))?; @@ -1775,6 +1970,7 @@ pub fn run_request(request: &RunRequest) -> Result<RunResult> { if let Some(path) = &request.crate_path { cmd.arg("--crate-path").arg(path); } + cmd.args(extra_args); if request.fetch { cmd.arg("--fetch"); } @@ -2396,34 +2592,55 @@ fn cmd_ci_run_single( } }); - let result = run_request(&RunRequest { - target, - function: args.function.clone().unwrap_or_default(), - crate_path: args.crate_path.clone(), - iterations: args.iterations, - warmup: args.warmup, - device_selection: DeviceSelection { - devices: args.devices.clone(), - device_matrix: args.device_matrix.clone(), - device_tags: args.device_tags.clone(), + let mut extra_args = Vec::new(); + if let Some(deployment_target) = &args.ios_deployment_target { + extra_args.push("--ios-deployment-target".to_string()); + extra_args.push(deployment_target.clone()); + } + if let Some(runner) = args.ios_runner { + extra_args.push("--ios-runner".to_string()); + extra_args.push(ios_runner_arg_name(runner).to_string()); + } + if let Some(timeout_secs) = args.android_benchmark_timeout_secs { + extra_args.push("--android-benchmark-timeout-secs".to_string()); + extra_args.push(timeout_secs.to_string()); + } + if let Some(interval_secs) = args.android_heartbeat_interval_secs { + extra_args.push("--android-heartbeat-interval-secs".to_string()); + extra_args.push(interval_secs.to_string()); + } + + let result = run_request_with_extra_args( + &RunRequest { + target, + function: args.function.clone().unwrap_or_default(), + crate_path: args.crate_path.clone(), + iterations: args.iterations, + warmup: args.warmup, + device_selection: DeviceSelection { + devices: args.devices.clone(), + device_matrix: args.device_matrix.clone(), + device_tags: args.device_tags.clone(), + }, + config: args.config.clone(), + baseline: baseline_source, + regression_threshold_pct: args.regression_threshold_pct, + junit: args.junit.clone(), + local_only: args.local_only, + release: args.release, + ios_app: args.ios_app.clone(), + ios_test_suite: args.ios_test_suite.clone(), + ios_completion_timeout_secs: args.ios_completion_timeout_secs, + fetch: args.fetch, + fetch_output_dir: args.fetch_output_dir.clone(), + fetch_poll_interval_secs: args.fetch_poll_interval_secs, + fetch_timeout_secs: args.fetch_timeout_secs, + progress: args.progress, + output_dir: output_dir.to_path_buf(), + plots: args.plots, }, - config: args.config.clone(), - baseline: baseline_source, - regression_threshold_pct: args.regression_threshold_pct, - junit: args.junit.clone(), - local_only: args.local_only, - release: args.release, - ios_app: args.ios_app.clone(), - ios_test_suite: args.ios_test_suite.clone(), - ios_completion_timeout_secs: args.ios_completion_timeout_secs, - fetch: args.fetch, - fetch_output_dir: args.fetch_output_dir.clone(), - fetch_poll_interval_secs: args.fetch_poll_interval_secs, - fetch_timeout_secs: args.fetch_timeout_secs, - progress: args.progress, - output_dir: output_dir.to_path_buf(), - plots: args.plots, - })?; + &extra_args, + )?; let summary_json = result.report.summary_json; let summary_md = result.report.summary_md; @@ -2564,6 +2781,15 @@ fn fetch_browserstack_artifacts( write_json(session_dir.join("session.json"), &session_json)?; let mut downloaded_texts = BTreeMap::new(); + let platform = match target { + MobileTarget::Android => "espresso", + MobileTarget::Ios => "xcuitest", + }; + if let Ok(device_logs) = client.get_device_logs(build_id, &session_id, platform) { + let device_log_path = session_dir.join("device.log"); + write_file(&device_log_path, device_logs.as_bytes())?; + downloaded_texts.insert(format!("live-log:{session_id}"), device_logs); + } for (key, url) in extract_url_fields(&session_json) { let file_name = filename_for_url(&key, &url); let dest = session_dir.join(file_name); @@ -2591,12 +2817,119 @@ fn fetch_browserstack_artifacts( }; write_json(session_dir.join("bench-report.json"), &report)?; } + + let mut live_failures = Vec::new(); + for contents in downloaded_texts.values() { + if let Ok(mut failures) = client.extract_benchmark_failures(contents) { + live_failures.append(&mut failures); + } + } + if !live_failures.is_empty() { + let report = if live_failures.len() == 1 { + live_failures.into_iter().next().unwrap_or(Value::Null) + } else { + Value::Array(live_failures) + }; + write_json(session_dir.join("failure.json"), &report)?; + let markdown = render_failure_markdown(&report); + write_file(&session_dir.join("failure.md"), markdown.as_bytes())?; + } else if let Ok(failures) = + client.extract_failures_from_session_artifacts(&session_json, |url| { + downloaded_texts + .get(url) + .cloned() + .ok_or_else(|| anyhow!("artifact {url} was not downloaded as text")) + }) + { + let report = if failures.len() == 1 { + failures.into_iter().next().unwrap_or(Value::Null) + } else { + Value::Array(failures) + }; + write_json(session_dir.join("failure.json"), &report)?; + let markdown = render_failure_markdown(&report); + write_file(&session_dir.join("failure.md"), markdown.as_bytes())?; + } } println!("Fetched BrowserStack artifacts to {:?}", output_root); Ok(()) } +fn load_browserstack_failure_reports(output_root: &Path) -> Result<BTreeMap<String, Vec<Value>>> { + let mut failures_by_device: BTreeMap<String, Vec<Value>> = BTreeMap::new(); + for entry in fs::read_dir(output_root).with_context(|| { + format!( + "reading BrowserStack artifact dir {}", + output_root.display() + ) + })? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let failure_path = entry.path().join("failure.json"); + if !failure_path.exists() { + continue; + } + let contents = fs::read_to_string(&failure_path) + .with_context(|| format!("reading {}", failure_path.display()))?; + let value: Value = serde_json::from_str(&contents) + .with_context(|| format!("parsing {}", failure_path.display()))?; + let reports = match value { + Value::Array(values) => values, + value => vec![value], + }; + for report in reports { + let device = report + .get("device") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()); + failures_by_device.entry(device).or_default().push(report); + } + } + + Ok(failures_by_device) +} + +fn render_failure_markdown(value: &Value) -> String { + let first = value + .as_array() + .and_then(|values| values.first()) + .unwrap_or(value); + let function = first + .get("function_name") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let kind = first + .get("kind") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let message = first + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("no message"); + let device = first + .get("device") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let elapsed = first + .get("elapsed_ms") + .and_then(|value| value.as_u64()) + .map(|value| format!("{value} ms")) + .unwrap_or_else(|| "unknown".to_string()); + let exit_reason = first + .get("android_exit_info") + .and_then(|value| value.get("reason")) + .and_then(|value| value.as_str()) + .unwrap_or("unavailable"); + + format!( + "# Android Benchmark Failure\n\n- Device: {device}\n- Function: {function}\n- Kind: {kind}\n- Message: {message}\n- Elapsed: {elapsed}\n- Exit reason: {exit_reason}\n" + ) +} + fn browserstack_base_path(target: MobileTarget) -> &'static str { match target { MobileTarget::Android => "app-automate/espresso/v2", @@ -2750,15 +3083,36 @@ fn resolve_run_spec( ios_app: Option<PathBuf>, ios_test_suite: Option<PathBuf>, ios_completion_timeout_secs: Option<u64>, + ios_deployment_target: Option<String>, + ios_runner: Option<IosRunnerArg>, + android_benchmark_timeout_secs: Option<u64>, + android_heartbeat_interval_secs: Option<u64>, local_only: bool, _release: bool, dry_run: bool, ) -> Result<RunSpec> { if let Some(cfg_path) = config { let cfg = load_config(cfg_path)?; + let resolved_target = target.unwrap_or(cfg.target); let configured_ios_completion_timeout_secs = ios_completion_timeout_secs .or(cfg.browserstack.ios_completion_timeout_secs) .or(layout.ios_completion_timeout_secs); + let (configured_ios_deployment_target, configured_ios_runner) = + if resolved_target == MobileTarget::Ios { + let deployment_target = + configured_ios_deployment_target(layout, ios_deployment_target.as_deref())?; + let runner_name = ios_runner.map(ios_runner_arg_name); + let runner = configured_ios_runner(layout, &deployment_target, runner_name)?; + (Some(deployment_target), Some(runner)) + } else { + (None, None) + }; + let configured_android_benchmark_timeout_secs = android_benchmark_timeout_secs + .or(cfg.browserstack.android_benchmark_timeout_secs) + .or(layout.android_benchmark_timeout_secs); + let configured_android_heartbeat_interval_secs = android_heartbeat_interval_secs + .or(cfg.browserstack.android_heartbeat_interval_secs) + .or(layout.android_heartbeat_interval_secs); if device_matrix.is_some() && !devices.is_empty() { bail!( "--device-matrix cannot be combined with --devices; choose one source for devices" @@ -2776,12 +3130,41 @@ fn resolve_run_spec( cfg.device_tags.clone() }; let device_names = if !devices.is_empty() { + if resolved_target == MobileTarget::Ios { + validate_ios_device_specs_support_deployment_target( + &devices, + configured_ios_deployment_target + .as_ref() + .expect("iOS deployment target should be resolved"), + )?; + } devices } else { let matrix = load_device_matrix(&matrix_path)?; match resolved_tags.as_ref() { - Some(tags) if !tags.is_empty() => filter_devices_by_tags(matrix.devices, tags)?, - _ => matrix.devices.into_iter().map(|d| d.name).collect(), + Some(tags) if !tags.is_empty() => { + let entries = filter_device_entries_by_tags(matrix.devices, tags)?; + if resolved_target == MobileTarget::Ios { + validate_ios_device_entries_support_deployment_target( + &entries, + configured_ios_deployment_target + .as_ref() + .expect("iOS deployment target should be resolved"), + )?; + } + entries.into_iter().map(|d| d.name).collect() + } + _ => { + if resolved_target == MobileTarget::Ios { + validate_ios_device_entries_support_deployment_target( + &matrix.devices, + configured_ios_deployment_target + .as_ref() + .expect("iOS deployment target should be resolved"), + )?; + } + matrix.devices.into_iter().map(|d| d.name).collect() + } } }; let ios_xcuitest = match (ios_app, ios_test_suite) { @@ -2792,12 +3175,17 @@ fn resolve_run_spec( ), }; return Ok(RunSpec { - target: target.unwrap_or(cfg.target), + target: resolved_target, function: function.unwrap_or(cfg.function), iterations: iterations.unwrap_or(cfg.iterations), warmup: warmup.unwrap_or(cfg.warmup), devices: device_names, ios_completion_timeout_secs: configured_ios_completion_timeout_secs, + ios_deployment_target: configured_ios_deployment_target + .map(|target| target.to_string()), + ios_runner: configured_ios_runner.map(|runner| runner.as_str().to_string()), + android_benchmark_timeout_secs: configured_android_benchmark_timeout_secs, + android_heartbeat_interval_secs: configured_android_heartbeat_interval_secs, browserstack: Some(cfg.browserstack), ios_xcuitest, }); @@ -2805,6 +3193,15 @@ fn resolve_run_spec( let target = target.context("target must be provided with --target or set in the config file")?; + let (configured_ios_deployment_target, configured_ios_runner) = if target == MobileTarget::Ios { + let deployment_target = + configured_ios_deployment_target(layout, ios_deployment_target.as_deref())?; + let runner_name = ios_runner.map(ios_runner_arg_name); + let runner = configured_ios_runner(layout, &deployment_target, runner_name)?; + (Some(deployment_target), Some(runner)) + } else { + (None, None) + }; let function = function.unwrap_or_default(); let iterations = iterations.unwrap_or(100); let warmup = warmup.unwrap_or(10); @@ -2823,13 +3220,38 @@ fn resolve_run_spec( } let resolved_devices = if !devices.is_empty() { + if target == MobileTarget::Ios { + validate_ios_device_specs_support_deployment_target( + &devices, + configured_ios_deployment_target + .as_ref() + .expect("iOS deployment target should be resolved"), + )?; + } devices } else if let Some(matrix_path) = device_matrix { let matrix = load_device_matrix(matrix_path)?; if device_tags.is_empty() { + if target == MobileTarget::Ios { + validate_ios_device_entries_support_deployment_target( + &matrix.devices, + configured_ios_deployment_target + .as_ref() + .expect("iOS deployment target should be resolved"), + )?; + } matrix.devices.into_iter().map(|d| d.name).collect() } else { - filter_devices_by_tags(matrix.devices, &device_tags)? + let entries = filter_device_entries_by_tags(matrix.devices, &device_tags)?; + if target == MobileTarget::Ios { + validate_ios_device_entries_support_deployment_target( + &entries, + configured_ios_deployment_target + .as_ref() + .expect("iOS deployment target should be resolved"), + )?; + } + entries.into_iter().map(|d| d.name).collect() } } else { Vec::new() @@ -2866,6 +3288,16 @@ fn resolve_run_spec( layout, ios_completion_timeout_secs, ), + ios_deployment_target: configured_ios_deployment_target.map(|target| target.to_string()), + ios_runner: configured_ios_runner.map(|runner| runner.as_str().to_string()), + android_benchmark_timeout_secs: configured_android_benchmark_timeout_secs( + layout, + android_benchmark_timeout_secs, + ), + android_heartbeat_interval_secs: configured_android_heartbeat_interval_secs( + layout, + android_heartbeat_interval_secs, + ), browserstack: None, ios_xcuitest, }) @@ -2884,13 +3316,23 @@ fn load_device_matrix(path: &Path) -> Result<DeviceMatrix> { } fn filter_devices_by_tags(devices: Vec<DeviceEntry>, tags: &[String]) -> Result<Vec<String>> { + Ok(filter_device_entries_by_tags(devices, tags)? + .into_iter() + .map(|d| d.name) + .collect()) +} + +fn filter_device_entries_by_tags( + devices: Vec<DeviceEntry>, + tags: &[String], +) -> Result<Vec<DeviceEntry>> { let wanted: Vec<String> = tags .iter() .map(|tag| tag.trim().to_lowercase()) .filter(|tag| !tag.is_empty()) .collect(); if wanted.is_empty() { - return Ok(devices.into_iter().map(|d| d.name).collect()); + return Ok(devices); } let mut matched = Vec::new(); @@ -2910,7 +3352,7 @@ fn filter_devices_by_tags(devices: Vec<DeviceEntry>, tags: &[String]) -> Result< wanted.iter().any(|wanted_tag| wanted_tag == &candidate) }); if has_match { - matched.push(device.name); + matched.push(device); } } @@ -2931,6 +3373,76 @@ fn filter_devices_by_tags(devices: Vec<DeviceEntry>, tags: &[String]) -> Result< Ok(matched) } +fn parse_ios_version_from_device_identifier(spec: &str) -> Option<&str> { + let dash_pos = spec.rfind('-')?; + let version = spec[dash_pos + 1..].trim(); + version + .chars() + .next() + .filter(|ch| ch.is_ascii_digit()) + .map(|_| version) +} + +fn ios_device_version_is_supported( + device_version: &str, + deployment_target: &mobench_sdk::codegen::IosDeploymentTarget, +) -> Result<bool> { + let device_target = + mobench_sdk::codegen::IosDeploymentTarget::parse(device_version).map_err(|err| { + anyhow!("config_error: invalid iOS device version `{device_version}`: {err}") + })?; + Ok(&device_target >= deployment_target) +} + +fn validate_ios_device_specs_support_deployment_target( + devices: &[String], + deployment_target: &mobench_sdk::codegen::IosDeploymentTarget, +) -> Result<()> { + for device in devices { + let Some(os_version) = parse_ios_version_from_device_identifier(device) else { + continue; + }; + if !ios_device_version_is_supported(os_version, deployment_target)? { + bail!( + "`{}` cannot run app with iOS deployment target `{}`.", + device, + deployment_target + ); + } + } + Ok(()) +} + +fn validate_ios_device_entries_support_deployment_target( + devices: &[DeviceEntry], + deployment_target: &mobench_sdk::codegen::IosDeploymentTarget, +) -> Result<()> { + for device in devices { + if !device.os.eq_ignore_ascii_case("ios") { + continue; + } + let parsed_from_name = parse_ios_version_from_device_identifier(&device.name); + let os_version = if device.os_version.trim().is_empty() { + parsed_from_name + } else { + Some(device.os_version.trim()) + }; + let Some(os_version) = os_version else { + continue; + }; + if !ios_device_version_is_supported(os_version, deployment_target)? { + let (identifier, _) = + browserstack_identifier_and_os_version(&device.name, &device.os_version); + bail!( + "`{}` cannot run app with iOS deployment target `{}`.", + identifier, + deployment_target + ); + } + } + Ok(()) +} + fn with_ios_benchmark_timeout_env<T>( timeout_secs: Option<u64>, f: impl FnOnce() -> Result<T>, @@ -2964,13 +3476,19 @@ pub(crate) fn run_ios_build( release: bool, dry_run: bool, ios_completion_timeout_secs: Option<u64>, + ios_deployment_target: Option<&str>, + ios_runner: Option<&str>, ) -> Result<(PathBuf, PathBuf)> { let ios_completion_timeout_secs = configured_ios_completion_timeout_secs(layout, ios_completion_timeout_secs); + let ios_deployment_target = configured_ios_deployment_target(layout, ios_deployment_target)?; + let ios_runner = configured_ios_runner(layout, &ios_deployment_target, ios_runner)?; let builder = mobench_sdk::builders::IosBuilder::new(&layout.project_root, layout.crate_name.clone()) .verbose(true) .dry_run(dry_run) + .deployment_target(ios_deployment_target) + .runner(Some(ios_runner)) .crate_dir(&layout.crate_dir) .output_dir(&layout.output_dir); let profile = if release { @@ -2997,12 +3515,18 @@ fn package_ios_xcuitest_artifacts( layout: &ResolvedProjectLayout, release: bool, ios_completion_timeout_secs: Option<u64>, + ios_deployment_target: Option<&str>, + ios_runner: Option<&str>, ) -> Result<IosXcuitestArtifacts> { let ios_completion_timeout_secs = configured_ios_completion_timeout_secs(layout, ios_completion_timeout_secs); + let ios_deployment_target = configured_ios_deployment_target(layout, ios_deployment_target)?; + let ios_runner = configured_ios_runner(layout, &ios_deployment_target, ios_runner)?; let builder = mobench_sdk::builders::IosBuilder::new(&layout.project_root, layout.crate_name.clone()) .verbose(true) + .deployment_target(ios_deployment_target) + .runner(Some(ios_runner)) .crate_dir(&layout.crate_dir) .output_dir(&layout.output_dir); let profile = if release { @@ -3476,6 +4000,8 @@ pub(crate) fn persist_mobile_spec( "function": spec.function, "iterations": spec.iterations, "warmup": spec.warmup, + "android_benchmark_timeout_secs": spec.android_benchmark_timeout_secs, + "android_heartbeat_interval_secs": spec.android_heartbeat_interval_secs, }); let contents = serde_json::to_string_pretty(&payload)?; @@ -3534,10 +4060,23 @@ pub(crate) fn persist_mobile_spec( /// Embeds the benchmark spec into Android assets and iOS bundle resources. fn embed_spec_into_apps(output_dir: &Path, spec: &RunSpec) -> Result<()> { - let embedded_spec = mobench_sdk::builders::EmbeddedBenchSpec { + #[derive(Serialize)] + struct EmbeddedRunSpec { + function: String, + iterations: u32, + warmup: u32, + #[serde(skip_serializing_if = "Option::is_none")] + android_benchmark_timeout_secs: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none")] + android_heartbeat_interval_secs: Option<u64>, + } + + let embedded_spec = EmbeddedRunSpec { function: spec.function.clone(), iterations: spec.iterations, warmup: spec.warmup, + android_benchmark_timeout_secs: spec.android_benchmark_timeout_secs, + android_heartbeat_interval_secs: spec.android_heartbeat_interval_secs, }; mobench_sdk::builders::embed_bench_spec(output_dir, &embedded_spec) .map_err(|e| anyhow!("Failed to embed bench spec: {}", e)) @@ -3653,6 +4192,7 @@ fn build_summary(run_summary: &RunSummary) -> Result<SummaryReport> { .map(|s| s.max_ns) .or_else(|| entry.get("max_ns").and_then(|value| value.as_u64())), resource_usage: extract_benchmark_resource_usage(entry, perf_metrics), + failure: None, }); } @@ -3664,6 +4204,50 @@ fn build_summary(run_summary: &RunSummary) -> Result<SummaryReport> { } } + if let Some(failures) = &run_summary.benchmark_failures { + for (device, entries) in failures { + let device_index = if let Some(index) = device_summaries + .iter() + .position(|summary| summary.device == *device) + { + index + } else { + device_summaries.push(DeviceSummary { + device: device.clone(), + benchmarks: Vec::new(), + }); + device_summaries.len() - 1 + }; + let device_summary = &mut device_summaries[device_index]; + + for entry in entries { + if let Some(failure) = benchmark_failure_stats(entry) { + device_summary.benchmarks.push(BenchmarkStats { + function: entry + .get("function_name") + .or_else(|| entry.get("function")) + .and_then(|value| value.as_str()) + .unwrap_or(&run_summary.spec.function) + .to_string(), + samples: 0, + mean_ns: None, + median_ns: None, + p95_ns: None, + min_ns: None, + max_ns: None, + resource_usage: entry + .get("memory") + .and_then(extract_benchmark_resource_usage_from_memory), + failure: Some(failure), + }); + } + } + device_summary + .benchmarks + .sort_by(|a, b| a.function.cmp(&b.function)); + } + } + if device_summaries.is_empty() && let Some(local_summary) = summarize_local_report(run_summary) { @@ -3911,6 +4495,15 @@ fn print_run_completion_summary( for device_summary in &summary.summary.device_summaries { println!(" Device: {}", device_summary.device); for bench in &device_summary.benchmarks { + if let Some(failure) = &bench.failure { + println!( + " {} - failed: {}, elapsed: {}", + bench.function, + failure.kind, + format_failure_elapsed_ms(Some(failure)) + ); + continue; + } let median = bench .median_ns .map(format_duration_smart) @@ -4592,6 +5185,7 @@ fn summarize_local_report(run_summary: &RunSummary) -> Option<DeviceSummary> { min_ns: Some(stats.min_ns), max_ns: Some(stats.max_ns), resource_usage: extract_benchmark_resource_usage(&run_summary.local_report, None), + failure: None, }], }) } @@ -4798,6 +5392,43 @@ fn extract_benchmark_resource_usage( (!resource_usage.is_empty()).then_some(resource_usage) } +fn extract_benchmark_resource_usage_from_memory(memory: &Value) -> Option<BenchmarkResourceUsage> { + let resource_usage = BenchmarkResourceUsage { + cpu_total_ms: None, + cpu_median_ms: None, + peak_memory_kb: None, + peak_memory_growth_kb: None, + process_peak_memory_kb: memory.get("process_pss_kb").and_then(json_value_to_u64), + total_pss_kb: memory.get("total_pss_kb").and_then(json_value_to_u64), + private_dirty_kb: memory.get("private_dirty_kb").and_then(json_value_to_u64), + native_heap_kb: memory.get("native_heap_kb").and_then(json_value_to_u64), + java_heap_kb: memory.get("java_heap_kb").and_then(json_value_to_u64), + }; + + (!resource_usage.is_empty()).then_some(resource_usage) +} + +fn benchmark_failure_stats(entry: &Value) -> Option<BenchmarkFailureStats> { + let kind = entry.get("kind").and_then(|value| value.as_str())?; + let message = entry + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("no message") + .to_string(); + let exit_reason = entry + .get("android_exit_info") + .and_then(|info| info.get("reason")) + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + + Some(BenchmarkFailureStats { + kind: kind.to_string(), + message, + elapsed_ms: entry.get("elapsed_ms").and_then(json_value_to_u64), + exit_reason, + }) +} + fn render_markdown_summary(summary: &SummaryReport) -> String { let mut output = String::new(); let devices = if summary.devices.is_empty() { @@ -4824,41 +5455,101 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { return output; } - let _ = writeln!( - output, - "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak |" - ); - let _ = writeln!( - output, - "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" - ); + let has_failures = summary.device_summaries.iter().any(|device| { + device + .benchmarks + .iter() + .any(|benchmark| benchmark.failure.is_some()) + }); + if has_failures { + let _ = writeln!( + output, + "| Device | Function | Status | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak | Elapsed | Exit reason |" + ); + let _ = writeln!( + output, + "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |" + ); + } else { + let _ = writeln!( + output, + "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak |" + ); + let _ = writeln!( + output, + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" + ); + } for device in &summary.device_summaries { for bench in &device.benchmarks { - let _ = writeln!( - output, - "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", - device.device, - bench.function, - bench.samples, - summary.warmup, - format_ms(bench.mean_ns), - format_wall_total(bench.mean_ns, bench.samples), - format_cpu_median_ms(bench.resource_usage.as_ref()), - format_cpu_total_ms(bench.resource_usage.as_ref()), - format_cpu_wall_ratio(bench.mean_ns, bench.samples, bench.resource_usage.as_ref()), - format_peak_memory( - bench - .resource_usage - .as_ref() - .and_then(BenchmarkResourceUsage::peak_memory_growth_or_legacy_kb) - ), - format_peak_memory( + if has_failures { + let _ = writeln!( + output, + "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", + device.device, + bench.function, + format_benchmark_status(bench), + bench.samples, + summary.warmup, + format_ms(bench.mean_ns), + format_wall_total(bench.mean_ns, bench.samples), + format_cpu_median_ms(bench.resource_usage.as_ref()), + format_cpu_total_ms(bench.resource_usage.as_ref()), + format_cpu_wall_ratio( + bench.mean_ns, + bench.samples, + bench.resource_usage.as_ref() + ), + format_peak_memory( + bench + .resource_usage + .as_ref() + .and_then(BenchmarkResourceUsage::peak_memory_growth_or_legacy_kb) + ), + format_peak_memory( + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.process_peak_memory_kb) + ), + format_failure_elapsed_ms(bench.failure.as_ref()), bench - .resource_usage + .failure .as_ref() - .and_then(|usage| usage.process_peak_memory_kb) - ), - ); + .and_then(|failure| failure.exit_reason.as_deref()) + .unwrap_or("-"), + ); + } else { + let _ = writeln!( + output, + "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", + device.device, + bench.function, + bench.samples, + summary.warmup, + format_ms(bench.mean_ns), + format_wall_total(bench.mean_ns, bench.samples), + format_cpu_median_ms(bench.resource_usage.as_ref()), + format_cpu_total_ms(bench.resource_usage.as_ref()), + format_cpu_wall_ratio( + bench.mean_ns, + bench.samples, + bench.resource_usage.as_ref() + ), + format_peak_memory( + bench + .resource_usage + .as_ref() + .and_then(BenchmarkResourceUsage::peak_memory_growth_or_legacy_kb) + ), + format_peak_memory( + bench + .resource_usage + .as_ref() + .and_then(|usage| usage.process_peak_memory_kb) + ), + ); + } } } let _ = writeln!(output); @@ -4870,6 +5561,21 @@ fn render_markdown_summary(summary: &SummaryReport) -> String { output } +fn format_benchmark_status(bench: &BenchmarkStats) -> String { + if let Some(failure) = &bench.failure { + format!("failed ({})", failure.kind) + } else { + "ok".to_string() + } +} + +fn format_failure_elapsed_ms(failure: Option<&BenchmarkFailureStats>) -> String { + failure + .and_then(|failure| failure.elapsed_ms) + .map(|elapsed_ms| format!("{:.3}s", elapsed_ms as f64 / 1_000.0)) + .unwrap_or_else(|| "-".to_string()) +} + fn render_csv_summary(summary: &SummaryReport) -> String { let mut output = String::new(); let _ = writeln!( @@ -5202,6 +5908,8 @@ fn cmd_build( target: SdkTarget, release: bool, ios_completion_timeout_secs: Option<u64>, + ios_deployment_target: Option<String>, + ios_runner: Option<IosRunnerArg>, project_root: Option<PathBuf>, output_dir: Option<PathBuf>, crate_path: Option<PathBuf>, @@ -5218,6 +5926,19 @@ fn cmd_build( let effective_output_dir = output_dir.unwrap_or_else(|| layout.output_dir.clone()); let ios_completion_timeout_secs = configured_ios_completion_timeout_secs(&layout, ios_completion_timeout_secs); + let (ios_deployment_target, ios_runner) = if matches!(target, SdkTarget::Ios | SdkTarget::Both) + { + let deployment_target = + configured_ios_deployment_target(&layout, ios_deployment_target.as_deref())?; + let runner_name = ios_runner.map(ios_runner_arg_name); + let runner = configured_ios_runner(&layout, &deployment_target, runner_name)?; + (deployment_target, runner) + } else { + ( + mobench_sdk::codegen::IosDeploymentTarget::default_target(), + mobench_sdk::codegen::IosRunner::Swiftui, + ) + }; // Progress mode: simplified output if progress { @@ -5289,6 +6010,8 @@ fn cmd_build( ) .verbose(false) .dry_run(dry_run) + .deployment_target(ios_deployment_target.clone()) + .runner(Some(ios_runner)) .output_dir(&effective_output_dir) .crate_dir(&layout.crate_dir); println!("[4/5] Building iOS xcframework..."); @@ -5367,6 +6090,8 @@ fn cmd_build( ) .verbose(verbose) .dry_run(dry_run) + .deployment_target(ios_deployment_target.clone()) + .runner(Some(ios_runner)) .output_dir(&effective_output_dir) .crate_dir(&layout.crate_dir); let result = with_ios_benchmark_timeout_env(ios_completion_timeout_secs, || { @@ -5501,10 +6226,14 @@ fn cmd_package_ipa( config_path: None, })?; let effective_output_dir = output_dir.unwrap_or_else(|| layout.output_dir.clone()); + let ios_deployment_target = configured_ios_deployment_target(&layout, None)?; + let ios_runner = configured_ios_runner(&layout, &ios_deployment_target, None)?; let builder = mobench_sdk::builders::IosBuilder::new(&layout.project_root, layout.crate_name.clone()) .verbose(true) + .deployment_target(ios_deployment_target) + .runner(Some(ios_runner)) .crate_dir(&layout.crate_dir) .output_dir(&effective_output_dir); @@ -5545,10 +6274,14 @@ fn cmd_package_xcuitest( config_path: None, })?; let effective_output_dir = output_dir.unwrap_or_else(|| layout.output_dir.clone()); + let ios_deployment_target = configured_ios_deployment_target(&layout, None)?; + let ios_runner = configured_ios_runner(&layout, &ios_deployment_target, None)?; let builder = mobench_sdk::builders::IosBuilder::new(&layout.project_root, layout.crate_name.clone()) .verbose(true) + .deployment_target(ios_deployment_target) + .runner(Some(ios_runner)) .crate_dir(&layout.crate_dir) .output_dir(&effective_output_dir); @@ -6619,11 +7352,12 @@ fn resolve_devices_from_matrix( if !tag_match { continue; } - let identifier = format!("{}-{}", device.name, device.os_version); + let (identifier, os_version) = + browserstack_identifier_and_os_version(&device.name, &device.os_version); resolved.push(ResolvedMatrixDevice { name: device.name, os: device.os, - os_version: device.os_version, + os_version, identifier, tags: normalized_tags, }); @@ -6655,11 +7389,36 @@ fn resolve_devices_from_matrix( Ok(resolved) } -fn cmd_fixture_init(config_path: &Path, device_matrix_path: &Path, force: bool) -> Result<()> { - write_config_template(config_path, MobileTarget::Android, force)?; - write_device_matrix_template(device_matrix_path, force)?; - println!( - "Initialized fixture files:\n - {}\n - {}", +fn browserstack_identifier_and_os_version(name: &str, os_version: &str) -> (String, String) { + let trimmed_version = os_version.trim(); + if !trimmed_version.is_empty() { + if let Some(name_version) = parse_ios_version_from_device_identifier(name) { + let parsed_name = mobench_sdk::codegen::IosDeploymentTarget::parse(name_version); + let parsed_field = mobench_sdk::codegen::IosDeploymentTarget::parse(trimmed_version); + if let (Ok(parsed_name), Ok(parsed_field)) = (parsed_name, parsed_field) { + if parsed_name == parsed_field { + return (name.to_string(), trimmed_version.to_string()); + } + } + } + return ( + format!("{}-{}", name, trimmed_version), + trimmed_version.to_string(), + ); + } + + if let Some(parsed) = parse_ios_version_from_device_identifier(name) { + return (name.to_string(), parsed.to_string()); + } + + (name.to_string(), String::new()) +} + +fn cmd_fixture_init(config_path: &Path, device_matrix_path: &Path, force: bool) -> Result<()> { + write_config_template(config_path, MobileTarget::Android, force)?; + write_device_matrix_template(device_matrix_path, force)?; + println!( + "Initialized fixture files:\n - {}\n - {}", config_path.display(), device_matrix_path.display() ); @@ -6679,6 +7438,8 @@ fn cmd_fixture_build( release, None, None, + None, + None, output_dir, crate_path, false, @@ -6691,6 +7452,8 @@ fn cmd_fixture_build( release, None, None, + None, + None, output_dir.clone(), crate_path, false, @@ -6712,6 +7475,8 @@ fn cmd_fixture_build( release, None, None, + None, + None, output_dir.clone(), crate_path.clone(), false, @@ -6723,6 +7488,8 @@ fn cmd_fixture_build( release, None, None, + None, + None, output_dir.clone(), crate_path, false, @@ -7091,6 +7858,10 @@ pub fn bench_query_proof_generation() {} None, None, None, + None, + None, + None, + None, false, false, // release false, @@ -7166,6 +7937,10 @@ project = "proj" None, None, None, + None, + None, + None, + None, false, false, false, @@ -7231,6 +8006,10 @@ project = "proj" None, None, None, + None, + None, + None, + None, false, false, false, @@ -7345,12 +8124,78 @@ project = "proj" Some(vec!["arm64-v8a".to_string(), "x86_64".to_string()]) ); assert_eq!(layout.ios_completion_timeout_secs, Some(900)); + assert_eq!(layout.ios_deployment_target, "15.0"); + assert_eq!(layout.ios_runner, None); assert_eq!( layout.default_function.as_deref(), Some("zk_mobile_bench::bench_query_proof_generation") ); } + #[test] + fn ios_runner_selection_uses_legacy_below_ios_15() { + let temp_dir = TempDir::new().expect("temp dir"); + let (project_root, _) = write_custom_layout_project(&temp_dir); + write_file( + &project_root.join("mobench.toml"), + br#"[project] +crate = "zk-mobile-bench" +library_name = "zk_mobile_bench" + +[ios] +deployment_target = "10.0" + +[benchmarks] +default_function = "zk_mobile_bench::bench_query_proof_generation" +"#, + ) + .expect("write mobench config"); + + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: Some(project_root.as_path()), + project_root: None, + crate_path: None, + config_path: None, + }) + .expect("resolve project layout"); + let target = configured_ios_deployment_target(&layout, None).unwrap(); + let runner = configured_ios_runner(&layout, &target, None).unwrap(); + + assert_eq!(target.to_string(), "10.0"); + assert_eq!(runner, mobench_sdk::codegen::IosRunner::UikitLegacy); + } + + #[test] + fn ios_runner_rejects_forced_swiftui_below_ios_15() { + let temp_dir = TempDir::new().expect("temp dir"); + let (project_root, _) = write_custom_layout_project(&temp_dir); + write_file( + &project_root.join("mobench.toml"), + br#"[project] +crate = "zk-mobile-bench" +library_name = "zk_mobile_bench" + +[ios] +deployment_target = "10.0" +runner = "swiftui" +"#, + ) + .expect("write mobench config"); + + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: Some(project_root.as_path()), + project_root: None, + crate_path: None, + config_path: None, + }) + .expect("resolve project layout"); + let target = configured_ios_deployment_target(&layout, None).unwrap(); + let err = configured_ios_runner(&layout, &target, None) + .expect_err("swiftui should reject iOS 10"); + + assert!(err.to_string().contains("requires deployment target 15.0+")); + } + #[test] fn list_uses_resolved_layout_for_custom_crate() { let temp_dir = TempDir::new().expect("temp dir"); @@ -7405,6 +8250,8 @@ project = "proj" SdkTarget::Ios, false, None, + None, + None, Some(project_root), None, None, @@ -7458,6 +8305,10 @@ project = "proj" None, None, None, + None, + None, + None, + None, false, false, true, @@ -7516,6 +8367,10 @@ project = "proj" warmup: 1, devices: vec![], ios_completion_timeout_secs: None, + ios_deployment_target: None, + ios_runner: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, browserstack: None, ios_xcuitest: None, }; @@ -7548,6 +8403,10 @@ project = "proj" None, None, None, + None, + None, + None, + None, false, false, // release false, @@ -7739,6 +8598,34 @@ project = "proj" } } + #[test] + fn ci_run_parses_android_watchdog_settings() { + let cli = Cli::parse_from([ + "mobench", + "ci", + "run", + "--target", + "android", + "--function", + "sample_fns::fibonacci", + "--android-benchmark-timeout-secs", + "30", + "--android-heartbeat-interval-secs", + "3", + ]); + + match cli.command { + Command::Ci { + command: CiCommand::Run(args), + } => { + assert_eq!(args.target, CiTarget::Android); + assert_eq!(args.android_benchmark_timeout_secs, Some(30)); + assert_eq!(args.android_heartbeat_interval_secs, Some(3)); + } + _ => panic!("expected ci run command"), + } + } + #[test] fn build_parses_ios_completion_timeout_secs() { let cli = Cli::parse_from([ @@ -7761,6 +8648,32 @@ project = "proj" } } + #[test] + fn build_parses_ios_deployment_target_and_runner() { + let cli = Cli::parse_from([ + "mobench", + "build", + "--target", + "ios", + "--ios-deployment-target", + "10.0", + "--ios-runner", + "uikit-legacy", + ]); + + match cli.command { + Command::Build { + ios_deployment_target, + ios_runner, + .. + } => { + assert_eq!(ios_deployment_target.as_deref(), Some("10.0")); + assert_eq!(ios_runner, Some(IosRunnerArg::UikitLegacy)); + } + _ => panic!("expected build command"), + } + } + #[test] fn resolve_run_spec_reads_ios_completion_timeout_from_config() { let temp_dir = TempDir::new().expect("temp dir"); @@ -7813,6 +8726,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" None, None, Some(600), + None, + None, + None, + None, false, false, false, @@ -7820,6 +8737,8 @@ test_suite = "target/ios/BenchRunnerUITests.zip" .expect("resolve spec"); assert_eq!(spec.ios_completion_timeout_secs, Some(600)); + assert_eq!(spec.ios_deployment_target.as_deref(), Some("15.0")); + assert_eq!(spec.ios_runner.as_deref(), Some("swiftui")); assert_eq!( spec.browserstack .as_ref() @@ -7828,6 +8747,146 @@ test_suite = "target/ios/BenchRunnerUITests.zip" ); } + #[test] + fn resolve_run_spec_applies_legacy_ios_deployment_override() { + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: None, + config_path: None, + }) + .unwrap(); + + let spec = resolve_run_spec( + Some(MobileTarget::Ios), + Some("sample_fns::fibonacci".into()), + Some(1), + Some(0), + vec!["iPhone 7-10".to_string()], + &layout, + None, + None, + Vec::new(), + None, + None, + None, + Some("10.0".to_string()), + None, + None, + None, + false, + false, + false, + ) + .expect("resolve spec"); + + assert_eq!(spec.ios_deployment_target.as_deref(), Some("10.0")); + assert_eq!(spec.ios_runner.as_deref(), Some("uikit-legacy")); + } + + #[test] + fn resolve_run_spec_rejects_ios_device_below_deployment_target() { + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: None, + config_path: None, + }) + .unwrap(); + + let err = resolve_run_spec( + Some(MobileTarget::Ios), + Some("sample_fns::fibonacci".into()), + Some(1), + Some(0), + vec!["iPhone 7-10".to_string()], + &layout, + None, + None, + Vec::new(), + None, + None, + None, + Some("15.0".to_string()), + None, + None, + None, + false, + false, + false, + ) + .expect_err("iOS 10 device should reject iOS 15 app"); + + assert!( + err.to_string() + .contains("cannot run app with iOS deployment target `15.0`"), + "unexpected error: {err}" + ); + } + + #[test] + fn resolve_run_spec_reads_android_watchdog_from_config_and_cli() { + let temp_dir = TempDir::new().expect("temp dir"); + let config_path = temp_dir.path().join("bench-config.toml"); + + let config_toml = r#"target = "android" +function = "sample_fns::fibonacci" +iterations = 10 +warmup = 2 +device_matrix = "device-matrix.yaml" + +[browserstack] +app_automate_username = "user" +app_automate_access_key = "key" +project = "proj" +android_benchmark_timeout_secs = 120 +android_heartbeat_interval_secs = 7 +"#; + write_file(&config_path, config_toml.as_bytes()).expect("write config"); + write_file( + &temp_dir.path().join("device-matrix.yaml"), + br#"devices: + - name: Google Pixel 8 + os: android + os_version: "14" +"#, + ) + .expect("write matrix"); + + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: None, + config_path: None, + }) + .unwrap(); + let spec = resolve_run_spec( + Some(MobileTarget::Android), + Some("ignored::value".into()), + Some(1), + Some(0), + Vec::new(), + &layout, + Some(config_path.as_path()), + None, + Vec::new(), + None, + None, + None, + None, + None, + Some(30), + Some(3), + false, + false, + false, + ) + .expect("resolve spec"); + + assert_eq!(spec.android_benchmark_timeout_secs, Some(30)); + assert_eq!(spec.android_heartbeat_interval_secs, Some(3)); + } + #[test] fn devices_resolve_parses() { let cli = Cli::parse_from([ @@ -8156,6 +9215,18 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert_eq!(ids, vec!["Pixel 6-12.0", "Pixel 7-13.0"]); } + #[test] + fn browserstack_identifier_preserves_versioned_ios_names() { + let (identifier, os_version) = browserstack_identifier_and_os_version("iPhone 7-10", ""); + assert_eq!(identifier, "iPhone 7-10"); + assert_eq!(os_version, "10"); + + let (identifier, os_version) = + browserstack_identifier_and_os_version("iPhone 7-10", "10.0"); + assert_eq!(identifier, "iPhone 7-10"); + assert_eq!(os_version, "10.0"); + } + fn safe_config_string() -> impl Strategy<Value = String> { "[A-Za-z0-9_. -]{1,32}".prop_map(|s| s.trim().to_string()) } @@ -8199,6 +9270,8 @@ test_suite = "target/ios/BenchRunnerUITests.zip" app_automate_access_key: access_key, project, ios_completion_timeout_secs: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, }, ios_xcuitest: None, }, @@ -8362,6 +9435,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" native_heap_kb: None, java_heap_kb: None, }), + failure: None, }], }], }); @@ -8408,6 +9482,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" native_heap_kb: None, java_heap_kb: None, }), + failure: None, }], }], }); @@ -8452,6 +9527,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" native_heap_kb: None, java_heap_kb: None, }), + failure: None, }], }], }); @@ -8495,6 +9571,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" native_heap_kb: None, java_heap_kb: None, }), + failure: None, }], }], }; @@ -8537,6 +9614,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" native_heap_kb: None, java_heap_kb: None, }), + failure: None, }], }], }); @@ -8586,6 +9664,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" native_heap_kb: None, java_heap_kb: None, }), + failure: None, }], }], }); @@ -8638,6 +9717,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" native_heap_kb: None, java_heap_kb: None, }), + failure: None, }], }], }); @@ -8661,6 +9741,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" browserstack: None, ios_xcuitest: None, ios_completion_timeout_secs: None, + ios_deployment_target: None, + ios_runner: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, }; let run_summary = RunSummary { spec: spec.clone(), @@ -8679,6 +9763,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" ] })], )])), + benchmark_failures: None, performance_metrics: None, }; @@ -8695,6 +9780,65 @@ test_suite = "target/ios/BenchRunnerUITests.zip" assert_eq!(usage.process_peak_memory_kb, Some(1_096)); } + #[test] + fn build_summary_preserves_browserstack_failure_results() { + let spec = RunSpec { + target: MobileTarget::Android, + function: "sample_fns::sleep".into(), + iterations: 3, + warmup: 1, + devices: vec!["Vivo Y21-11.0".into()], + browserstack: None, + ios_xcuitest: None, + ios_completion_timeout_secs: None, + ios_deployment_target: None, + ios_runner: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, + }; + let run_summary = RunSummary { + spec: spec.clone(), + artifacts: None, + local_report: json!({}), + remote_run: None, + summary: empty_summary(&spec), + benchmark_results: None, + benchmark_failures: Some(BTreeMap::from([( + "Vivo Y21-11.0".to_string(), + vec![json!({ + "schema_version": 1, + "platform": "android", + "device": "Vivo Y21-11.0", + "function_name": "sample_fns::sleep", + "kind": "timeout", + "message": "Timed out waiting 30s for benchmark completion", + "elapsed_ms": 30_000_u64, + "memory": { + "total_pss_kb": 1024_u64 + }, + "android_exit_info": { + "reason": "low_memory", + "raw_reason": 3 + } + })], + )])), + performance_metrics: None, + }; + + let summary = build_summary(&run_summary).expect("build summary"); + let benchmark = &summary.device_summaries[0].benchmarks[0]; + let failure = benchmark.failure.as_ref().expect("failure summary"); + let markdown = render_markdown_summary(&summary); + + assert_eq!(benchmark.function, "sample_fns::sleep"); + assert_eq!(benchmark.samples, 0); + assert_eq!(failure.kind, "timeout"); + assert_eq!(failure.elapsed_ms, Some(30_000)); + assert_eq!(failure.exit_reason.as_deref(), Some("low_memory")); + assert!(markdown.contains("failed (timeout)")); + assert!(markdown.contains("low_memory")); + } + #[test] fn build_summary_prefers_measured_peak_memory_over_browserstack_perf_memory() { let spec = RunSpec { @@ -8706,6 +9850,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" browserstack: None, ios_xcuitest: None, ios_completion_timeout_secs: None, + ios_deployment_target: None, + ios_runner: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, }; let run_summary = RunSummary { spec: spec.clone(), @@ -8723,6 +9871,7 @@ test_suite = "target/ios/BenchRunnerUITests.zip" ] })], )])), + benchmark_failures: None, performance_metrics: Some(BTreeMap::from([( "Google Pixel 8-14.0".to_string(), browserstack::PerformanceMetrics { @@ -8819,6 +9968,10 @@ test_suite = "target/ios/BenchRunnerUITests.zip" warmup: 1, devices: vec![], ios_completion_timeout_secs: None, + ios_deployment_target: None, + ios_runner: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, browserstack: None, ios_xcuitest: None, }; @@ -8835,10 +9988,15 @@ test_suite = "target/ios/BenchRunnerUITests.zip" warmup: 1, devices: vec![], ios_completion_timeout_secs: None, + ios_deployment_target: None, + ios_runner: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, browserstack: None, ios_xcuitest: None, }), benchmark_results: None, + benchmark_failures: None, performance_metrics: None, }; run_summary.summary = build_summary(&run_summary).expect("build summary"); @@ -9019,6 +10177,7 @@ Samsung Galaxy S23-14.0,basic_benchmark::bench_checksum,5,136000000,136000000,14 std_dev_ms: None, }, resource_usage: None, + failure: None, }], iterations: 5, warmup: 1, @@ -9044,6 +10203,7 @@ Samsung Galaxy S23-14.0,basic_benchmark::bench_checksum,5,136000000,136000000,14 std_dev_ms: None, }, resource_usage: None, + failure: None, }], iterations: 5, warmup: 1, @@ -9323,6 +10483,7 @@ mod ci_merge_tests { min_ns: Some(16_000), max_ns: Some(19_000), resource_usage: None, + failure: None, }], }], }); @@ -9545,6 +10706,10 @@ mod ci_merge_tests { browserstack: None, ios_xcuitest: None, ios_completion_timeout_secs: None, + ios_deployment_target: None, + ios_runner: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, }; let local_report = json!({}); let run_summary = RunSummary { @@ -9571,6 +10736,7 @@ mod ci_merge_tests { } })], )])), + benchmark_failures: None, performance_metrics: None, }; @@ -9599,6 +10765,10 @@ mod ci_merge_tests { browserstack: None, ios_xcuitest: None, ios_completion_timeout_secs: None, + ios_deployment_target: None, + ios_runner: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, }; let run_summary = RunSummary { spec: spec.clone(), @@ -9620,6 +10790,7 @@ mod ci_merge_tests { } })], )])), + benchmark_failures: None, performance_metrics: Some(BTreeMap::from([( "iPhone 15-17.0".to_string(), browserstack::PerformanceMetrics { diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 4e25d2e..4db41c8 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -2706,6 +2706,10 @@ fn execute_local_android_capture( warmup: DEFAULT_PROFILE_WARMUP, devices: Vec::new(), ios_completion_timeout_secs: None, + ios_deployment_target: None, + ios_runner: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, browserstack: None, ios_xcuitest: None, }; @@ -2976,6 +2980,10 @@ fn execute_local_ios_capture(args: &ProfileRunArgs, manifest: &mut ProfileManife warmup: DEFAULT_PROFILE_WARMUP, devices: Vec::new(), ios_completion_timeout_secs: None, + ios_deployment_target: None, + ios_runner: None, + android_benchmark_timeout_secs: None, + android_heartbeat_interval_secs: None, browserstack: None, ios_xcuitest: None, }; @@ -2986,7 +2994,7 @@ fn execute_local_ios_capture(args: &ProfileRunArgs, manifest: &mut ProfileManife ensure_local_ios_simulator_booted(&simulator)?; manifest.capture_metadata.device = Some(simulator.identifier()); - run_ios_build(&layout, false, false, None)?; + run_ios_build(&layout, false, false, None, None, None)?; let app_path = build_local_ios_simulator_app(&layout, &simulator)?; install_local_ios_app(&simulator, &app_path)?; diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index c83cec1..5c3f9c6 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -42,6 +42,19 @@ pub struct BenchmarkResult { pub timing: TimingStats, #[serde(skip_serializing_if = "Option::is_none")] pub resource_usage: Option<ResourceUsage>, + #[serde(skip_serializing_if = "Option::is_none")] + pub failure: Option<BenchmarkFailure>, +} + +/// Structured benchmark failure loaded from `failure.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkFailure { + pub kind: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub elapsed_ms: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_reason: Option<String>, } /// Timing statistics across all iterations (in milliseconds). @@ -180,7 +193,12 @@ fn parse_benchmark_entry(value: &serde_json::Value) -> Result<BenchmarkResult> { .unwrap_or("unknown") .to_string(); - let label = humanize_benchmark_name(&name); + let failure = parse_failure(value); + let label = if failure.is_some() { + name.clone() + } else { + humanize_benchmark_name(&name) + }; let ns_to_ms = |key: &str| -> f64 { value.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0) / 1_000_000.0 }; @@ -203,6 +221,7 @@ fn parse_benchmark_entry(value: &serde_json::Value) -> Result<BenchmarkResult> { label, timing, resource_usage: parse_resource_usage(value), + failure, }) } @@ -262,6 +281,8 @@ pub fn load_results_dir(dir: &Path) -> Result<SummarizeReport> { } covered_summary_dirs.push(summary_dir.to_path_buf()); all_platforms.extend(report.platforms); + } else if let Ok(report) = parse_raw_failure_report(&path, &value) { + all_platforms.extend(report.platforms); } else { raw_candidates.push((path, value)); } @@ -271,6 +292,8 @@ pub fn load_results_dir(dir: &Path) -> Result<SummarizeReport> { for (path, value) in raw_candidates { if let Ok(report) = parse_raw_bench_report(&path, &value) { all_platforms.extend(report.platforms); + } else if let Ok(report) = parse_raw_failure_report(&path, &value) { + all_platforms.extend(report.platforms); } } } @@ -502,6 +525,75 @@ fn parse_raw_bench_report(path: &Path, value: &serde_json::Value) -> Result<Summ }) } +fn parse_raw_failure_report(path: &Path, value: &serde_json::Value) -> Result<SummarizeReport> { + let entries = match value { + serde_json::Value::Array(items) => items + .iter() + .filter_map(normalize_failure_entry) + .collect::<Vec<_>>(), + _ => normalize_failure_entry(value).into_iter().collect(), + }; + if entries.is_empty() { + anyhow::bail!("Not a benchmark failure report"); + } + + let first = entries + .first() + .ok_or_else(|| anyhow::anyhow!("missing failure entries"))?; + let platform = first + .get("platform") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| infer_platform(path, first.get("device").and_then(|v| v.as_str()))); + let device = first + .get("device") + .and_then(|v| v.as_str()) + .map(parse_device_string) + .unwrap_or_else(|| default_device_info(&platform)); + let benchmarks = entries + .iter() + .map(parse_benchmark_entry) + .collect::<Result<Vec<_>>>()?; + + Ok(SummarizeReport { + platforms: vec![PlatformReport { + platform, + device, + benchmarks, + iterations: 0, + warmup: 0, + }], + }) +} + +fn normalize_failure_entry(value: &serde_json::Value) -> Option<serde_json::Value> { + let object = value.as_object()?; + let kind = object.get("kind").and_then(|value| value.as_str())?; + let function = object + .get("function_name") + .or_else(|| object.get("function")) + .and_then(|value| value.as_str())?; + let mut normalized = value.clone(); + let normalized_object = normalized.as_object_mut()?; + normalized_object.insert( + "function".to_string(), + serde_json::Value::String(function.to_string()), + ); + normalized_object.insert( + "failure".to_string(), + serde_json::json!({ + "kind": kind, + "message": object.get("message").and_then(|value| value.as_str()).unwrap_or("no message"), + "elapsed_ms": object.get("elapsed_ms").and_then(|value| value.as_u64()), + "exit_reason": object + .get("android_exit_info") + .and_then(|info| info.get("reason")) + .and_then(|value| value.as_str()), + }), + ); + Some(normalized) +} + fn normalize_raw_benchmark_entry(value: &serde_json::Value) -> Option<serde_json::Value> { let mut value = value.clone(); let samples = extract_raw_samples(&value); @@ -660,6 +752,36 @@ fn parse_resource_usage(value: &serde_json::Value) -> Option<ResourceUsage> { .or_else(|| value.get("resources").and_then(parse_resource_usage_object)) } +fn parse_failure(value: &serde_json::Value) -> Option<BenchmarkFailure> { + let failure = value.get("failure").unwrap_or(value); + let kind = failure.get("kind").and_then(|value| value.as_str())?; + let message = failure + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("no message") + .to_string(); + let exit_reason = failure + .get("exit_reason") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + .or_else(|| { + value + .get("android_exit_info") + .and_then(|info| info.get("reason")) + .and_then(|reason| reason.as_str()) + .map(ToOwned::to_owned) + }); + Some(BenchmarkFailure { + kind: kind.to_string(), + message, + elapsed_ms: failure + .get("elapsed_ms") + .and_then(json_value_to_u64) + .or_else(|| value.get("elapsed_ms").and_then(json_value_to_u64)), + exit_reason, + }) +} + fn parse_resource_usage_object(value: &serde_json::Value) -> Option<ResourceUsage> { let object = value.as_object()?; @@ -795,7 +917,11 @@ fn render_platform_table(platform: &PlatformReport) -> String { .load_preset(UTF8_FULL) .set_content_arrangement(ContentArrangement::Dynamic); + let has_failures = platform.benchmarks.iter().any(|b| b.failure.is_some()); let mut headers = vec!["Benchmark", "Avg ms", "Best", "Worst", "Median", "P95"]; + if has_failures { + headers.extend(["Status", "Failure", "Exit reason"]); + } if has_resource_usage { headers.extend(["CPU total", "Peak growth", "Process peak"]); } @@ -806,14 +932,50 @@ fn render_platform_table(platform: &PlatformReport) -> String { ); for bench in &platform.benchmarks { - let mut row = vec![ - Cell::new(&bench.label), - Cell::new(format!("{:.1}", bench.timing.avg_ms)).add_attribute(Attribute::Bold), - Cell::new(format!("{:.1}", bench.timing.best_ms)), - Cell::new(format!("{:.1}", bench.timing.worst_ms)), - Cell::new(format!("{:.1}", bench.timing.median_ms)), - Cell::new(format!("{:.1}", bench.timing.p95_ms)), - ]; + let mut row = if bench.failure.is_some() { + vec![ + Cell::new(&bench.label), + Cell::new("—").add_attribute(Attribute::Bold), + Cell::new("—"), + Cell::new("—"), + Cell::new("—"), + Cell::new("—"), + ] + } else { + vec![ + Cell::new(&bench.label), + Cell::new(format!("{:.1}", bench.timing.avg_ms)).add_attribute(Attribute::Bold), + Cell::new(format!("{:.1}", bench.timing.best_ms)), + Cell::new(format!("{:.1}", bench.timing.worst_ms)), + Cell::new(format!("{:.1}", bench.timing.median_ms)), + Cell::new(format!("{:.1}", bench.timing.p95_ms)), + ] + }; + + if has_failures { + if let Some(failure) = &bench.failure { + row.push(Cell::new("failed")); + row.push(Cell::new(format!( + "{}: {} ({})", + failure.kind, + failure.message, + failure + .elapsed_ms + .map(|value| format!("{value}ms")) + .unwrap_or_else(|| "elapsed unknown".to_string()) + ))); + row.push(Cell::new( + failure + .exit_reason + .clone() + .unwrap_or_else(|| "unavailable".to_string()), + )); + } else { + row.push(Cell::new("passed")); + row.push(Cell::new("—")); + row.push(Cell::new("—")); + } + } if has_resource_usage { if let Some(ru) = &bench.resource_usage { @@ -873,29 +1035,64 @@ pub fn render_markdown(report: &SummarizeReport) -> String { .benchmarks .iter() .any(|b| b.resource_usage.is_some()); + let has_failures = platform.benchmarks.iter().any(|b| b.failure.is_some()); - if has_ru { - output.push_str( - "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU total | Peak growth | Process peak |\n", - ); - output.push_str( - "|-----------|--------|------|-------|--------|-----|-----------|-------------|--------------|\n", - ); + if has_ru || has_failures { + output.push_str("| Benchmark | Avg ms | Best | Worst | Median | P95 |"); + if has_failures { + output.push_str(" Status | Failure | Exit reason |"); + } + if has_ru { + output.push_str(" CPU total | Peak growth | Process peak |"); + } + output.push('\n'); + output.push_str("|-----------|--------|------|-------|--------|-----|"); + if has_failures { + output.push_str("--------|---------|-------------|"); + } + if has_ru { + output.push_str("-----------|-------------|--------------|"); + } + output.push('\n'); } else { output.push_str("| Benchmark | Avg ms | Best | Worst | Median | P95 |\n"); output.push_str("|-----------|--------|------|-------|--------|-----|\n"); } for bench in &platform.benchmarks { - let mut row = format!( - "| {} | **{:.1}** | {:.1} | {:.1} | {:.1} | {:.1} |", - bench.label, - bench.timing.avg_ms, - bench.timing.best_ms, - bench.timing.worst_ms, - bench.timing.median_ms, - bench.timing.p95_ms, - ); + let mut row = if bench.failure.is_some() { + format!("| {} | **—** | — | — | — | — |", bench.label) + } else { + format!( + "| {} | **{:.1}** | {:.1} | {:.1} | {:.1} | {:.1} |", + bench.label, + bench.timing.avg_ms, + bench.timing.best_ms, + bench.timing.worst_ms, + bench.timing.median_ms, + bench.timing.p95_ms, + ) + }; + + if has_failures { + if let Some(failure) = &bench.failure { + row.push_str(&format!( + " failed | {}: {} ({}) | {} |", + failure.kind, + failure.message.replace('|', "\\|"), + failure + .elapsed_ms + .map(|value| format!("{value}ms")) + .unwrap_or_else(|| "elapsed unknown".to_string()), + failure + .exit_reason + .clone() + .unwrap_or_else(|| "unavailable".to_string()) + )); + } else { + row.push_str(" passed | — | — |"); + } + } if has_ru { if let Some(ru) = &bench.resource_usage { @@ -1147,6 +1344,47 @@ mod tests { ); } + #[test] + fn test_load_results_dir_preserves_android_failure_json() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let failure_dir = temp.path().join("android/passport"); + std::fs::create_dir_all(&failure_dir).expect("create failure dir"); + std::fs::write( + failure_dir.join("failure.json"), + serde_json::to_string_pretty(&json!({ + "schema_version": 1, + "platform": "android", + "device": "Vivo Y21-11.0", + "function_name": "provekit::passport", + "kind": "timeout", + "message": "Timed out waiting 30s for benchmark completion", + "elapsed_ms": 30000, + "android_exit_info": { + "reason": "low_memory", + "raw_reason": 3 + } + })) + .unwrap(), + ) + .expect("write failure"); + + let report = load_results_dir(temp.path()).expect("load failure report"); + let platform = &report.platforms[0]; + let benchmark = &platform.benchmarks[0]; + let failure = benchmark.failure.as_ref().expect("failure summary"); + assert_eq!(platform.platform, "android"); + assert_eq!(platform.device.name, "Vivo Y21"); + assert_eq!(benchmark.name, "provekit::passport"); + assert_eq!(failure.kind, "timeout"); + assert_eq!(failure.elapsed_ms, Some(30000)); + assert_eq!(failure.exit_reason.as_deref(), Some("low_memory")); + + let markdown = render_markdown(&report); + assert!(markdown.contains("provekit::passport")); + assert!(markdown.contains("timeout")); + assert!(markdown.contains("low_memory")); + } + #[test] fn test_load_results_dir_backfills_resource_usage_from_nested_summaries() { let fixture_dir = @@ -1323,6 +1561,7 @@ mod tests { native_heap_kb: Some(120000), java_heap_kb: Some(45000), }), + failure: None, }], iterations: 30, warmup: 5, @@ -1359,6 +1598,7 @@ mod tests { std_dev_ms: Some(35.2), }, resource_usage: None, + failure: None, }], iterations: 30, warmup: 5, @@ -1599,6 +1839,7 @@ mod tests { std_dev_ms: None, }, resource_usage: None, + failure: None, }], iterations: 5, warmup: 1, @@ -1624,6 +1865,7 @@ mod tests { std_dev_ms: None, }, resource_usage: None, + failure: None, }], iterations: 5, warmup: 1, diff --git a/docs/guides/browserstack-ci.md b/docs/guides/browserstack-ci.md index d0c11a8..f1efa0d 100644 --- a/docs/guides/browserstack-ci.md +++ b/docs/guides/browserstack-ci.md @@ -72,6 +72,34 @@ Invalid devices (1): - Google Pixel 7 Pro-13.0 ``` +### Legacy iOS Devices + +mobench supports two generated iOS runner templates: + +- `swiftui`: the default iOS 15+ runner used by current CI lanes. +- `uikit-legacy`: a UIKit runner without SwiftUI or Swift concurrency for lower deployment targets. + +The current/default Xcode lane is treated as iOS 15+. Older BrowserStack iOS devices require both the legacy runner and an old enough Xcode toolchain. BrowserStack App Automate currently lists iPhone 7 as iOS 10, so an iPhone 7 legacy lane needs an Xcode installation capable of building/installing for iOS 10/11/12. + +Example `mobench.toml`: + +```toml +[ios] +deployment_target = "10.0" +runner = "uikit-legacy" +``` + +Example device matrix entry: + +```yaml +devices: + - name: "iPhone 7-10" + os: "ios" + tags: ["legacy-ios"] +``` + +mobench fails early if the selected BrowserStack iOS device version is lower than the app deployment target, for example `iPhone 7-10` with `deployment_target = "15.0"`. It also checks the selected local Xcode version before iOS builds and reports when a legacy deployment target needs an older Xcode lane. + ### Verify Benchmark Setup ```bash diff --git a/docs/specs/mobench-device-farm-spec.md b/docs/specs/mobench-device-farm-spec.md new file mode 100644 index 0000000..c58b8b1 --- /dev/null +++ b/docs/specs/mobench-device-farm-spec.md @@ -0,0 +1,1084 @@ +# mobench Device Farm Provider Spec + +Status: draft +Updated: 2026-04-30 + +This spec describes a `mobench` provider for running mobile benchmarks on a +private fleet of real Android and iOS devices. The provider is intended for +devices that are unavailable or unsuitable in hosted device-cloud services. + +The device farm is narrower than a general-purpose mobile testing cloud. It +installs already-built benchmark apps, runs the generated Espresso or XCUITest +harness, collects logs and device metadata, and returns benchmark results +through an async API. + +## Goals + +- Run `mobench` benchmarks on managed physical Android and iOS devices. +- Add a `mobench`-native provider API alongside existing providers. +- Support async multi-device runs from day one. +- Return parsed benchmark results and raw debug artifacts. +- Integrate with local development and CI without requiring the farm to build + mobile artifacts. +- Provide enough operator tooling to run and maintain a small unattended device + fleet. + +## Non-Goals + +V1 does not attempt to: + +- Simulate mobile OS APIs such as wallet payments, biometrics, camera, GPS, or + sensors. +- Provide arbitrary remote device interaction. +- Execute arbitrary shell commands supplied by API callers. +- Replace all hosted device-cloud usage. +- Build or sign mobile artifacts inside the farm. +- Provide native flamegraph/profiling capture. +- Ship a full web UI before the API and operator CLI exist. + +## Provider Model + +The farm should fit the existing `mobench` lifecycle: + +1. Build platform artifacts locally or in CI. +2. Upload artifacts to the provider. +3. Schedule a provider run on one or more devices. +4. Poll until completion. +5. Fetch logs, artifacts, and parsed benchmark results. +6. Write normalized `mobench` outputs such as `summary.json`, `summary.md`, and + `results.csv`. + +The API should be native to `mobench`; it should not copy another provider's +endpoint names, payload quirks, or product terminology. + +## Architecture + +Use one unified control-plane API with platform-specific rack agents. + +Components: + +- Control plane API: REST JSON API for artifacts, runs, sessions, devices, + pools, identities, and admin operations. +- Scheduler: validates requests, reserves devices, creates per-device sessions, + and assigns sessions to agents. +- Object storage: stores uploaded apps, test bundles, raw logs, screenshots, + result bundles, and large artifacts. +- Relational database: stores projects, identities, agents, devices, pools, + runs, sessions, leases, events, metrics, and normalized result rows. +- Android agents: controller processes connected to Android phones over USB, + using `adb` and Android test tooling. +- iOS agents: controller processes connected to iPhones over USB, using Xcode + command-line tooling for install and XCUITest execution. +- Operator CLI: scriptable fleet-management tool for device inspection, + quarantine, logs, and run cancellation. +- `mobench` provider adapter: CLI integration selected by `--provider`. + +Recommended physical split: + +- Linux controllers for Android devices. +- macOS controllers for iOS devices. +- Wired Ethernet for controllers. +- Stable powered USB hubs for phones. +- Outbound-only agent connections to the control plane. + +## Device Procurement + +The pilot should optimize for end-to-end reliability before broad coverage. + +Target device classes should be defined by measurable properties instead of only +model names. + +Example Android tail-end class: + +- 2 GB RAM. +- Low-end CPU representative of the target userbase. +- 32-bit ARM userspace or 32-bit-capable SoC where relevant. +- Android version capable of running the generated app and Espresso harness. +- 16-32 GB storage. +- Enough devices for multi-device runs plus at least one spare. + +Example iOS tail-end class: + +- Lowest-end arm64 iPhone models still compatible with the supported iOS target. +- Reliable signed app installation and XCUITest execution. +- Enough devices for multi-device runs plus at least one spare. + +`mobench` may need platform-support work for older devices: + +- Android jobs targeting 32-bit devices should build and declare + `armeabi-v7a`. +- Android SDK and dependency defaults may need to remain configurable. +- iOS 32-bit devices are out of scope; low-end iOS means older supported arm64 + devices. + +## Physical Inventory + +Every physical element should map to device inventory. + +Example labeling scheme: + +```text +controller: android-rack-01, ios-rack-01 +USB hub: android-rack-01-hub-a +port: android-rack-01-hub-a-p03 +device: android-low-001 +asset tag / serial: stored in inventory +``` + +Each device record should expose enough information for an operator to find and +service the device: + +```json +{ + "device_id": "android-low-001", + "controller_id": "android-rack-01", + "usb_path": "android-rack-01-hub-a-p03", + "serial_number": "...", + "asset_tag": "...", + "physical_label": "android-low-001" +} +``` + +Power recommendations: + +- Use stable powered USB hubs. +- Prefer managed hubs with per-port power switching. +- At minimum, use controllable power at hub/controller level. +- Do not block the first pilot on per-port power switching if procurement is + uncertain. + +Network recommendations: + +- Controllers should use wired Ethernet. +- Agents should initiate outbound HTTPS long-polling to the control plane. +- The farm should not require inbound access to rack controllers. + +## Device Setup + +Android phones should be preconfigured once: + +- Developer options enabled. +- USB debugging enabled. +- Stay-awake while charging enabled. +- Animations disabled. +- Lock screen disabled where possible. +- Battery optimization disabled for the test app where possible. +- Connected to stable power. +- Enrolled in inventory with serial, model, OS version, ABI list, RAM, storage, + and physical label. + +iPhones should be preconfigured once: + +- Device IDs recorded in inventory. +- Trusted by the macOS controller. +- Prepared for command-line install/test workflows. +- Enrolled in the minimum device/provisioning setup needed for reliable signed + app installation. +- No dependency on personal developer accounts during normal farm operation. + +## Data Model + +Use a relational database for control-plane state and object storage for blobs. + +Core tables: + +- `projects` +- `identities` +- `identity_policies` +- `agents` +- `agent_heartbeats` +- `devices` +- `device_facts` +- `device_labels` +- `pools` +- `pool_memberships` +- `artifacts` +- `runs` +- `sessions` +- `leases` +- `session_events` +- `session_artifacts` +- `device_snapshots` +- `metric_samples` +- `benchmark_reports` +- `benchmark_summary_rows` + +Database records should store: + +- Run/session status transitions. +- Lease events. +- Failure reasons. +- Device facts, labels, snapshots, and health. +- Small metric samples. +- Parsed benchmark summaries. +- JSON copies of benchmark report payloads. +- Artifact metadata and object keys. + +Object storage should store: + +- App and test artifacts. +- Full device logs. +- Test runner and platform-tool output. +- Instrumentation logs. +- Screenshots/videos if enabled. +- Complete `bench-report.json` payloads. +- Zipped session artifact bundles. + +## Device Inventory and Pools + +Expose both raw device inventory and curated pools/profiles. + +API: + +```text +GET /v1/devices +GET /v1/devices/{device_id} +GET /v1/pools +GET /v1/pools/{pool_id} +``` + +Device example: + +```json +{ + "id": "android-low-001", + "display_name": "Android Low-End #1", + "target": "android", + "model": "Example Low-End Phone", + "os_version": "13", + "abi_list": ["armeabi-v7a"], + "ram_mb": 2048, + "storage_gb": 32, + "agent_id": "android-rack-01", + "usb_path": "android-rack-01-hub-a-p03", + "state": "available", + "pool_ids": ["android-tail-2gb-32bit"], + "labels": { + "tier": "tail-end" + } +} +``` + +Pool example: + +```json +{ + "pool_id": "android-tail-2gb-32bit", + "target": "android", + "description": "2 GB RAM 32-bit low-end Android devices", + "available_sessions": 3, + "capabilities": { + "ram_gb_max": 2, + "abi": "armeabi-v7a", + "tier": "tail-end" + } +} +``` + +Pool membership should combine: + +- automatic facts detected by agents +- durable pool definitions from reviewed configuration +- manual labels for benchmark intent and operational state + +Durable pool and policy definitions should be configuration-managed. Temporary +state such as quarantine, maintenance notes, and debug labels should live in the +API/admin plane. + +## Device State Machine + +Device states: + +```text +available +leased +running +recovering +quarantined +maintenance +retired +unknown +``` + +Recovery ladder: + +1. Kill stale runner/app processes. +2. Uninstall app/test packages. +3. Reconnect through platform tooling. +4. Reboot device. +5. Power-cycle USB port, hub, or controller-managed power if available. +6. Quarantine after repeated failures. + +Quarantined devices are excluded from normal pool scheduling but may remain +targetable by exact ID for diagnostics when caller policy allows exact-device +use. + +## Scheduling + +The API supports both: + +- curated pools/capability selectors +- exact physical device IDs + +CI should use pools or selectors. Exact IDs are for debugging, reproduction, +maintenance, and quarantine validation. + +A run can fan out across multiple physical devices. The control plane models +this as: + +- parent `run` +- child `session` per physical device + +Use all-or-nothing reservation for v1: + +- all requested devices must be reserved before sessions start +- queue timeout controls how long a run waits for capacity +- partial capacity produces `capacity_timeout` + +One physical device runs one session at a time. No shared-device parallelism. + +## Run and Session States + +Run statuses: + +```text +created +validating +queued +leasing +running +collecting +completed +failed +canceled +expired +``` + +Session statuses: + +```text +created +queued +leased +preparing_device +installing +running_test +collecting +completed +failed +recovering +quarantined_device +canceled +``` + +Failure codes: + +```text +artifact_invalid +capacity_timeout +install_failed +test_timeout +no_bench_report_found +device_disconnected +device_unhealthy +agent_lost +abi_mismatch +os_mismatch +runner_failed +result_parse_failed +internal_error +``` + +Timeouts should be phase-specific: + +```json +{ + "timeouts": { + "queue_secs": 900, + "device_prepare_secs": 180, + "install_secs": 300, + "test_secs": 900, + "collect_secs": 120, + "overall_secs": 1800 + } +} +``` + +Cancellation: + +```text +POST /v1/runs/{run_id}/cancel +``` + +Agents must stop the runner if possible, collect final logs, clean up app/test +packages, release leases, and mark sessions `canceled`. + +## Artifact Flow + +Use presigned object-storage URLs. Large binaries should not flow through the +control plane API process. + +Flow: + +```text +POST /v1/artifacts/initiate +PUT presigned_upload_url +POST /v1/artifacts/{id}/complete +POST /v1/runs +agent claims session and receives presigned download URLs +``` + +Artifact upload is generic, but run creation uses platform-specific roles. + +Android: + +```json +{ + "target": "android", + "artifacts": { + "app_apk": "artifact_123", + "test_apk": "artifact_456" + }, + "runner": { + "kind": "espresso" + } +} +``` + +iOS: + +```json +{ + "target": "ios", + "artifacts": { + "app_ipa": "artifact_789", + "xcuitest_zip": "artifact_abc" + }, + "runner": { + "kind": "xcuitest", + "only_testing": [ + "BenchRunnerUITests/BenchRunnerUITests/testLaunchAndCaptureBenchmarkReport" + ] + } +} +``` + +V1 signing/build policy: + +- CI or local `mobench` builds and signs artifacts before upload. +- The farm validates artifacts and installs them. +- The farm does not own mobile signing or build pipelines in v1. +- Server-side build/signing can be added later as a separate build service. + +## API Contract + +Use REST JSON with explicit versioning: + +```text +/v1/... +``` + +Payloads include: + +```json +{ + "api_version": "2026-04-30", + "result_schema": "mobench-farm-result-v1", + "bench_report_schema": "mobench-bench-report-v1" +} +``` + +Compatibility rules: + +- v1 clients ignore unknown fields +- server does not remove or rename existing v1 fields +- breaking changes require `/v2` or a new schema name + +Core endpoints: + +```text +POST /v1/artifacts/initiate +POST /v1/artifacts/{artifact_id}/complete +GET /v1/artifacts/{artifact_id} + +POST /v1/runs +GET /v1/runs/{run_id} +POST /v1/runs/{run_id}/cancel +GET /v1/runs/{run_id}/sessions +GET /v1/runs/{run_id}/results +GET /v1/runs/{run_id}/artifacts + +GET /v1/sessions/{session_id} +GET /v1/sessions/{session_id}/results +GET /v1/sessions/{session_id}/artifacts + +GET /v1/devices +GET /v1/devices/{device_id} +GET /v1/pools +GET /v1/pools/{pool_id} + +POST /v1/agents/{agent_id}/heartbeat +POST /v1/agents/{agent_id}/leases/claim +POST /v1/sessions/{session_id}/events +POST /v1/sessions/{session_id}/artifacts +POST /v1/sessions/{session_id}/metrics +``` + +Async completion: + +- polling is required in v1 +- webhooks are a possible extension + +Polling endpoints: + +```text +GET /v1/runs/{run_id} +GET /v1/runs/{run_id}/results +GET /v1/runs/{run_id}/artifacts +``` + +Future webhook shape: + +```json +{ + "webhook": { + "url": "https://ci.example/hook", + "events": ["run.completed", "run.failed"], + "secret_ref": "..." + } +} +``` + +## Run Creation Examples + +Pool-based Android run: + +```json +{ + "target": "android", + "device_request": { + "pool": "android-tail-2gb-32bit", + "count": 3, + "selector": { + "abi": "armeabi-v7a", + "ram_mb_max": 2300 + } + }, + "artifacts": { + "app_apk": "artifact_app", + "test_apk": "artifact_test" + }, + "runner": { + "kind": "espresso", + "instrumentation_args": { + "class": "dev.world.bench.MainActivityTest" + } + }, + "scheduling": { + "strategy": "all_or_nothing", + "queue_timeout_secs": 900 + }, + "timeouts": { + "queue_secs": 900, + "device_prepare_secs": 180, + "install_secs": 300, + "test_secs": 900, + "collect_secs": 120, + "overall_secs": 1800 + } +} +``` + +Exact-device iOS run: + +```json +{ + "target": "ios", + "device_request": { + "device_ids": ["ios-low-001", "ios-low-002"] + }, + "artifacts": { + "app_ipa": "artifact_app", + "xcuitest_zip": "artifact_test" + }, + "runner": { + "kind": "xcuitest", + "only_testing": [ + "BenchRunnerUITests/BenchRunnerUITests/testLaunchAndCaptureBenchmarkReport" + ] + }, + "scheduling": { + "strategy": "all_or_nothing", + "queue_timeout_secs": 900 + } +} +``` + +## Runner Security + +V1 supports fixed runner kinds only: + +- `espresso` for Android +- `xcuitest` for iOS + +Allowed arguments must be controlled and allowlisted. Do not expose arbitrary +shell execution through caller-facing APIs. + +The farm may run any APK/IPA/test bundle that matches the runner contract. +`mobench` metadata is preferred but not required for installation/execution. + +Result policy: + +- if `mobench` markers exist, parse and normalize results +- if markers do not exist, complete the run with raw logs and set parsed result + status to `no_bench_report_found` +- projects may require parsed `mobench` results for benchmark-gating workflows + +## Agent Behavior + +One cross-platform agent codebase should have platform-specific executors: + +- shared agent core: auth, heartbeat, lease claim, event upload, metric upload, + artifact download/upload, status handling +- Android executor: wraps `adb`, logcat, package install/uninstall, device facts, + and Espresso execution +- iOS executor: wraps Xcode/device tooling, install/uninstall where available, + device logs, and XCUITest execution + +Agents use polling or long-polling: + +```text +POST /v1/agents/{agent_id}/heartbeat +POST /v1/agents/{agent_id}/leases/claim +POST /v1/sessions/{session_id}/events +POST /v1/sessions/{session_id}/artifacts +POST /v1/sessions/{session_id}/metrics +``` + +Agent failure: + +- agents heartbeat frequently +- if heartbeat expires, active sessions become `agent_lost` +- leases are released only after a safety timeout +- devices managed by the lost agent become `unknown` +- devices return to scheduling only after rediscovery and health checks + +## Session Lifecycle + +Each benchmark session should start from a clean install/state. + +Typical Android lifecycle: + +1. Verify device is reachable through `adb`. +2. Capture pre-run snapshot. +3. Apply pre-run gates. +4. Uninstall old app/test packages if present. +5. Install app APK. +6. Install test APK. +7. Start logcat capture. +8. Run Espresso test. +9. Collect logs, instrumentation output, and screenshots if configured. +10. Parse or upload benchmark report artifacts. +11. Uninstall or leave installed based on maintenance policy. +12. Capture post-run snapshot. +13. Release lease. + +Typical iOS lifecycle: + +1. Verify device is visible to Xcode/device tooling. +2. Capture pre-run snapshot. +3. Apply pre-run gates. +4. Install signed app IPA. +5. Prepare XCUITest bundle. +6. Start device log capture. +7. Run XCUITest with `only_testing`. +8. Collect logs, test output, and screenshots if configured. +9. Parse or upload benchmark report artifacts. +10. Clean up where platform permits. +11. Capture post-run snapshot. +12. Release lease. + +Installation reuse is debug-only and disallowed for CI benchmark sessions. + +## Pre-Run Gates and Metrics + +Normalize device condition with hard gates where possible and record everything +else. + +Hard gates: + +- battery >= 40% or externally powered +- thermal state not hot/critical where observable +- screen unlocked and automation-ready +- free storage above configured threshold +- no active previous session processes +- device reachable through platform tooling +- expected ABI/OS/platform matches request +- artifact compatibility checks pass + +Recorded metadata: + +- battery percentage +- charging state +- thermal state +- uptime +- available RAM/storage +- OS version/build +- controller ID +- USB hub/port +- pre-run CPU/load snapshot +- device model and ABI list + +Metrics model: + +- `device_snapshot`: pre-run and post-run facts +- `metric_samples`: periodic samples during prepare/install/test, every 5-10 + seconds by default +- `benchmark_resource_usage`: benchmark-emitted resource numbers remain + authoritative for performance comparison + +Keep fleet/device health metrics separate from benchmark metrics. + +## Result Model + +Store: + +- original `BenchReport` JSON in object storage +- JSON copy in the relational database +- normalized summary rows in the relational database +- mobench-compatible aggregate grouped by device + +Minimum normalized fields: + +```text +session_id +device_id +function +iterations +warmup +sample_count +mean_ns +median_ns +p95_ns +min_ns +max_ns +cpu_total_ms +cpu_median_ms +peak_memory_kb +process_peak_memory_kb +created_at +``` + +Per-session result endpoint: + +```text +GET /v1/sessions/{session_id}/results +``` + +Run aggregate endpoint: + +```text +GET /v1/runs/{run_id}/results +``` + +Example aggregate: + +```json +{ + "run_id": "run_123", + "result_schema": "mobench-farm-result-v1", + "benchmark_results": { + "android-low-001": [ + { + "function": "sample_fns::fibonacci", + "samples": [], + "mean_ns": 123, + "median_ns": 120, + "min_ns": 110, + "max_ns": 150 + } + ] + }, + "devices": [ + { + "id": "android-low-001", + "display_name": "Android Low-End #1", + "model": "Example Low-End Phone", + "os": "android", + "os_version": "13", + "pool_ids": ["android-tail-2gb-32bit"] + } + ] +} +``` + +API payloads use stable device IDs as primary identifiers and include +human-readable labels for display. + +## Authentication and Authorization + +Support distinct identity classes: + +- CI identities +- internal scoped API tokens +- external scoped API tokens +- rack agent credentials +- human/admin identities + +External tokens are first-class principals. They can have explicit limits or +unbounded access when intentionally granted. + +Identity policy example: + +```json +{ + "project_id": "example-project", + "allowed_pools": ["android-tail", "ios-tail"], + "allowed_targets": ["android", "ios"], + "max_concurrent_runs": 2, + "max_concurrent_sessions": 6, + "max_queue_seconds": 1800, + "priority": "normal", + "retention_days": 30, + "can_use_exact_device_ids": false, + "can_create_unbounded_runs": false, + "expires_at": "2026-12-31T23:59:59Z" +} +``` + +Authorization is project-scoped from v1: + +- project owns runs, artifacts, and results +- project can access selected device pools +- project has concurrency quotas +- project has usage quotas, optionally unlimited +- project has artifact retention policy +- project has allowed CI identities and API tokens + +The same physical fleet can be shared across internal and external users. +Policies should live on auth identities and projects rather than requiring +separate hardware. + +## Retention + +Suggested defaults: + +- raw logs/artifacts: 30 days +- parsed benchmark summaries: longer-lived, project-policy controlled +- failed-run debug bundles: 30 days unless project policy shortens or extends +- external tokens may default to shorter retention unless explicitly raised + +Retention should be enforced through object-storage lifecycle policies plus +database cleanup jobs. + +## mobench CLI Integration + +Add a general provider model. + +Example CLI: + +```bash +cargo mobench run \ + --provider private_farm \ + --target android \ + --function sample_fns::fibonacci \ + --pool android-tail-2gb-32bit \ + --count 3 \ + --release \ + --fetch \ + --output target/mobench/results.json +``` + +Keep `--devices` compatibility, but add farm-native selectors: + +```bash +cargo mobench run --provider private_farm --target android --pool android-tail-2gb-32bit --count 3 +cargo mobench run --provider private_farm --target android --device-id android-low-001 +cargo mobench run --provider private_farm --target android --selector abi=armeabi-v7a,ram_mb_max=2300 --count 2 +``` + +Provider config: + +```toml +[providers.browserstack] +kind = "browserstack" +username_env = "BROWSERSTACK_USERNAME" +access_key_env = "BROWSERSTACK_ACCESS_KEY" + +[providers.private_farm] +kind = "mobench-farm" +base_url = "https://mobench-farm.example" +token_env = "MOBENCH_FARM_TOKEN" + +[run] +provider = "private_farm" +``` + +Provider behavior: + +- Hosted providers and farm providers remain separate. +- A single run targets one provider in v1. +- Device matrices may include provider-specific profiles. +- Cross-provider comparison happens in reporting. + +## CI Integration + +V1 flow: + +1. CI runner builds Android/iOS artifacts using existing `mobench` build paths. +2. `mobench` initiates artifact upload and receives presigned URLs. +3. CI uploads artifacts to object storage. +4. `mobench` creates a farm run. +5. `mobench --fetch` polls the farm until completion. +6. `mobench` downloads or receives aggregate results. +7. Existing outputs are written: + - `summary.json` + - `summary.md` + - `results.csv` + - optional JUnit/check-run output + +CI cancellation should call farm cancellation so devices are not held until +timeout. + +## Operator CLI + +V1 should ship an operator CLI before a web UI. + +Required commands: + +```bash +farmctl agents list +farmctl devices list --pool android-tail-2gb-32bit +farmctl devices inspect android-low-001 +farmctl devices quarantine android-low-001 --reason "USB disconnect loop" +farmctl devices unquarantine android-low-001 +farmctl runs list --status running +farmctl runs cancel run_123 +farmctl sessions logs ses_123 +farmctl pools list +``` + +The CLI should be usable by the person physically maintaining the rack. + +## Observability + +Control plane: + +- structured JSON logs +- request IDs +- run/session event timeline +- metrics for queue depth, run duration, failure codes, artifact volume, and + API errors + +Agents: + +- structured logs +- heartbeat metrics +- device discovery events +- command duration and exit status +- recovery attempts and quarantine causes + +Alerts: + +- agent offline +- stuck queue +- high install/test failure rate +- repeated device disconnects +- high quarantined device count +- object storage growth above threshold +- runs stuck beyond timeout + +## Security Considerations + +The farm installs arbitrary mobile binaries onto real devices. The security +model must assume uploaded apps can be malicious or broken. + +V1 controls: + +- no arbitrary shell command execution for callers +- fixed runner kinds only +- per-project and per-identity quotas +- per-agent credentials with independent revocation +- short-lived presigned URLs +- audit logs for run creation, cancellation, artifact access, token use, and + admin operations +- outbound-only agent connectivity +- scoped external tokens with expiration and optional IP allowlists +- secrets stored in a managed secret store + +## Rollout Plan + +Phase 0: local spike + +- one Android phone connected to a developer machine +- manually install/run generated APK/test APK +- prove log/result extraction on the target device class +- identify `mobench` changes needed for `armeabi-v7a` or older SDK support + +Phase 1: Android rack pilot + +- one Android controller +- 2-3 Android phones plus spare +- minimal agent +- local or development control plane +- repeatable multi-device Espresso jobs + +Phase 2: iOS rack pilot + +- one macOS controller +- 1-2 iPhones plus spare +- XCUITest execution through the same control plane model +- validate signing/provisioning assumptions + +Phase 3: unified provider integration + +- add `--provider` model to `mobench` +- add farm provider config +- add artifact upload/run/poll/fetch adapter +- write existing CI output contract + +Phase 4: CI pilot + +- run farm-backed Android and iOS jobs from CI +- support cancellation +- publish summaries/checks +- monitor a soak period + +Phase 5: external access pilot + +- create scoped API tokens for selected external users +- validate quotas, retention, audit logs, and support process + +Phase 6: fleet expansion + +- add more devices matching measured target classes +- tune controller-to-phone ratios +- add per-port power switching if pilot data justifies it + +## Pilot Success Criteria + +The pilot is successful when: + +- CI can submit Android and iOS farm runs through `mobench`. +- The farm runs multi-device jobs unattended. +- Results include parsed benchmark numbers grouped by stable device ID. +- Raw logs and session artifacts are available for failed runs. +- Common device failures trigger recovery or quarantine without blocking the + whole fleet. +- No manual intervention is needed across a meaningful soak window. +- Operators can identify and service a physical phone from API/CLI inventory + data. + +## Open Questions + +These should be resolved before purchase or production deployment: + +- Which exact Android and iOS models best represent the target device classes? +- What is the acceptable budget for controllers, phones, hubs, mounts, power, + and spares? +- What device provisioning setup should be used for iOS test devices? +- What retention and access policies should external identities receive by + default? +- Should the control plane be private-network-only or expose a public API with + strict auth and rate limits? + diff --git a/templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template b/templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template new file mode 100644 index 0000000..7797c53 --- /dev/null +++ b/templates/ios/BenchRunner/BenchRunner/UIKitLegacyRunner.swift.template @@ -0,0 +1,163 @@ +import UIKit + +private struct ProfileLaunchOptions { + let benchDelayMs: UInt64 + let resultHoldMs: UInt64 + let repeatUntilMs: UInt64 + let warmupOnly: Bool + + static func resolved() -> ProfileLaunchOptions { + let info = ProcessInfo.processInfo + + var benchDelayMs = UInt64(info.environment["MOBENCH_BENCH_DELAY_MS"] ?? "0") ?? 0 + var resultHoldMs = UInt64( + info.environment["MOBENCH_PROFILE_RESULT_HOLD_MS"] ?? "5000" + ) ?? 5000 + var repeatUntilMs = UInt64( + info.environment["MOBENCH_PROFILE_REPEAT_UNTIL_MS"] ?? "0" + ) ?? 0 + var warmupOnly = info.environment["MOBENCH_PROFILE_WARMUP_ONLY"] == "1" + + for arg in info.arguments { + if arg.hasPrefix("--mobench-profile-bench-delay-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + benchDelayMs = parsed + } else if arg.hasPrefix("--mobench-profile-result-hold-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + resultHoldMs = parsed + } else if arg.hasPrefix("--mobench-profile-repeat-until-ms="), + let value = arg.split(separator: "=", maxSplits: 1).last, + let parsed = UInt64(value) { + repeatUntilMs = parsed + } else if arg == "--mobench-profile-warmup-only" + || arg == "--mobench-profile-warmup-only=1" { + warmupOnly = true + } + } + + NSLog( + "[BenchRunner] Profile launch options: delayMs=%llu, repeatUntilMs=%llu, resultHoldMs=%llu, warmupOnly=%@", + benchDelayMs, + repeatUntilMs, + resultHoldMs, + warmupOnly ? "true" : "false" + ) + + return ProfileLaunchOptions( + benchDelayMs: benchDelayMs, + resultHoldMs: resultHoldMs, + repeatUntilMs: repeatUntilMs, + warmupOnly: warmupOnly + ) + } +} + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = BenchmarkViewController() + window.makeKeyAndVisible() + self.window = window + return true + } +} + +final class BenchmarkViewController: UIViewController { + private let reportView = UITextView() + private let completionLabel = UILabel() + private let jsonLabel = UILabel() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + + reportView.translatesAutoresizingMaskIntoConstraints = false + reportView.isEditable = false + reportView.font = UIFont(name: "Menlo", size: 14) ?? UIFont.systemFont(ofSize: 14) + reportView.text = "Running benchmarks..." + reportView.accessibilityIdentifier = "benchmarkReport" + view.addSubview(reportView) + + completionLabel.translatesAutoresizingMaskIntoConstraints = false + completionLabel.text = "" + completionLabel.accessibilityIdentifier = "benchmarkCompleted" + completionLabel.isAccessibilityElement = false + completionLabel.textColor = .clear + view.addSubview(completionLabel) + + jsonLabel.translatesAutoresizingMaskIntoConstraints = false + jsonLabel.text = "" + jsonLabel.accessibilityIdentifier = "benchmarkReportJSON" + jsonLabel.isAccessibilityElement = true + jsonLabel.textColor = .clear + view.addSubview(jsonLabel) + + NSLayoutConstraint.activate([ + reportView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + reportView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + reportView.topAnchor.constraint(equalTo: view.topAnchor, constant: 16), + reportView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16), + completionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor), + completionLabel.topAnchor.constraint(equalTo: view.topAnchor), + completionLabel.widthAnchor.constraint(equalToConstant: 1), + completionLabel.heightAnchor.constraint(equalToConstant: 1), + jsonLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor), + jsonLabel.topAnchor.constraint(equalTo: completionLabel.bottomAnchor), + jsonLabel.widthAnchor.constraint(equalToConstant: 1), + jsonLabel.heightAnchor.constraint(equalToConstant: 1), + ]) + + runBenchmark() + } + + private func runBenchmark() { + DispatchQueue.global(qos: .userInitiated).async { + let options = ProfileLaunchOptions.resolved() + if options.benchDelayMs > 0 { + Thread.sleep(forTimeInterval: Double(options.benchDelayMs) / 1_000.0) + } + + let repeatDeadline = Date().addingTimeInterval( + Double(options.repeatUntilMs) / 1_000.0 + ) + var repeatedRuns = 1 + var result = {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + while !options.warmupOnly && options.repeatUntilMs > 0 && Date() < repeatDeadline { + result = {{PROJECT_NAME_PASCAL}}FFI.runCurrentBenchmark() + repeatedRuns += 1 + } + + DispatchQueue.main.async { + self.reportView.text = result.displayText + self.jsonLabel.text = result.jsonReport + self.jsonLabel.accessibilityLabel = result.jsonReport + self.completionLabel.text = "completed" + self.completionLabel.isAccessibilityElement = true + } + + NSLog("BENCH_REPORT_JSON_START") + NSLog("%@", result.jsonReport) + NSLog("BENCH_REPORT_JSON_END") + if repeatedRuns > 1 { + NSLog("Repeated benchmark %d time(s) during profile capture", repeatedRuns) + } + + if options.warmupOnly { + NSLog("Warmup-only profile run complete") + return + } + + NSLog("Displaying results for \(options.resultHoldMs) ms for capture output...") + Thread.sleep(forTimeInterval: Double(options.resultHoldMs) / 1_000.0) + NSLog("Display hold complete") + } + } +} diff --git a/templates/ios/BenchRunner/project.yml.template b/templates/ios/BenchRunner/project.yml.template index 7ccad1a..cdf35c5 100644 --- a/templates/ios/BenchRunner/project.yml.template +++ b/templates/ios/BenchRunner/project.yml.template @@ -8,7 +8,7 @@ targets: {{PROJECT_NAME_PASCAL}}: type: application platform: iOS - deploymentTarget: "15.0" + deploymentTarget: "{{IOS_DEPLOYMENT_TARGET}}" sources: - path: {{PROJECT_NAME_PASCAL}} resources: @@ -33,7 +33,7 @@ targets: {{PROJECT_NAME_PASCAL}}UITests: type: bundle.ui-testing platform: iOS - deploymentTarget: "15.0" + deploymentTarget: "{{IOS_DEPLOYMENT_TARGET}}" sources: - path: {{PROJECT_NAME_PASCAL}}UITests info:

oG(jS4v#DQA19d`OM)-Ra`TG+Q$3)GN-#-;oVh|IVUsYpR%mdP8>Cp2#KOL?>}(JU zH1XNoLLYvElQv%cO8Xvb>_SU7*iIPhg+v|y4ydR7PlSdZEkW*gw7D+a)R+u=dU=-r z`Cgj?V{84m7wnfUnURgqWjpU`X^q>mJts-1J4pCIMw<42 zwiRHR8(79Y2KV7eHOk`7RUiZ-Nr}0*CcrS3MI$$fL>u^pK*i|i77=)f#?3J>toG$_ zElJPC|DUZGSSj71Mlsj+=M<-Rk-?u+l1wai?+oC~<$b*VN2Y<&twY`QaWR!B7-&y{ zNU=X3U-j)Y>C|hP2>b6b1)T1-3_(J!_4g0ghC%PBm{ra#8~>Ae`C+ix>=qHQ^TJl4 z^D1-UqDH1-HDgum;1(=Tw8Pl2LkifD!S>7%zJIDrw?rRnc4qHoF$-Sc>ZbOeXsHR zw&2g-7#8cIm(&jsIj=9K2(IY0g9}=i0i>6a>Q@?~te2S58zLm$I zh~xP#J;?@OVRVhD^z4mU-%N|x&2e#Q6LNnG3F%;FGbzp0J0(pNbid~z^g(|R)2r^c z37|@s>ijU7C)MiTpC)-5*C!<|&ZH~ohvC68ISIrECCbH8iO8%Ssj2DdDCmUpIj|6> zOo6M*D+hZ!)bd_Y@nTk%SJLsdI5oCRW@qG7mg0f`i%3Yk6vF?}TtJFrY-HC-UDMJC zD%d@oBryZW;Y<1VAUzvvd6fu4bg-h&5(@*Ciit!M=4XIKL_a^k|BiV{7!*zKRFFHUK$#H3mmTF*p1{kxqz*}l z%|RF>=;3C&(P4I#PId{X)SCGLq5|9)RQvQG;=SqXOeMJM@5nTySl~PRn`R&Q*r>6~ zUSl7SpnEU|%~*MTsg{w?ZTRJ7rr?w9)ng{o$)Y9mudT^GEbnI(152&Lp(H&mK_3lI zrv*XjL@3{+#^&#~Zlr%ad6h31Iy(>Ht1z3|-<)Y)8bTE* zD!4#WZVJiV0dlt>;IEMwWPeMz#OnvPBJ#==v9en0Hl03>!*uS9AK|ZxM9=;(^r$CZ zXdCIixK0R5Q=^|axZ++eS1qYX7w|so)>ot+bLTfgs5V{q+1rd6Dvazz&Q4B3MR}pM zaJJZJOUX*sc5`ay-{4cTNk4Z{2S>fN;TdT# zE^CQcDAlNGXNnlEiEr*o=_BwS(G3hge9G5w@_O~ksyi+n1=)HpG-2x<_upQI#(mch z(j{9mU0__m&C`sN*r^fr`UwEe98TdzWGS5_WF(^{^4&D>=`M|F7wV4(1A3}*JR$WC zG8$5fmPX{JrY0uv(5U0(dFbc_q$nv8435xnu~;r%2t*f0vRW8(nR>{lRsJF^neu!@ z#g4~jHS6&!#EUoTWG*oyb)T4E-e&kS@$>hKJ|x2B_A2|M`<=+c=i3&yav~zFbYyFt zm8+G(+;cU^Jj1gWJ&VesL_)T2s6`hWw+b$I$gia|$q1kGDj7=OAPjhn#T>>Io z=^z!X2}K^BYhr%X03vw9j1uf9;-`kwhVI6%*qk?Dm><5DwMUuxS;RuC_9_;aM9i{V zyZy#6$M>dCxl!U}xZ)C>i+Xu6rvmq#d7u?7Xy(8P^XKd_zbC!cpoH{7<6*@EXE{(* zgol{6-0E7tvWpCu>cduzWb|%WAM%7^Yze2?IXtP+be6ic^zXt-AbIjiUJKPMqf_hS`sA3I5cc& z04xM09I%Q?x1EeD&020R+tC9L3GC^V5t-tbh2-l@XtHeodzan02Uh7^a)T2+nQ-4_Y zx7KY-j!ERPAH+_V`(##}(ItldAa81NtkbaH8_25n3muosI<_#1$;JGn*XKmL13@g} z$H=Gq_(3BqV&=aWq&~_>yS_{Hdf)04T|^)2)E;d@jp9~-k}b;S^vgAT9%V6;=CC-r zKgo#YRNq?w>)t2{fCu`^ZL(}udFb+coHsBDZVSkrtgP{E{u5SYX`c6L=6mm9@BR3{JBGs-mynp?*fP&@K}j zX{se!YMRSF(Ti^8px6FoG^$wc{Mdr<3gJ%ABE(+*UH-ev_V<0r>}J#12dk(^_D%kL z9=fA(4=%pZupxfYuq2(P5Bn5Ja)jTOa&1Ok`j*?=?MCmXSKnGJEqzPEt3Q5mk5{A| z9T9%%SdIaNy+yOFGM^+Q8^1jl)S-OZ+N^arde{GA>L_$&n~S>z0q?oWM zIXZvbmVKsL+J;l^`bHZ(4?GhiFR{T7Gt33siH9 zSh2c~H#c8#Z~snEl+TN_T(PL6`<81hJ6Sni5@frUEuY^->x9e3$Ifjx$5g{wx;Zx{ z!6FoL@gC_lB7QY2^X==vDVsUj^q}rWoFa4=CL1&1Q%uK!Q1<;~H5R!3u$PA<$fvd> zKLe7BZg;7(pMC{F5X)x8+gz7_YOYxOi;K0p);mobtWDGexwIe<=+s$@$Xnjj@Z|Q0 zhyMk_|2AmvNJvSOW?^MDV!=V+u34dukskIOBYqlRVf0ajCK~-@l_*Cov#k>eL%*&Z z8ektWV|;P72@08Pit@3g-f_DibwKOMI*U7Cv*I>849ddwEni#T)@e4t9Ao*^&+D@J z!jnNIdp%!Cyh-y|t1ZjX@k@A^jEwBI=Z{vwe3_f}cgELq&C~d7Zy8m#dwXj3ifK+U z*)ZaUVUy@5t5#NzPr_fNE$j`?GoVyICnFE46!&@^T^)Z#0n;j1Af{q?)+N&cMmQXP zx#3aoPY&xBpUdh{P90CTKDUvm?H)3U5SZiZVYcw5*k5F6yR5U_8(J$|93BzwYJZZb zl?^NTJ_DtZVn_}4sYIy6IqN>;{VOO#^X1`~4LoFo_vU*myX7RR##e?jsRx;x5d6U?~=Ma&%k{lAf zvlb^%Hl8IJhXm?`%bTV=;z^@I^wX|$kLWDYsLyE}$3tvp?T!|l34o<#ob zJUyjwG}o5YYqce`E=GIBlqn5wWm&Lg_NHaFSwt!~aPBMlfum8m%^Mw?6ZuS=g(D9X z^t|t=5%O88YHl?nNw%;CbvZ9*_#mLk;_JIXQTV83gABu5(m?yImvK^9Oxf&AWTr+{ zQPbY#Al^3bGh&Ltz(AFXtMfVZa#1@j9E%r3bn!^@33p_ae@ESXlpYY=$L<B4h0rdU8A{4I(uZh$(3O*(DsLu#_IF7XaDzuL-R+(a?v;|k4S){JJu zom@MrDu|m+Z)4(h)bZkc+i_-X{N`ZYfxq0GluEjmmK^aSj5CO`44%yDV;I3wTiXH0 zxBS@yPZ)pLxgl65F%x?OPqU_XfXRhKxSO+j(cU!R=rgN{yI46#4?RRcUntiDp~G03 z?D5KUrBe{kNU|R}1vz}fO1t~sT-O8W9uX0qCI*O#D#oK(wt%QxXs#4ksGgR}2^v>4 zp|pgmcjKV6AQ#Ja`|Zt0pQMG9hI17Qlxxo)%_Eu*w{RA07S?fGVL%+v-3S2!f@0Qb z136oveJNRqBE=S8)wDX}t#xr18zAy$%Z&N_~gI7@|pk{H=R<34; z_nx0FX*7#})-__%8khAl01y$&^Mc%`%c>M)o|^{Sri}}m3_AI`Pvku9ro8%vJ$1WN zWCA?<<3|S#Oqo=N?dC!`(@EoNOklz}Wa3IqrvsSd@65#I={HY5D2Cf@fLL>}*Au|X zo?W0!LG@N2hWXmjm@);~Q%w7I2DyT=jR&1}d5uJNBIxc^RaSk1P5`Kg_+$z~@pA#DBzcPh`S5Qv8QX{XTQOD?|aQw#;6KF|)f$~Kl9 z>nzJtH@?g85^pKW#jAs{Kuk{vpMjTpf@7cFKBUeLaLs+9%^`M7Lyk z5|KfBW@whUIu!?7sae<he7>f8(s-b3|( zU)4I_Xpl!iNo~S)UTiBT%AkXQ5dEbS_r^?7^oh>86E3_zx^=&bVao7xE(P~@vAF(~ zr!K!T^R}c4A89({qNsO31*sUBl!E?nNRHg_bqMn|kDQ{}!%Y%V{E@P^XOLhMTwVYdynA&ItUFUR&NGGV zz2f3kCRkZ%{rYK#=pkbyB)IxgKvHA9LGbPt4u$u-YwY@l_R>;*H31U7s_9Rm_pAdX ziy5iuo9p|%<|;g895f`$T|*10j>a%?JSog3w)q-YZ^G|hkUCzRVu+hDkJjpB;t-!~ zu9K>rev`;f{PaPIhetyiHCBn5Fk3;r)X3oyXml!7T>^v&^}dG`6y%L=uF11s+-Ym+ z_%|0Jle?&4zEa>b{lD6N$1;um$S&W5kNd;Cj0ZRppx3`j^t(UWn!wF05 z&Qako#tWmMz(3$SIt(Y9==xcU+8&-1uvN)&`vIwJp1;=d7|6M*I`!UZF5EG zZFo4dufR}|E1EA)@%+Z_9$KdeL#vD!&eiw}w&}qUdSJzma%is=)dssevN6OB1mJva ziWUaYu!|n&Xzty;#iQbcvY)uc-{L9iBG3l`6Vx4?ouiD$H`XE9pV`N^8!@r*gg?xpX+M4apyF64WzqD!$z0CAK+YHUD_{2l| z(0E8#wejAX(BXzuwdha-EjT-pd zczjeVeP89-{F>PR8zbEgt3C^h;&NInh&SXj9>Ir`1sT=0>S$sf3JQlUo})SGxoqKA zD(GSoub$TI=i_ZI6~-xhoYl{GnKwJ3=Q$D1-jF6gj=NcBiMJ)o0F!2p_`e3J%XVP~ z!Y6KqkGfE~7k*hg*YVDRa3e0dh~GmUK2BiAWh7C1&Vo=sazMFaIiA}mMFsT22(e5j z9jlCUIZwA@mjC_e?Va3G*@I{D@NjMMO7Juf<`+S&Wrn&% zOa0o2$NC51Y>SK)5}qmKFWUxy`5&u>PL{cp7(3{Cf_(|}p!L-asJ%Ql28PYTnb3$4)RNaStEgBWuQ2iB)?C4) z`z3(}TOxaTVT)T5>Pw;f7FSWlVgj6fMBd#?1dafIgHJAlv%KG)nU|J=ewv?5=6ogh zg+>n?Zr;kgbf6{!X1@aD;XCa51GK70ngss@>i>MO1{?(bHT5)T-K>Wa*PcAyL>e2P&-SJ;ofTt1Wpwv*(_6%e~azN{Q>63WV3)vch7}#6?HTgDLP_%?w zBR=NH!eq7rz=9HemvG=sGhE5qo|)4P5{urmMdtm-SG3iLt{gYLq5NnPcgY68CfQ)-cQ)%=arTYuvNPn{hjjb*t$A(y3x?KQ+N4iiu?4N zM$zZflmP^ud9!RHpxeV^9o6VQDe2V>k^SVo5u!yG#nX@k^xKg|e21>H86X8}*LvD< zJYN<+9@i8$p_m<+>1|e~1t4m#cWwA=rjX3ye5``v&sd0L)ca%Hj{h1@U|Wn7&XjDTnB;A8)fcxNo07JKhlcS`@T-!U#kv^-7nh7%-X|Gm~K2 z3*+|X%8%jVI*L&;XAGGNT=akZ7TuBMaTfb4NOZi+zf{#}TS|k67%~uoY?C-@Ic0II z%fmcCu67-d4df`qj}H7jk?Vr3^CpXZO$W7OD|Y`ij_v{6M!g$$Qnd(-YWLq=fum3f zKN!M}+Fn*Sor{Q{p|lxf|pH8CtGOaXVQv%xcns=Wn^W zK3c0{l}#$4QC2wGTR59Y)VHpV<+UH*y?;_NSGcm}i?F2E8)G@B*v9vzx{k9p2k2-*N<+yPygLJ0#{QDh zRiSA*6?ZmtpxB>n-r^p6c=|4-8%i+}x21P?KYLrd=5XI9o~scWP0)r5NE9-Q&+HTN zz44N-G4z?wW9meieNi^P8y9;gr}c{UtYwpy%Q7G!(gFd))akl z)bxKc_FnrZ;5z@F_Dk1KI_pzB3Z&Z{7QH&JTQqSFSkj0L6I!khyna+!T7q*lP`sCW z-_1F=KkZng8}|OqAjo})F*bktkQ<(uBs|B*B@WkFPZF4!{aG-Nhr;u&`ONqv(g@!7 z3X4NtbES@aR+e9^UfWE<2zxa?PS7h&p%ecS^UPti@}B}kOeLx@A0$ZPbUh#n+Xna- zAgg1Hgqm;qdyId@>cED0Cxv8q@k)rhD2cw5;Q93wPsr!xw%m_ZNVbogFk&vO5+(>p zEIpObpKr-vkq74n&JK!WUkKXaOz&n_&ri$5=2BA<-DT-$1*FhDli{X>cZAhUj_MBV zPou#l#>3mCGtW_*!@4O|iTJQK#&d8-VxOm6Ff=-70;t++tFJUR$@+an%w&^!lCA!K zZ_fZ@q$*fWp55<%W2493vszaT0>R2{v4wCU+}|~w2xQh^-Qaa^>7(B1pObQK$*3DetHaIr;=dwYc%A&i1JIy+_JFAK37unZ0D2&t zXGsI(6n<7gg{^#f%S81X*;?90<%~@dvx~Z5&z{69L!%EzRFD?kBND<4)<_?DtuK6c)*k?&k2n$eXVR^xLd?cmKY8~Y7xL*A^c3KP{@*L{Jhk#zJQhvv zUD(nrvD3X3$a&4NIDn6a09j zY9~XF?>qjx0rr_s3;_d}=(C4{7a!LbvAceOhbMmaKGm9hxp{e&j840AC{T0QUDs06 z2k(0#)*IwibvhRB%g`nDYqoC38d^96*%ZM!b&{Y8ne9gUieN0s5JD}!~Cli)7P=}03R>O#8FzT!psSa<`o1X|N+G={E) zof2rrpXYb+UKJBM@9Vp6oTv)f3G-W5UO=YxPQg^ zy#n4zB=KsmnMzWosGT1n&T4bXxqwTjN`A215Jr3Ar2(ahP`wa)jbJSPT2-_uC_~_G zwFiR5ZWWMTw=tEZjqfwrMgw6*tm_+Z(!jkXH!ccdT!t={vwnaCR`X~dp+S#mlStvS zBq@|%#8fkAfn@U(H8;$d=W1*aVC<@_IEM*KKQMOt{>dhCK?{uLjeTEy(EEl)E6E-e z27h3l*D|Dy{Xj;Qsb{q}CnaHd{nP0$6DY_X-*?gVfz523La6d_l;YVz4o!dvYR9n~ zFOl3D8JYJ$U37YVcNSr=zpPhi$Ew#|+>Fzqh7=FrT8QNEpnO+kVb`NFwfOaBU|5Wj zM1h14=kz7&pvI~yru3(6!3D?5Q&8cGC9k>t)$G#_Nu~?g#t{t$&|_vY$^gv=;!sc< zm=i2Sz zTzGKblfQ0f+vV1&qUvVNpzrd^jrmK-N4wqiqJrs)wqVtMdz_B1bjogmqoRG~o^@AT zkOhHVvRi5TrPJ9lB>rt1%hQeoJx)gaN3YDpZb~e=wI|g&?#wC%i}o*4CA{CbA(!iN zi-ZYq0z_PnXV>$rNe(aD50?BkVa@!E1)tuBGc~iZsM~-0Z3@DR&}tug$X)rBu~@#$ z^Z>SleyFiW|L9w=B@D$G7A!C(gFtg7+VsE+JDa=2EW5fj%jt#^Sk6p8n(oN+%%xXJnCD&QU6YBGszQFs&{ zi3J#>TQU(Xvin%b*-FajGWmH)`qmV;&dj-MWrk)&$%`J^xI(ezR{FFXgLR*MhRg7Y z_{n2&n0eZiCfsJELBn+v6$|l@IqOkES4~F=g**#e>v4$FK+!nv=tsT(W&+s6{3QJ| zBhkbkmA>^3hpw3jP@2G*tMXYUO-ve(@!99P(bnxVzxvM7RETHkW8a13_YwN7dHp+8a!^h7o310YYy2b+wRj5+Qq1kq%1R*cx~ zDA<4FB=?jll~7C`7!{N;NPq_Swc&hLH1UIj{r68_2zWwnU*n}8rs~a9^MyJ^#m#Nr z1OMR8PYq}fdg=f^2pO6o#n}OG&)JaAc8olJdnkXnB<8=>Wz0f=UlTeswN?-k8ABD= z6fP}I)JR>@1eRsl1lhDeDk$w^907PI(o7wmoZKK!4pyjc<|rb5B$*FljtrFi-7!V4 ziA{VEM;LOp5$s|Y4*?VS|EsNI^2peedi&#rN!VFPNm$T7VYjSqOog^HYjyB5^NlI) zZ9maT>L76oQ=-})b9kLbL_JS zeqwxq>DcB4q2a|--}>dF1a1T6^Z!02uG;6@NQ(6N1pN-<>Cm3t#D|w<@`*mbfb#pN ztGa=qbkLIx-fO2vBF3lSKiNWJn5)$`<9$xLZnj?~ZK;b}7y%Y7EwlO;aE)OH=>D+x zTKqgkqe;t-($zRmrX2KxYW-glEAE4`<$T~oagWzbZS7!L?%auhfRmwozNrz7YP^(v zAIhbvym-Cwzr6s%Y3DIe%;*Yr4MrcR-oPm5BY=KqR=H-0eGB)=xTE|j&=gGI<*&R~ zzQkKDCpE?*R&)H-@?RM15-TVFsH|qH=qo9{Np1goAy~yUpb(7&!kK2NbHi-{i@0y(t`|8jbD(}$H3CSthFHuU5cG8Vn zw=7D6^?7B+r&-}YmhSHvks2rIbsa42BwJWeYvS_Bo%`N$E)K!OZ9bno!DK{7MaYBB zbqm|8+h+}PIpy0wfu@VCp6)}F4}an|-#wszTMkTcMwOy*L*F6Uw_N9k)8`GBl(8XF zLJRYzmmGr&Yju%|^OPkJDE;Hv zo`w>mcd2_z*qN;NnU$PNG5<`pH6tL8j^6<-*1D(|@4p5J8>?SPgh)e|y_A>l4rT)Li*A9-)&4vr@wO!YI=rH2C%T|YP z!R@0vLx+#gV~Noi)Gy%W=qFqfDD#b$xl!Yx_3do0>Z#Z9WvWBtpue)yTveSXF zfQPuCoV9Wft^_4OcQdfb%-9_4PgmM*62-?}Y+Z<;1V>#ea%s_Gz(KcRpf}{pFiuuO zgJPiCAm6dv`=dQKL}DwrZG4^mrpBzskuL{^q!iZqKtw%}v}fzkQnva{odK%z+^35v zte#&7&Ei4$QYvP`@=zHW{X~4#P1;1LeIatDx|}_#%Wy+dCp^w4%7wh%FI&?sM@7;8 zAvmd7?22B8hF!VDF-ugC@uKMFQISkPkK5fpNvE(0h&$VMjUE*dao;Sp1SdoFK6IOn zZgoV*#S>?NKt#dLBs8e!`>fWc7ojV~(77KuwvDvyG~u|g7bp!g7Yw&YwodUtkhL?s zFK7=CsRr0cFJwF!gjjU1Mu(qk!yBDe%Qaplmx*++UXJID?6l$7jqBb~XN#X~0i4+; z`DFW|N3Zjh(A2Mov&IdNba5|0zDWCJ;7)|XcYB9OQqLdg%4aQh_#8@P$MD8tV7px` zlIT^B2nbfUxj&v-(ma)jb$7uQQh}Jrk;LoMk-P{~K@?sV)zZl=3KvmZS zF4SMXtQBf}XVY1g9Vonh>^qQ9+7CfD>m4`V&SW25H&KpPDujC0cEfVvTq~NLW`e#a zgBQBI?A;cWOw{VnsNy9B>(iW$GLX8FYu|Hu$0TT+W%q?^HKK!(KO8^6lv~bFixj!& za-ORgi{2@jRAAEB0gyw!d=JQS;g@wl5qKH7eCnhpI2y*L?5L29*m{p@Q3$+-&$_Bx z`Nh|K;&O#U@U}Jm3rYiDmq+@As-^kOBLfcyeTxqPG@DhR+=6K&XLFHT>g9-*o4rE} zr4?&VGyDapSPOmoy+{4nyh}|g&bldr!j2b@UoZd%Ka|Wef07?JrQ} zK_n2?p0C5&oo(PhG2f2q%W3nNjjd zCk6)SkU65}3JO3WjcDa^3J%U3I`h*cF3#xYdS8xz`6g)33NrMb!_A1p?5&h7a)*hwYoz>{f5U#sTz`cYY(Nrht66htac-` zkw5koGtnUkKZ@?oX%dFffil`;+-zFkPd{-Y#Ikf97D7t$6=jk)p9QTNzlOjz? zj`seOPX13Ay~KKVJkd?~;@j%&yBhmKt(kVzaO|)=$*9gCE8qk9W!bPCl<-a$_L>y6 zD2(HDOj>Z><@|Jzy#DCU^57Y!X0Z zDmdBQ%rRxLs8GOvpI31L{oPvm&v25{-WuOC{o0>gj`c;V*+9lvX$o}9QCRRId1Y_T zPk&zQ_*8ifgQ%+RL6-mM6PgjhQVw_VD$AKa(V6YSGhnWE$J1j)(Rvzr^F zf3y;4TI+08gQG4M@vTukR5{`=~sx>TDZuWE&xi2;VjFD|K;|w z#0X$4hLp@aA0O`g&P71_ZwDynHad@Hj@}vZWmegW=i2e6^Un`fSMq!S7_G&1*S4u- z!kEonx-b*tytZ$%D7^{lr+H-7ss3Qc0^2y>PWnvs#7OPv#&x>ia00)E^w6$}Jw z9-B`@zd~FeH*FOZQ?%F;XvwJ^W~LuLgR%vy_cOiN&!^+***x5z9t%zUObQDHeQt4H z{D-#Hnyq@aH}rXZsYq1P*FT&zStsklQ$|Dd>b|nD>;y*-$UQjt?p_t`qs;Cu!QY*& z{XGvPeS)tuZ&Hs(*6qpW4H!TIp_g8a=5_#v=KwJQ|B-sB-l&P7EZMXW%x1X&HfdU) z*ZSE0o$A&szyl|@^=zZr@oqQ}NHF9x9%Qr}r}s_#_&WUHpwvrNs%LVxkdcuoCgct8 zS5QQObwtT8yeyXwbpVLlhD5M8UP;in=xnFQNm`%8EvNlCD!?+$E2Xae5hAji%G9WEa)}X;k;3W>s!g_hdfaO^WS!8U!*DywZfnY8 zo^DH~(VidE{fM%?K(xS!Zi-}q1ipC2053UXq67+SV0p5!2em^b8|HEDhQU%&P-TfPDcQ(K~o%iD_0-ODTGe;IBQAZXdelHu7D22)8 z3ZF&eF}t46XEu_4L>XnFD`?a%Qny-A58c`6yqMpvUe_nIWP?hlin)r}byDhr@BGPF zZ|hQ{#u=X2D+pM(b^!7z`fRa$eAFI;jSDbibRS=o&$>en2T;?a=c(*i7Qj#3ySpTU2XC91wgLs=S#PiQa1V1jUjl~ z91RyDZd_;zg-7mNKRD=@E;C=(7f1avSY<&(Jki% zY{R^6W;7K!5`5L>Jt(sfc%#l@ZF-C*qE)M6dEUX9`po$Hx`Evd9)`xbC3-hEx;AqF zVAlDCem1B6VcD6eMOv92mK#xw`&IAeZbozYHuJ}ZrN8SRDMTt5krw&Fs5U{0Qh~Mg z{1)T9k=$2zRfY4`tDml!!|8j)ae`$t8f~}Q0Vv$QbV;D=?c)Q{<<@n1dAZ;I*iwfp z%%J_YnVFKs#Z;_ra0H!p$?^J48Gxy+)NG(ykz+Pty~Rwz>yK%EyJB(I+if6YM8j*V zPGfdE;w6gl|G(6E(CWiS(8&2}$i(REp|fQs7Av-`oO%53oDrrehg6)lZ2s4sWfQNw zpD}nu>%ZEtHd<|K{dHGj;-0bv)~+BIJG*2t{ltm4x#UGzya-Lk<_#P>Mh80FGM6Y; zf!p^t$9>e4(W`P1*c)+`V^E-bL0`v+u3W#(iu>jd6WMwZp<2!-xE?S$TJr+UUi1v6 zLlq~hmvhLuV8K#67js5)j~H5oZMC%y6VjmCGs4IZKmWO&R(66>2_0Q7O*n`6JlwbO zop9L^9UO4#{ffRVpix7mh!}d*(x1564>22c-ET;|J;`OpzRyFs=WX}tp(HL=8uR?# z=Hb}@nLoH3%7^^UZi#PO_y!J6eNq31S)2^9csxGZiClIy+%KGR-djcz^!6_XScM6q zHGxhqZEx9n`JzbA3(*WXcH~zL?Oy^G^EK(BG%l_#!Pf2W?%6VD!$;^jjS+8-QR&Gj z&`1orraf^FMJSNUjnd5FAx(VBgu~0we7xjQzl;0#WW#g_WC?9eprdM_SV}Oeo?ccl0o9Hn2PLBuuWKi~?q@2GX8!o$-B5>NdGN=v%tL7W?bqw1_raLW?k{Q{k)fs4=d+Y)HrKqHP;Cb06^@>QWIbx}-J0 zz9oZ*5M2^ulWR)&~ur$#;U_{%?M=qmqppNd3RlMR1}3IFeqD6 zynA$`J)^d&wJ>4H@x{YD`Y!I_JE&6eAek2XV;%m`{MQaFArhZk>ks+$Wh({84SmU4 zu)*GHZN6Q6E6TO3$&Ve%GXrj#CsFw*r>FHsq}ac2V{7pffc2y5fwed-d&Oi)&nJ(M zcl6r1r;Rt?qf2y5pSqMaf7@WQwj>SS zY%A2ocGp;QV=`0txk$e0k=abI~6xZ-gSkBw*#5}FKFb6zRH$yeHD6#e>z56w1ydwmn` z{z%2n{3kKfbKY$h?#@mRhWui4{|>6C{UFmn=-YT=xL+a=~_*nV(|+&&99RQ-;bQ zS*?jU5&>BzuI$kWR>NY@AZD^UgZlTX%(rl@eB0$*S}F;*%8cM}1aPg^OMeV)3>04d z**gP6y&%Sn8!;efpjjxTbc#`itwkL7(Khl zbXq0>o(URLpx3`t&!D!DCdb|2=NFLc9+nXBK|A6egE zR{i(>z}E5zzAUv@*vm92ncO*_qNiqIA$*|OQAto*g22jck={dda{E_8KkBM@wsz&m z*ZmTS(xl(n9|tqMTTWU$u#bLiMC@~@*-*F|l*>a)Q7g-`=dJvaKWd=+R2eI$?Lm{^ zSaN-ibvkO4rC3Db9U@nW{6&1RVowiOU5l^lUGX!}HK{KzC#E!${3C7v@8vkyY3mcu zmX;FXjQWaUeLqevjkrbdJ;$n=2zl>L=fE529pL@um$m|}YLP!z3C+CW5Sptp+J!d+ zYYYs#XP~XLghZtyiE6G+4v+fayh5IHeKO&q-@)uY^myf@P^m@%V_2)r0ZTWdjGU}8 z75pu3UOnuY#N(5-dqS7yKsXi8Z*h6>?!dAMHTd&6*)Y(u!L>qDvq=muahU0N4-qa^ zWatu;MM+tBSmiI$M_Cy;zD_S%r%D)}H|cr_qOox9pdp>-42n+I`%pswy=^$2H=A4K z`o*83)hZ00fpcB3T57ZgTZVT&U0!6SCtA+!Y|F|=z?~L~yyYt7og7S{_Rz72v=?Bc zgD06W+dn`7e=GWQU8_vd$srOLOW9ib)dtuN2w8ntiwrquAD_D#xC{2<{a|@?HWwb@ z-LGe}*t!E;*~R0dDc5UuvMIrLIqScbt7iDU+!qMAKMJ5nPT;B^7rEJSSg+**#t1{9 z@D7Tx^;w{k%_Nr|3O|bYVPI@0H7(_1T*f!rv8koLo`K}Nf(BfUU$a1*SFf~0%5P;0 zpc~?!OrFSwg$^!Vf?Lj!ql`DBTusc1bH2I^^c>Stuh=l(zc)CJkoA>MJv>tI-VI7J z{oAxC98iWe^3f7)7E{N?(e?e_HISN&HoE=QT4~I+PCTZVU?|t3znZ>y>GzZpl4Z4^ zCovheVrJ=eN#x;J7?C|a-1+L4>T}Fzk^yD&x0mlf5tu%pw!qRMK#ME58D z0of6o!?!2jXCnK0sbh?HbQ1xyuQn%hkxIqaV22FBpDdEwxFwK29_VQa66{#FBuZbY za-m_?5XD~^T#4HPD=XC@xX}j`{KMv*DRK)-eK$uhZExwE)0D*DxSz$&W6=R;o$xlZ zw>y5F%a;Y>9J0PXwTg`t9;0008?Bt8(WgtJ3bhj={Je#e#dxf6+evEu6)=50>kcia3?qby8Ye(BLTDgWW8i*O^XM9A@BtxU%hVPqTG@^8oqM{bC zh>1%mRj` zUU*Dgcrc~0MJ|;LMmjl(0MfGa^LbSZFbJ5pBvz&68kXIs!)CdHV>#4=t@lpeF;EFK zlj9DXL`Sbq1tl!aSvN5Fqrcv7XZb-NG-E!y-ud3rg@W98P=Vo|$nuMEXL|CuiD|(T zEG!G&K@E(ftJ9mwqiABjvZeI)CQ2c7!Z4&rb}$B|u;S5Xyk<>%Iu30_t>S1GLNogd z!24*Q1e3!E&G~MDlvVduS1DL#0EJ+!dvv-&rL9vKyc-okY%Omwb%nN~BUKKB&s(~r z8pjGxJgf-;zun((EPlA$4v(cC8`)gd68UOg_xGRDzV+=c;WXg0YsEjLV#!FK+)z}A zl_<4%dA)rz@jfAZvE;C^0D)>24NrrH6Xh)8cij6TRq3c>e_Iu(DYZw?0D#Xk9Z0Uo zY4mJLwU;E5(>-2GH$>$>?4gI;{UZ+=9_TXBLE^3nk$S1lnJZ2`k7c^*OH>6a`sSg| z+(>$Rs`1fHa8GvX6OSYr{V!oDhyAvy5Seb8Qp12HrovwuJ98$o-+U;*k))Tz7rLuI zjeBPCdwz4BQdc14kRgxqCLfw5=u;8#lg7g5Ip^D+7f@4U&g-S<^hFlj#G=&gMxmJ! zQ1wkjpV~l2aAN z6QsF_d*U|Jq{s1n6ptZ_Stnx;N9h}q1-X4D_|fzJCN(K;cYEL9w7-6rtVMe7`@j?{ z7E!TSj*e&MI5nuWBNEH7CSsF1mrS^l*rG&~Mt%RH+#dNVF0Ba*)#)lpT39C`xCpQj z{2!Hbd*SV}Zj*{eTPpvPM4uyK01k`(ZFSFonngcT;)12@Deg=05zi(U^PmD+nyM5e zeTA255Gudh-(5P~)o$7`5Po*u`DTb710EG)4X<6Mg+fdGm1s`CpRr3J<7%l&!(+Oj zltj~uU&v{TCCX6@!m}jUVqd63@hT1M$#T@}5uekf%MYEsr;$QTNuF(QXwRca!M473 zkO9oM@)~OAPa%tRvatVO_>m+3o&5l4w#FJb(aMHA98-Iqzv!Wb9EJ1%qjvQs)0G4h zztkmBuDEy`!1L!bB*ScRu2ej)Csaj@jJ+vZE|Oxr^FQ*QaK4`tAmYl9R0fZcGv_rq z(F$_KR-Y_Vg$)wab<~lKD(FZ_me9py6Kh_&( zfow;|xl_lsp?4%nRFp+y$L2^fkzB48XUy^BH25uU!4(oEYh z<0l`x6USKQs+GMaNCzeS|LVe!4*X6+RfE;W-v)T;zO~8iP+jNECtT*bgCTO+Z%%>j z5A|=ylN(-V@jtgF8zhU1e`-^4ne{|gvY?=oMK7`uN}%pWlDj>o1ExyrV8_~)HflqN z(tm*fV%MvKO0Sy{Fa4|)De{Sj0%0q2m+OPF%26COC)p=QW@=>d7!F+E$+m<>?cXgj z<6P$h)}9Yr`#vZwX>Kdpn4-pe(&YtQt{oW#&tUVE*qku%?F7hKl zWh}L=#>NU-5Uqs<1MX9Ejw4~atpDmfL%L5IiOTsAPYnW`R9l+=>Q}=`v}5(Z{C)z+ zwX*n73^QZE{ogwd+gruT4;$7IXulGTrd=jR4|nl;sEqa3#y_FbiNwH+`pq$`a0Pw`do1u9N+iAh`N zqC|QAq<^Nj444`4Dk_Zpt3A=#jGgt#jiBxG4KD@KET4RJdi$=SVHyR66jJHNy}-oj zOQB0!+WYyhL(|7^>rO;}es!GnDyt`otfajsUId$(^Y}O&?xLLH1wQ-+AJH-ZyGI}1 zH&l)^1q-!8orYq@dCPlmWQAKE1ll)4i!XR~>sNNR{(X7;kd{;*`!-gT&A9Bgp?UO| zlcpgd8z5A9I;~r_H%Vi5Z+%wMs)uoRNi1yqVkR6_siHLB*4`})$Xy8SKHSWt-&o)y z==&`?J3U^G%t?Fxxa4%$3JmXZh4TM|uwpYGJN-JUi4@Zt9L~V2vzVzYV}nt;MEN93 zV=(&668%+~Bnzaj=kH8Xiionk|Ea9y`nQN=y{5@VGsev#jF}ly$%RK34Lgkx6%^~Q zd42pJ8E%z$qNrXqR=ip1_vWT2G+L}817VqosShg=)D>+vB})Z?d|Ox60-bK!MRQ3d zDr3B|vvoy&es^;LjBf{fL@DP-l_@+$n!%OiJv0Y084|A6u;4lop5bPlmyO&Y%ATd!FD?6?4|wXkL&VM_lZ%b&`0c$JXKyB!05|)};&}JPRl_x>Q%v?g4&u<+_{ODwML$;=nAw5mYYufu#?9n)$~m5WOo0i+#Dnfz)!+-`-xE>LgW z#e}}Vm?dD_|8~i@23R$C!a-}x0`sxSl`4JB7-~Isy030eoxfD&gFG6ZF(~=lho@@v zMqClV!@UAc51;!oHYN}OBaDet#His!TBGsEoKF)eHGv_$=TO`$COeVKdKT)d zOf_(evn~Mvgf|zHl~H~O;c|Fua&8r?R7_PsUi3tT(tHv`$;{S@-jwpe8$~yq*N0LZ zG{d;V!XOZV4c$hFBy6mN5cjU{^fDhB6=ID&+>ZA9ucM+p%J_)tdo2ScEv&^?!qRY* zohY5?)>|?XW`8aU%;%PN&9VR<#yFvhGW_G)p0L;uT?RmZ6pV+O2?rK;dG*6Suc;BAcz%IfwUnu$jHW2dRDHBtM=?i1l;z zD>D9SlS#4UsD10i(U2-{$_4tbxEw|TTmi1$OOCJOZMtTJuz}`By+Nxq8GW+0g8!M~IAU8Qhih9^%{O10+gvhHB>dDuf~ zRWa8leV4$g@Tu>iNdul5M^i*QNzdhes?mwDbqT&j&96HCMpz`TNLaV5mclQfm3c;Kg3q{N%|^mme2cC2$xpa>yZz z^UdGbXmK_1c2?=bZ2&Z;T!oUagaX`+X6Y&vKK57KqKLIBBb6PpjxI;{TOYLNO-zlU zlc?A>J5Y32*T$~Uu^$}w?3|dDl=-}0;JV&w(u2*k9JhnSu7JTns@!7sj>iSNC1l~V zwR=Dmf+O(;$bj_vb3@;SWq||s^H~D4pBd*X_vhgF0rO12-BdJN5q*`n-MqthJz05J ztis1$VkFDw=r}j373~uYBP?zC?B}wN(~s{S5}|xg!6-Luus1j>a?fl z`R&m!uXyww(rkhBi-Bou5Rr{mEin`S`cPlPM#>$DqC-VtnkusPgaop!c6Myi{?;Df zbrcwmLam;fijVKsg769s-%TN`Qn_-cs7VlY;n?ta!xM=U z@VJr&f2W`#D4^gc9CtL(Je(VV-^Fac{+7jJ%PV*7qWsfWeL6b!83=rY(|HMhYd(vt zBbbbrzu9_oi_ zciv^Pl>g0V2r#CIM@uO@+{`)Tvi?*o*0JtmcXip-E%7zBS6u9;DeKFD0A8bQ*X}@S zj;h?7hmel!Y-G3@F<~Ud)jZf~uWa_-`s`2W#;g6s{TAR-6t+(j3&_?#wK#zA$p2N=R^?pH?0@@qWj zGe1bf%m69zUj1al*s7A+7~2aYe#eYBYrI1E(y3LglNnp{eDLOi$Bh7qdI_d`dhm-8 zaroZoNHcbCl&ZkBc?8+#KD*Mimq{I-%NeJikKXDBZ{yiCp{%Qhkte)G0$8=*a`N*<@TPC?=YN@55{zuFR88L1p z_vN95^D-#iZtqK{^$(eyFn$4>mWI<+M5;gjWsJF;WOuBet~zgc;cdI_iG8QmpLQ`9 z{r2`A<3Uw?QW~{yLU*vEWDUp-P`WC}`Mv@lvheT)msee$x4lOqUIfO-(dX-pDcOLi3ut={b~|Zkdo-{CNu!b=M88XgV`838x2Ls} zR5S6lCAK+`P0`fXs3xJci6D=h~cm@V}Zj+0q} zRX`u_@V;F%s?$?xhL#ugk^lmX3G za``-GG1G;1fLI#B48sNy3SW?{cxJNGJhz~BfrndQUxDjLsDy(-^Y8Y>uinKkSC34c z7hzlCh!0I_4A)KRt4DTdYFmXKNFZx;T6Vc|YS;hXIqS&fFZAo%L`S6CGb?^BlO^X` zK;Fe%=iC)p39L&9e|Rp|UVM0RAeXRN_u|UKYU|26{W33WqxEN?UgDDbOi_QM0Oqc8W}xc1nnVrFBiw?ZsL?CzN`6Y_su**D5KU+dk(^< zQf)|Xfu{BtIT?EEy5w_)|m_=)$Pd_QxF!*K zV{nxyfM#qz=5Mp}Wa6d>gw#@%2t6C9I?y0EFJ*s%cSUjhu&j4iAHdUKww9@1qo|+8{di?q5h_BjSzU z?Kl*F*iQJ46VPiqLy(drhDPjOThojKX|zbswlYm3uGd6S*dt!8v95+lKidXWQ5%+O}k zlmQ71?bY^_E~R>ONm-|1UBbq|NiB|pv5D^~x9wl<})A5l=aXL6MGw4N-2HD*2;Sa9}`b=$I zqAgT|vo&>T%fjf`kfZBeeSO2o$WW)xR(-T0-hO#juHw=7(6B6=LFGzLY}hS-o|ENt zSqwch+2HChtf|L*Oj1qS-v=2c0#-{Vd%Akqg#l!-@-VHopJxPF}72Dsr&xh3deYwQ5 zX_W;dzqjD#tDdd;-MCv@^L|2cBHHBYtRv`(hd;GA3w%4>!;qp6g!)*fD}8f!Ytwo( z@zCIdd-vdKc%g~xL!X%4$(TH1#FiT?Tw(MWKc&G*{Cq=nwN@zL$HLAw0ub68Zp6VO zQH~TuOfLOdb0eCWMuR(6Ty66jTxPi0RVF6q#59A!8Q`ZP=PKb)iKa@c=}IR62UF>! zJYLU!W8^6*hAJ$@zwwG9qiy(`k~USUk(A;o5RR=?7>)TreV?aO8CjO*m8`xnI7b#L zQ7K{g)_Uoz3*)GY3<(+cBT2)GvSeJJ=;%iC7l&MJ>dD)nKa zM}=+^kO`aW;2+FaQDnz~nY$HjPt7CCtHGy>TkP;&BAgb+#=)wnq5S^ zTS~>@hVo~pl0XRm#sPQR+UjYd-v{aWps&yGYQLQ!Ab1K@BB@QRkg80RC_Dt)PhpY( zYg`EC4@U3(w-8>Db_)sBS9Pk|vZtgT8jU&D8w7fdGu0rz%Z+ zDTHs8<*6RyzSo-dnb3rK3eCpn*r`xJP^tJH>tr=U*p5@lc%Rl*WTHz=wwIxPMDvl4 z<-M8#-R!vPkN>*Aq6YTp7CkoZcGNIqdB-@?NV9-sE~oAlGJkp70JWNuBd^|}J!w^S ze5P!r{fGgS5#|}@)&Z9#B+$b1Yk>~adn^c4%(1zPlAJH5tC+7YpN}h-^h1kICI6u^ zv*yy?m-N)Sb7a@!(zu)V;Lx7N>Pi|PeR_JE#o?B!QeP?#KMNk0{5-VbaTL$uc4K*$ zhsX#_C+oS{jo-f&O7MNST?u)*N?#R^RSAR3USCMe)km4>A+mDRul5VPoyC&5-Ng-R`B(O0dKx_A`Bg{gTaW;R7m zbf1LFsi@nDKYEIv%UA8%Mbj>-^QkVYX+hD7eB;kbY2 zGR~JJjg&DBmQo#qBpF-2A>X^ySmhHNrmM>Xt!a}}3BU8@b+kJF^3*HWqSPKQBw;I| z8r$z2|CXkvU>5`zbFsbaO>3A9qJtL2kZ{JA$&f}NXSyp6kaGEY@R`*4 zv&8>SK*i9I?a~RwfMhb6vP(=UG2=7rzfKZsB+82KqDgQ5`rJ%awK$GVJ1#9b&yr^( z?VPEqBjYvyjh{(+odW|?8IJSX5D_>le#RLj9d9e0{=hp9&l?4$Z087HQSK*p&1nPk1+ zQm3ahVzU{-5^bb4Ut79o-o9gL-a$06w=XlOE~Mwbd-vh}jMoBL_IsZ+g_`Kw)sK{t zVu~Duj0RQjc=-w3Q2H=sjUMw0JiPf!P}973%IjOdxE|+TP6|*+pH2Pug}_oueO?ZS zmSnB6=rbD&?zo|uTGwGDFZ(S(SK%e%MLBYbo%x}nBDc*$N&IznLY>ajR2FldOR1m?mWmZp)GDRn34sIkay zbf_}vbM@u=YY{cU6vlN|0|mP<17=00n1bU`H#Q*Nkh5bWi6#y{3FsHptdtKwO|R0D ztPh%>{D%r3mtkAA)UyV93^pjD3Sr52<-20)13npW5P5k*yIyGOG%9?eS3Q2l-a1mi zV1*mLt1dF^=>x-eYpg8o{?w;)mI3L*`3PMU@f{LCM$0^2vAhWTKqTa8 zEzuN-y%Bj15$~iZ{mmm5f3Jq=KQM!}zxM?<6)>LRXEjuOAFbalAgW3iL{K!%1d*1j znKMq*_*b)gwL_IL9*ORNrH8MtOCfj>EXY5dX?if(4U1zz1oELGYjqLFMtrqohTZ62jMxyrt?#)9r`F6aGx3L~FT_Z$lYfri+_N&@&Q=O? zY0rdHb3FWYK9;T)+(gISe){n!t8;fKq)=Pc_Ha@>>Q`^s^2eSmC)V)wL5f7P%3u zmIxk)Jr8enBAz^rgRQ>fvmCt_U*gTdHA$`ND-@Xz@2wu1Zi?I5 zhtA(2b-PJXSa+KdNvrE#`eG)HH&}EyniF&LKgzy{xIDZ(ZcwgQ^SqyJow-_ezH10p z)OIxGH-bZQxGseo@Uua>BscQLOjVrePFeGwWX)AHJ7_vxpUk3ae^@j#yDqzt+0eRn z^0&(UbIhF^(VygBRzU(GjdRqo`2?TG6=>unA8R^*8_KeC5tak4h^HKd(iD<8K?g}n z#8iFC{U*Txj2EifCz3S2sufh#K*+n?0)q+Yn;a5MI1#{7RT04iZOz-Pp8xV^AM@b( zdvwA6Xrs{ox%SThpI{!81PzP@8krO)Kw@ z!|$`3p8WM-T|K864o#|)hxS^R+h3agSaesmM!pZxj4|IfsQD}SLxC)??rxppmx(MK z$MXBJu||z}noOeUM$^{nrf<{!RtELE-*Y7pqg+i})w3yeP$WZSha=_3RZIPlyz;Xm zi!HjoP1iEbfg{`O0{RMe$M7IyYQTpJQMq(*s-J9P#-bCr_Il=BN-fmP?`8lhxl*<1=#u>!s$ebUVa_Whpi^NrS|(8L23HKi;Xg zB7?7bvXIvYb{y;PRdP6N_|?CHAuq{m$WbG>^Q~HL`d%@&>;1;H;m0bVz!8#S7vi}J zZUWBA{Pa523yqq_I{1oarXQWIHV$y8n-q5LpB&fAU-d@B=gh{rF^=5MT!Z|XG-DJN z;7wSuVErK6)w$}MCS_hB;@`?dusTOF;%=o6T|^~v)D>cqv$M;}tbP-o?GWBIS6%B- zjV6YOdiIk)r?KVzleXa+{pWNkg=KgTcF5^+R>VsN|(|J@C@mh^AZO>Kz)IyhB@D zIUG%k!X&D|yrAH|nyzc64PTy_act6sR~*in)ks_V(2j*%w&L91xAnH#@nA-|e1cSb z^u2xi9?$aI0B)pzSd)`h9sR~g5|Pi#N+&jOO)_)P&93Q*nf_*h3B6ih*_G0@LcRIt z>$2}_ZR6Y8DCt{*-@eT|~DjLzn| z3OtkHCtd@2U%kp$DVWz5#hXalC7BUx6?uk=m6FAHrKjyW$FJ%&{|$DX$fAZzFkPIB zN_B{fw*?X2%MA0s>E+2{P4+{NaB?&~3?*5{ZO^0Gvse08JSWDg2McsO8O&M)<(_+{ zZM)?EiMxj;`Is`jQP$e01`5MOaQfm!yWoS6sMT=j)2awz&--QNvavJwTUIhmgjH{9 zjgoNI?mlTE1dlH%P;RT9T)X=+krXgaT=?cUulia)CT|D2-}SFoMj-F#;b`YjzAUx%>walfSf_0USZ|59T3vB(}Y z3nWcGfrg7rUVDFz`2G{71{eHWH%@4;5#P2&7_kXFrHZV4++c65C?4fc&xB}Tnr7qj zXFNUR*RFd_R^b)SaGTRG=M--C>N~pYuSA(BolON8TPenP>D}b4VE5=2-=L5yB4E4< zIT-Wtf!yx_#zx}BZNu#mrnUI}SjODm0qPw1@aXT`W8>>+<|{C({NC-kvE$_B_b#*6 zaDUIb`3j2MK&9_wO_q%eqh(rkUE=Fvl&#e`Ec=DI(82r9HuLgtQ)PsLtx5S8r58vz zZoGL4Mr#xqKYQD|*RQ8Lj=6<&jYbZMx?)%-(NV_DrDDSJxH}X7D1B|z4V;>vVk0J_ zq|8)^pS(%^s7VL&Lu%xILXs?EV2iLplz(SvVec^KryB zoWD+`ByF7N7Qe*Ou45-vvei!MumR|0!a7JdT?&bz1{JkvUbJX8TW4A%^v#rJYdwC@ zqqpS$arG8#b!|)2XcF8lxVuAecXxMpclQ7Rf;$U$C%8Mo-Cct_1PLy8?)RMR^F8+m zgv>SP=pJ3wRo&EoC3A(NEq5#;^}%R{N|3ma>tOLx2LJ zV4dHe8}s;*Gy{{)fuSl4ta5IXik2ilYKS?)iZ2&t(1;*a11}Mc7^T2)X3j<%D?Y2R zH*lRdRr$d|lwq$D(06HME4T_p~Vr91TEjjG;=2 z{ikxuL03Z-H#fVD8dWiK4LXR{W9!Nr{orfBtM+BqEOuh$SvUaCE50b<_v5O#cnZWL zS8ouWlTctv6Jky^1Y-n7BV~v^H*03T>%bY_Kz6N*<|BZh8OISx@Xtb8SAcuDzpX8l zn;UYE4?yXWvFPuIpNqK)AAB@^pvu4o+)ev?6e|8w*_=a&!+dm|%hz|pi2C<3gT5a5 z!Zyvo5!)F4%R~aiGFwsj3?qzC#U*Cqa_8CMI4)1?aTyuY^MSoqlmr8FZ$HwMxm7j= z&cti=9rC_qh_AB*Z~ehHyQwOs>Fd<9pv=+q7N^7n$0W713nv{Ck2#`UEqsaclEs}^ zfId|#qc!?pt_u2kWhOrb-4^_j*C=)ZE>H8&4D`hErbx&@wVih^H5}uMA%>65{1?^`fF0 zTL}H#zCXrZEPP?Y-&TrL4Jv~BzU>p{_KqbAI;ddWXE|Q%Ux^{2?qeIaSD(nmMYsuL)YE3z(boIdtakI8%tLck9jUQ zEnBtX{Q;IF{s%@!phy+HGGsm-oOmf~Z59^YaeI7f;y+Yi@O6H7GVTb)wD-b~O9kDV zmAS>QXiY0yGW$JzVIn}BgS#H_`2gZMTR#G2LR;0UuI@a1%<1#7K3l`kEnN50?3keU z@A6yxTS7$2`wkm_f*T_>}D9)jW@jL%0Kn$=NA ziBAlC*%7Dz!OT$-tyIjU_|4u$f?11Rku@$(F;lbbSonM9dZJ`AS^CS>h_Oz@GtKwe z?(oL*=;hKUj717=Zkii|sEA>V$fx!;8_lAVE@mN)kw8sP|f1R#n$t-a-P?18t7^%wbL?&zB%RLt8A6ZH1lb*i+XwcasRNpX5+1_IT~*eOO+mRxh}%8PIr#;gq|XCnOn73k4(tvKJb3|M?e=Mt|D8hf}j%J@?Pwkv2dj2 zfvV}2m4jTV2o84f)t?+aRM@?7w$pmELC^oWz2TZ)l+||z8BOfeVRK146VC%?z$VZ{ z*w9LN!iu$XVZ}hD3%XIWmX=d-xh5F*amBo?i@MsvwK-jniL~wHc!wi-PnqffWU6bi*6=QfEtnJa}W1tAL4?87_ zywS6KbsInX$BX>|m&3%`ZH6~oGhnXV?{ahc^#f~N)5ct0h9V8qYR8kmu};uMep%l`8+c{xI&H z<0SzNmtYgDcRGJ?;#rL`I{_v$61skN;fB3AH@R3wX`ARUUfKy*2xBcih=jI#z}NA- zR?r^6;m~*JETj}<=XZ)IA-p-!5nyw2ZiBKV>&VVZ0{hNL-dtVBwN{WLNz*vLU`972 z5*+R3nk$>{Nbv1-q$aMmu_Fy$rQ>wwM*RGb44v(06Xn5vpeYPr!Njf=nm)&&bvJ(t zY5d58n2V&4_1W=`bcx;Vt<_J&`DVzJ#kda(wyPVbw^}0f=cL3!9L5De{w2;z0u8O# zDzI@-O~+N7+nDXzTe+ax|+B>zP$c)!2T(3 zW+LYbKD`$AK}e;HmD{39R5lLwr{_wkCV5J2HzhD^Y`8?kD|wm3rZ!A3oOP@(Mr&7X zpQiRU%KQJRGvMOV1c|xP3F=#Muni{{0b(A0DE?Lvib98T9d0= zx%yCV!pH~VjA$AMA#$J4@7lkT7m+_=idbLJ=*XTGrh@5|*HSc&Q{DIEMm6+0!@{oL z+lRmkYH-Ze`($D zB0~GDL8wMr*fky^_ieDm7O}m_>f!V_W&x>K?q|fn7%er))-6vMsaHSa`Sa|$8-6Gh)^*i5@ zjMY}@JYB=Z;+K-f(izLZurGAxqOxfZ&}YY$-BIL(6-HCllxFcE0qdLXY@@= z@7P{|qe9y&q;5PlI9u6LZq_s@x3cc)eGfm_6!i(-B!j&XL@clwJ9MtV#pc1miuqky z#N6F>6EnPV!Bfx1+uidk-yyxYt9D)Nk`fC;R7WXjOo~;}jHx7TqXTAa}O8Nz8 z3i$p}wSwnC_xW2+ba{#@ABPM(3Xd@TS#d`mvuxL-ATQL6|G09Yku%7G%i4y5*0?X% zQ@r%?cBhawy|K7+B#3$TGYfZtHG$HTVds_f99jkN|5>-fHlZRvlhYVK zhD=*$SHi+PkrY}QgVb(S#)>m{hA`HqrjUc_NTr5a57R={nDq4d#+r21_KOx=5Ya^c zut|NL*cwFarPsyUW^>!~RaIxk%JTWiwA)FgjUict|K?n+4t6xn?xO!l`2CIvlKY8J zT!b?E|NUgqVOqSt7}610>JE8^63R&$;}!HG7@j742!o`dMmIITd*`LBmi7oN3ba7n zT04a(pbs|+Lz=f%_Dzm7-%M^C2UsuupCYw9Y73N*#Hy_J^7L!6&LURcPn@a!piXNH|w!Gq`0Pj~`?+klvsJQ#X+B8{V(HD?AH#J;0oH zz>z$%&&0K&sDR7oXK&WYB)Bubd$ZJdAxQIc7NWoA<+~5SvCJqoC)sNQ-EuTcN8{J; zGAws$x5L{1;9622jZ>^tCi@u#VdygXz3Rt#Vo^eM%d9Ap{3iD3MT!A>oGPrdn^)@R(6p}%*$x)?D3XOx^sG;bbTtHGkjk~ zJ;gYj#tVno*xoRMy&je&w6FF=>%Xg=T2DhaMQ{ViLXO_K!fb4kd3?g@n9@!SOS&y7 zu*jzjU^VP`J&HE|asdS_NAM?k8;#R^DlJB!z1o275I76RtTSREg9_Cj+l$HH!rnxN zEfj#vEk(0$B%wM~Bl5qQ_pIBme62L!O3JW-r5gM?{6S&(NA_N0OUPwEmJ?DK{GT#0 z&c4eQ-;IuD&Eka@?&T#(MK}mBd_F5=4&RErt`>`9{2t-6%tU-5sq}^Cpv6-NmL@+d zv*>9q+m-zOf4njf>gdH^3h`~=CkrF{_S9rM5<|pit+U*#<+=HIBJ9^eyuZ<+@DxeV zVrj8>Iv$3#=013ZXv`RY4ZMzW_{n+lgN{kb^?-(~L7V-+OA!vrMK*ejK|LjoOh$fN zTU$~es;)?!yv2QSm5--3M(Mj?7dU`eWmX@>X3i`%9lXLJEIG-6uj;-uM;Dr9qeByF zY5!^alqzSInXJmB^c!oI<0fr3?O(WxZoVuC0PYkTU8j6wM=w#sav^s98Rq=E&v?BH zjQj)Jw4n>U#uvg3s$yb=m?>0uH73zRw5J%PW0Taf=-&-XHfqsrN7Gt(D~tIHcPtx! ztNFzA%~;E4C5L_knh;ZSY87dWGw@eg65OwCk(Yebq)1Ea0Ap`f9!->k`h@qS!#x1t1o@Pu(KTguy{B0R! zC(rj>;=9pM^}f8Ad*Y}l$UdTK^I)m0Xs#V@X{Ae)qBa##L&BuS9A^85jhfeCgogWP zV?AmVfiyojFOr6pV(G&jX%@fU@na1yBhv9{?kpEM;}-M60XNUl#9KDe@S%cQe+_M? zG#%PVmF@MMl>DdKKk9yWWh9w}ksSZhcM&5IB)zQ4)Yq{6wbV(BNU>vO_Bv;LNeR@l zQzK#D@~WDm)4D>7qzdb9&wN=(_4L!`)=izgbfsg8!n0#As-((OD21+M?ge=7e70yJ z^;iQAH*gS(Nx41arGITEzHI%YtaFNj|8w}B*?m~bN|p%=bNCL+zL7)m`*hyaBJmJ2 zEUdQ_pBC$XD3+*PrZm8%X`&&N3 zDZI{i8=bbr$TlV32O(`h7qWb)CRIL>TgG;=jOY}rtydG&bhb{ZvE!p0Oh{At#cA5t zxzLy4mHZnE6+<$Vfr~T|JvOYPLW>T2Ey9;u6!kbxtOUWe(PQdDo@O9pcz#94UMOg6 zf8L}1WVO_rR6$3MLAT|(3sf(r7BdU{J9#}nGeKIUz$Wn+ZAsz*pxqojgzWor!-b(9E#ywn24pijPOCxF!eymN6x6~D}!CXo4^Q-1b0ABCRt08fvXEqyW+o#qq z2Ch%cYM43PczXB?TztB20azNIJp&7j@r>YJQcE1>)q|7tnS?r=I$fseHFxCZ^J8lE zrHlWa>O1^SJYlm&Ns!TYsA0x-0uqYu;cv3RAM8DJBJ7Cy11E22(f-bTrzczMk^>Ah zdWoNi`qQ}Lb!XsH?|BK9|IxVYY`)VFxd~^fp@>6)(&bvJ^`7Rgf8!UQhJG-H6pfg6 z)9>+MWDXxR10>vTkMQX3wVU5^x~&>~aPqwOUtE=|y$fNF=hpG>rZt09p#hrkNb`@U zu1g{F_Ta`2E)u7An>M}<3O#*t>mGL6fQ~xia}ph&3=WEz!*!0n0JxWoJVbVL9uM*e0U^8EPntPM8UfJ*@P}`bV?H6psoUIzGUY&O{ek#|! z=!zH_r~X}LR50>l7Ov`k48ulUr_yJw{bGzsDH(gNZn2woxZA!&{{7&~?$yq20-zMz z#&YkKYbRIGmn3*RyyTaKss}4jp9>{tmNIK~0WnFeX6c;5ACKWl6YvE`%fk&K%vo=K z_f_32{X`5+l#P-!A#wWxX`rsjwJ) z`?bcuTdVfoGqfiU8VnD4ZAp)G{c$D4$V~rOi*p@+C!gJiA~ZV;&juf0Q86|UE41Y! z{J^bkZvV5mUKQ_YgzuDzWPTYP(3a&tRX(25nd?@->#hZ8~WdLh+OC^_}+$(jG&B3#ZQE^ zkNzF3KqbKacZkX-ySK(}s9<&thhBM#fSiMinu$&4Qz^wy1(8gw*9{vtHXd<@QQmJJ z?{2mZ1HQ6`XIq$t1#|VGtorO-Y%Wq=lIf1OJT@wR-g)qDcLMi(Ulx~Owm4AcOSyC$ zrq+GYM44O)dL8wQuCTkKZP|S+P5AKXxdU{qCpRmqIkLPymUNx#J`au(H)L#%{oYI6 zJ3~as+?{jQAn-%YNjI_49|=*!m0cI3)b{2XCy*Xt7{a`3-NCHAnXNbwI17$>%`*rl zwzFYQzJ-%Nn(NI{<*IU@?1FUH0`pTh@OHRvQCx@j+KpL*^oVd;TYt=$nRlUjY=g2VlOM!%_5k}Q z=J74<$Sn#kqOU107UzeP6>t=Q|s?F`kkobWU zYop)39BC(A{gk^n^Z2?+&YIb=_H)*8<|yS=d(lu~lJEY@{HW+uds|V4@#z#QF>p06%9cr+5#XP={>yB#RCeS zob3Du*8u`|pt3{ho!yTxSsq#FuQBmiPL43ra2}=qTh2+Ys5Tchw8F*qX?yattnTBr zqxECa1{M?%XH8>TqJJ-K48uh=c%&~DmwYxiG z=xA%Rr1eR%+wnSqI#W%0-6^}Z%=BT%S@15&+c@;!=V2A>wFRRpv8jUI1$1h zNi*1tsI_7@2N~D>kWj-8@0q9RYvU^Ns@S2$Ny1e#rcBnNZyO5|>Qeixn^YfK?Jz%8 z{lLnMOJQ+7<`UtvDo4<%$+-M&*kdnN8e#U zQobMIW+c=% zVBu(1wy{it+?#2nEZc|fI@L2!tUDFnnHAD0+yEAaDB<_XGZ75+s6b!4^-!N1X_!*} zSTK zXy3SEPfM!Xt<&TE(OjfLFCX57;LYsJPGCA3M$Y!GeO8jt!80aQFXnNh21s55$!f}4Jy+5s?U-TAGdqtta28& z4!tZ%RB>l>%d#E{70Pc=2tadrfh@MDWk0rp5`hwu;~WKOt$@L@|GEg663B2vqx>@t2%*8^TtZR`NYG8+enzW-urW>Obbvm{_4;N zFGH4vZ{JCCifY#zx#9#$ej22Uw1}g?MvE855hN&dn(Un&AR%3fI6oEHIN(h?%t>JO^y7ZRy5x2KD1V~D)Goh=4a#e`#o zcI#kpe1Exs!y*j;`M|GpRgD%4N57qRWDDACAmK}qTVCPLRPCX*B@62Bh3BtxWTH~j zWU%I-xutQsllXpgQ3I5EbjTlJ(!Le)ChE)|IzG-za?_`pTVYMdr353Y;7bRGPID6q;KTDfRAp@4j~ zZQf(z2XWuFbA%b&gG4cvoZmBZO=*;o#sN+3C?L0=<0Sk$-z2UY1`P~9QMn@5pNc^M z;r>sJnR@*zR@B+iLcLX9_dxH){TtVHp=ya$oGcysm%a`nA`C(c zxMiDPcqng{%mh*JwYo~D0p1dn~>U&RiCDGmcJ-@ zFEQ6n(hlgHp)T)U^_r;dI^EWY#4%!d&73ev*B!fu?Ezc$?RM{`p-d7PBEt zXH<4EC>zT^&r)+B#KgUncoz(O_s&7D0aY&HG@+dNT102l0wBkSE2HZMrvv> zRv?$9&enI??~#y*{GALs-|CPV=mDuShtcH^H#g@Q1)ayAd_Y|U;Xp1iBb zt1a{DM!2KM`CKNu8E||q5V};hYb@R6%3Vpyt;Q{C!*ak zpAT&lCbfsNS8YUzx@T#n}X*LuxL2In^S769r~vqT4s42jd|HB zM&K?B&!gU^xn!{>Q^l!c{736A3ychlC79{`86g~_kqM65QG3~&ynj}+ zLW)_>l}kmlOts;D7`&j+js4_6t>!37-ys>8PYq zVZTJ;tPKNFd39f4RRl7wsExiO+spCGu&Iyzx62L;Sm)&StnK>ghDVVOOvDNq5NYzr zjo556QyS6WPc8434{iE@3LY@M*~+?guRT2c{*eNf!4vHF#NHjHtK@9{5n5k`owuu@ z#t-mm8A4@M?Y>mhT(0*p@G>2G3+{xDL?2xbRe62zY1td1R}kU2hm5w}9rzWExf%Ed z!PdZxfpv5(qf1vxj_eAN{CEWdXBIf9#`o4&H8%$9BrU9pHDVEt=oronVf zlVIfLfVmfE()Xqm`p@lhoDj`6N9KsI6wm=O-FbWUBe(=ZB{0NBRzFw(g8-m7zqQ>{ z6sP=72JGW2vT#yFv*Y&|*#_lhJWn?-d(od&?^avf-Dtm6)_s9OC1?N<88C!ovyBUt zT|GcW7*QsUh~@j%iqa)Yo9=sV=4OVybc4N%I(qri11>T8kDxK~%gCq3Y?&%Jx%Z*F z8WnxDCUCrzH+{`-z@W7Mr%HrTEJk%ls$oazFFr`lVxeYv2julciT{o})SWH)OBTar>g|WvS3$kTnrr+^SJ*RffvsoD7 zazXRGOU*Rc_L-Y1q|2801qLAE#Ec*l4@TNRlD*hiMu`g6<|Is=30m^m>NRpp_tsu~ zzS0fUJh3e!Y_908EMqYB#7de4{4TpAC zL=(3|$@4`7%0sVz8J-u+pyEYNQ^_6e)Y_WURPfmw^=YpP3pCUar?hST3~#-0UX-Ox z)A1rmOpXKiDSp4q0bbnJF9hz+h-<2;xuE1i_+*cu_)*ixM`6X&2Vc8Dfvl`uSan2m%?T2vm9T?d-hr0xt#Yb)s>hW-A`EJM{=Z0WosbbHIsCR9lp!G^J1FYyfc z0Di(TT)A=FBgvFN@X(rk1oZ{JV2FQ0^OH^(9s#A|p7fW%P4UKfA*UvkXiAfCv2?Ew zHf8!@TW2%{AQvpo6rqH`4|T0e)P5MawetrOQe&x^)R;Rf{>e(u2JuW~-i8Nmj%49A zGxxT(;3w7^vl)O8{JaY2xr<#Zgr;89K9R{Exd5-#*A$5-UNR{IR9RtRXl)7TF=pUL z8|CwC;9L=|L%6iGu%EmkS68_U6IA>AXoX_T`Bnw7*JlK=z`L9Gn zbkebCbUgeX3mAO(GKk}z)PxnEq?4#dE18qE<-XCHn;mKYwXOLztQahvXQo8=2mH~u ztTg>5#mqPcx&pKW=IhgzRAjZU(8G%#SHOqN%GzNcnIBRK+#eskdVTW(HrAh5p4JLS0y{59FD{g7n74kzLT$n*FRMexrb#M0Y?{$V0JLTeV&u0=@1wv154Y=%N*#&PU+B zc?E4=C@wWsC~T+KOd~0hdmGbbTp*kqjg6zl>UudG&uBy$4$0d1p=BKdMvrz_RJJU| zjltiu^_*$Clk{v&LDm8YQa4U)HoJC}t|xlBO&nua$dA_#mIUr3@zC?!DlV=+MB?rt z$Q)E$PH*Q$8Nv*x1==ILmS+OIcWQ2V)!Q6rB9!k?|0#w4$AW9fswlw4WeWCO!%3|< zQ)?#5kR-a~gcReq;4}Gvy7Q{kaUF2uD!4gnGHQ4K5#Q%YK5^5StjsK#VKTSsM87tZ zubKaJ&olBMV}J-s#!ZC#`bS*PFTC@*M4Qdm8j3w2yg!9 zUV4HiwPTyf)=_dwo?fGU`T+B})QQW~bnS%5O^q-B6*1sM?`HJop$>P9Uze+CyHZ1n zRAaNfsRhjLvB*KL8Kxu`Dz&xE$;vA@Wh-xvzVpYT{H+;-!>%2l;fh`U_gkpu;%LC^ zBOR0G4#&_SvnhoRcv)L-V1l75wfD5I% z!s0875y~FeC&HwvY=8eiMT4c>UQAHx!ek*Aoq_g&4<(+IIqdc@`pw=kSEVRJFnok- z9;yBetETmY1y?swtc{A^A3v`BN~dRY1QbftKAiqSd?wn*sgZW%I&ZXOtbMT`6=)^u z)^I3?C`#-z%@hVqv$iB-q}m2NmPM32QlR2|eB5KE0p{Em#Z&A21F3%Y0#mlcO?2qH zD*81^37Q#7)c;xXF&y)&QJ$MbWk9XvUvN4)EJJbaka#Go7el~AOt5%MzB$ru0Q&h7 zn$Q*9$$b}RKOgyL^$23GS~5x*Xmp!n=Ts=R=n0#y4W7U_iM`hc#bhXUS^FeL1}1Xp$RV>ukm4trOWPt6 z$<{XcUrt;n5j0shkL4F97zKh|Sp359Jn^N18r7%-g3v)pNIBK?sE2T_w=~X5|L~ZL zx>~&vMkOb7D!LN`I%&aqr0sW;61JtePng{Edz%p# zxc`ME;ALbHAqucV2cOTbIPDnW%SxQWRX=;{+J6$_NB!+ILX-E+iIbfF9MxUh%cRR= zwNKs+o=17MFUI#Pmiyww_Z0pS1(FVB0XBH};X)?WAWW%-u@}-0VG=F8*KS~TWClL} zJGHB%FhRcB<&Fd45kD75KEXu~=7}%BVw!x*t7WIPCg!^h4a|;4XHsm0{pJkMlkd$N zqmqNN7b*TnW1PAH^pA&=SEB`FQ)6LY(7`AWn#CHm=OiUh;pC{wpPVT2NnZt~9xvZU z-JBYg1T(%mLl=7_5lxru|G0`cX=K;Ty%JEekYa|frfoTziA<31u_*+gZfZzvMXj82 zhAokmy*3V?j>lR~No1i)OdWx4bR>gMxQRHwo#(#^->+0eySETkxU0*zSgAY zXv`8VrZd$ESG7)!l7r+(vXH_7WIK%oi%Wt0J3j0G-v4`k-`lhnwL~_Mc=y15B0j!3ZGUQ(`e|}|O z0AI!0D7dn$*4t#)H~zvDkfWBTMNpOdG?%tOChA3jn>2G-AJ<`ODnAS8m)t>WsS}y6oCL|^-0=t?FBK;}VKKXetfMm)e zj2ln-wskALhpPAt!CKJ}H~4I3!(!wo_gF*|9Xk4HYA*rmD478OmVcksfI6>MYw2a@ zq&0)1Tnlf`QPO7#5#ih)S8dCse}>-T6Xl;f@ZF8CqPQ-496r_|fpN<}`dN)!9BBM+ zDi2}qQoO0LAK!kKZDeqBYvF!XqKnt_{XEx_vY*(KROo8-zPJ!|8|kO;NqCN+LmnW< z-L4y(>!(f5a_a*7QEZl_eO!-izBpCg^sMoD_|JOPbRGfd{gR{ zz4n01C0rNdo8#OW09;Co<&2JjOb;TC5c)ZbLr>1oclxuwJG^&nSf6k$J8+@h&ZUQ; z+tDFF2h^0X@ux@Op)`~p3>UC)@TPU$DOJV~k)S#sscdc$7&!eRKT2?%%XX8LjkNlGYNZybHh6Au3&JjW?S?Tz<}YeNDtzX8p$G(V$^xF;X}mrl?PL3w zckx7}$01=dnij{Wz3ELo38Z76*HJOPPGO~ona=hCsQZe;lj(N9u?ZV>DKg`xS{IkC zZcN7U(eV!^`t}#YZ+t?mTGwlMoXPRoh{V4ox0@Ybrr);x+M&9N zH%mcNuf%>AdrVKN^6HBLW1w5hIe2L-zS7H19S+O(_qmcdyPNcPidY+!ZO-Z;t}~ zZqE_VHy~DG2n!j$83PF%P4DyXC$pZ1?O>w!o(ue`9PW?U>{Z%QIT6rsS^QRbdk#yb zn>ja!!aA1iZKG82Sz>BmAI#I7$8yXryk^E+Hd~oy|7brhS^jN1IG>dCSS9Z9 zcNd>4rcgF5=4377H}9aU?(VljBoA9%sIcw4ytpp^M-o5uGX;Du(a(vF5(pbMt^=V{T2?b&aro|K~(Gsa4AO^7;}sUJvW9Iv}e3J&V_TqL!0cMpnSdX*_EQ~E$E z8(GS3;wZOjwa~XQ3Y;$;yLhff^=Wp5RD*MEa}!|8m~Ac=r2#v)T5HWUL-R|^zvz_p zZmh`BU{i@p`QEF7bVnriQ{1AZuFfVb)ZJA(t26)GfZ02H`Pi#Mezk@Vd}lg)ATBKY9k%on01dPt&smmN8FR?&s~O%s!ra2MNCMtlRTn zfO@`-d2$ibi86GSJQxy+`KW+HYQkEDAOJVO>f+Q2CTQaofzN?2R=_nHRb74~`Wq!~ ziwF353n;sCCIYtVZeF??m;@}$@8)13GV)OV4=yh&>jSVH!M9A-{oAFq9+UXS{NGv*-Lc z?I7-;-+gixUSPOqV}rk0Z*x@2BtAHpJ1_WZVv@hX$Gg%Dh7iv%v{Izq&afev^2x8p zWQhR3&6OwM`eDbbzUN}CK}*;dFRN1HT__hji_5)#X2z`dUSM#bAO3ObG}T7RAQuXc0-55tgz^{(OslVv9yZW^?j?$2|Yaxl(>6&4~__h zh$}@Ee!d8VdT12h#Nka|m;};=%kv5?sY&9m+-3)ZPI%v&a=Rf0O!*wm*ZaCQ{x1G# z9D`Oz|BERB;VBV;Z($*HnJi$iQVI%1ggbiNAFLwDwxE$3Q?+nT!Fng|Iwwbk$M4!>Ucoje8b z1C`jqGI^~%57hQCB;~{{Dc5U8fbS91B#&6r}`GC>O@m! zFJEhd<&~g~xVwd8Glrv2b9Gd;R{QgV8|j?0*myr8^jPLsXg@-K&s`Mi-BgZMEw9b} z64ZG+4QpLGa2VviSlo4 zCY?xe2xutr=TieHOtq3|Bx*=t@@uLLbzzP<6iN+ZQk}OO@-2+%kf|&7^?B~vpj)-+j&e;a`Jm3W$t35?V5kD z$6QZpVq(2t-|7(3MetLT?ZT6Xg8_$~&@um3RL0T4_DYQtnq>EHA4qF_OYsVJhp^Cl z0!~lEX2gueAKzq-fT81SZhN0CHT$~o*DBo->1{Lu4MBJce*C##+>cj$Mq>qRP8AtX z0#z_?Zf=B#f?1#4SvUWjTynZ*PL*hT-yDwx1qFR;(PRi~clsmUJrpZgQ&2wxhxrWx z>i%^0E<|8|*ZrDmpPJp}jj&HVI#4vL4Z+eXirejh$aOc#ily!D1T2iGLa2FsOc$0$ zXC?WgF$pON`bF6LkG0hF*3Gc@jY-smq58IE=O<+UTi^Z-ztpW->9@kXQ#FKA+;lYV6&yyQf2Cbf!m-6I4 zW5j1}cm&zcr~9D@zR6M~O7`b21|qh+&wE)8BXy52vP_39eNB?^rYSclOO=Gm(?!iQ}gJyLVQhTFy{slcfn2&@++})tu zlmC0w`RTjP)lg;{u#|1J?jHG3HA!((#(x*j%q>QN9~w5Ny_cNsKb=(oBntE`R851V zHNtR=r!QI(_*Xa zEX{bG#&SG#%){XzDI@pj@R&nABJ~Q1RIqXS71#^_%z!Z;(&)jmYDBbLY-1~>*)}3> zFx2QaB2_#kLLtC_f9Bz+`Z>5xOOudL^ux~9uz>!P-iCV?>{4nBbU_NOm2kM${2DYY zD6Mti^eg80e&w1+{;A>NuU}`%2%=TKd5-hnq~82vm#TCTao7F!Lo18T%^U2+fP*hL z#(r+q$iPkG{KhzExx}9S@?yND8Jn(O(_B~VpE^yWri#_*=^7jweFPt!-e|}i7OHp- zp$5%9h=q%fV63u|Y_-tTm-AkUz0ytDpi05`Vl!0W<8e6kLd`^+JwovrHj?&0>h-m6 za8h20Nm?Jt`x)|X9QaGNBhc=*H|a0uUku)9nFo4GDn;JvzKPI&=A7RzZ z-&}>L3-yloPE*yv*I+ievmHEoED7vYhAeA7nTT8Y*bc+c2;G zN-vRG9ghDfKrBIZ!G42kOLSFp9=l04cQ+e9HlJ58kikFPF6Xy)sm`BEe<1#85arBD z5w`3z1N(dn%C~Zqto2m6Z+hMw{?EnQXwfaXbXLvD(dohF;`2ds+Ioq(8&5QNC zDFuY_u|Sx9uegklt*~)j`5N}Dzxy!ePmWy*{jPh{m6JbBcXt&UbT)G}lL=b~UztCZ z=-SMG_51(mdaHmqmtbpqC%8KVC%BW~?(Xgq+?~NCcyJQj-CYL>9^Bm_xV!uJX3IJM zx%hA9f~%pYySln+J*)Ox-HxA$*l5n=DM^5x_SeEP59VVrbEU!@`;5*~-q}}UoT{G2 zvu%%smb+6J@i5W%w1_GslC&EgBZZStabRX0?&w{|d@jmA#p{TBaub;2P4v8GI+Ugd7Jen?3osih$I1TEJSHy8n~=} z{o=Upv9MCZuWu{`kEqi8c}qOjU8F5-Jjj`xp0C<$8ek{7ykZo$$8@Mz&6Y2A^4Q)3 zPTGHh^vrbjHy|CN+o3AZbtr3A;($k{*Nt308zkF}cLrIBWe|qSGh+qvDM3v$L~;;R zFhI9^6pWQc&nZk#!*2zJgoZLxJTtq!jutV!=}JA^F{HDQ2^b8tPk=31U?hs_sA33L z>M)SKuJI*@j(e2tOTe29aukPfC`d1+LqSBgY5v;tK&Bwqijvvlnx>z#8S#~3p=N%b z;o?|k5dlDGmdO4|zYXxD4pMN4+P=!sf44G{cvV0RGsPk5>r6~lGm1@&XUsp5&eS!9 z==m0GjK>Qn+kXGeY0g4x{&8k?0Q?;nD5i9Y(~2eaA%}QsZzZmK+%mE;88|(Ob!W*2n_9t#&shLINEJ}~BR~0>M)wOKXV-!b)~wgVoPlW~#~_n8JMH`mh*ioHdei1K-~ELqvT2mdKuDFR7S0wInQt7)iSVcG| zx{Lp+`yj3?k?G^kq)ZOoOY@g80U4Z^yZI~^M4r^KRnKnU;UzT%qBTEgD1mx6 zoc%C7^}c?v(lT;}Q1(jwsX&TsW`(BBN1D%ucZpH`C?`x{qdR-z{Qp{;BR0 z%p#`f_|4l3lKns&%$>#4vIY|m+Z|tvUsYvgW(pTubEd1M$zNjj1I3>`JerxZp4<+X zrn{$VBl+{0lx`BC6OKdcjHm)60L@RJ?n_f7QS9i*DJgnyMTeo+_=B!(b{4=6Mmaz+Y%wO7WU(5GSf* zo(*;p^+u*Cqb!x-_&m}6Xg~iw!XBtJNa-ZX+YF3#?l?(n{Lm?71>F+vt$!X~WzX=2 z{?%0;v0lwYgQPJV2G5ol(!cY}Ub;4ZMD)g5+=$(=(4U)Hk#iQLBKr9aEWtu0$y#a3 z)#w!!J>^}qJ%`S{wrbNFYZ4_u{Xlerl>*JJdz>qYkL zzN2jr*lDq#>6NzHEj0TL+SlFLtU@vzvN#M3lEF08)6fh}m)&;YgBenJtbs@2Z&c9{ z{TCQoMr9q`g0ub56lXi#Hb{y)+)wvlis77MTotORigxF%)TOL?c(0gt2*7lkqg=cB zwF4_VZVGL)9&;@kA|z^HK+@ptEF36tsEn6cUiFP?qes2vyn(uim$ zG_)jfD}?Xc=t`86W!trK2GEOKP&^l z@Up}kA8RKI#H2P+;aqTyqYM!ea8~NkIM+-gO8lXQvA>RJfADA@zz>mD#I{$VmG zFh=(T!3qaQVZcLDtR1G8mtRM^c)`G!{h*{)Uys3O13i|1j_Zhs2>T4K&%R-DMan_t z_9n9Re>MKuvH(_jkN4w8yp4%X`Nn3BRtoNfNpVi5e^-MHj-X1~<#Z&RiXS~UlY*?| z_Jw=>u%;MY4|HuoQBj(X<|pMOXe^eop^>1&7cRe3pmfkk$a}iA{H;X9+orrZn5!a1 zJ>EO#6V2zYp(SxsCLy)BxF5%agz@cE#pt{~bolrMdpbn4kLN7pd~B05zjS*>{syHP zZ}eE+&>c9%0`Nllsbz6e=m^!b9ZVOLE&prB#a=pAdsp>p;+D(Ou_8aSs@B(d6WyJx zy%y-*s+O8%w@;RhT1ny#8^w!9jDu=QI?sJ)QRG1Nj%?jkNT`tn8SzD2f$_V)xAO;g zC<_Y<+R_)o)hyZiI|pFd6AJtfH6Ggwtc%H=5+)4I7PBj8qf^SbDET=zLURk}Jk(#W1mNzrkiy)~Qf znryywo)s!`b}A8{(U@JEtf?o=?{T`(RdZmqzt=!KD%fxc&p24HY?QFu5cp8&EH8vVP%GU_JN7*2DnO z(JcDS8Xo+M|0Cux?@=H4VT&Ma*9B6MJVpaQ_IBN`si9KidAn_vufsbnUY_0J{49bw zWy_s_IvGI?zh|t8`134THzX-F7mi-4oNmS7h@&d^DH2+I`dCJim@Ar^r)S*{U2=%2~nK#Nh*x_qcKY$_!a6~JzfQIEio!5^z=pqt;6F91& zaZGMj4FRd)3*>Ja=;pr1U*;r}-C8|Bhg)F>l839#7=MUrpWwnz;-^)t=Sf(iR*n*; zLT(b~-ry}ICF8GJyBe`%k$OncGU}F6QBhH&T#0tG>?mcle`Kj8kX`cwWh$no)ipG@ zZ`*AbDYB7z18*#wx=s%~rg_G6%J#eu0Zp~vwp9;kYgjLEg`0Kl%pYf8eY0Xe&P;FM zANb-c#H zC-SWwYr~<0xGu#bNJ~(el15fHRpO^7`uG^I==rj=R0}1q8&Q889{&E4+i$TDcRhy2 z$Wb@-x3ZR6r)nH)Z$2t7lJ(E7Y4gOIX=69E7_!um~TVUR=kVzt`v(<;8HLF>(j;Uo_pHp%&K7 zRrgLzm_hw{@1ioGw0ZyRO4Y<6xo?0G-b}(mo_6Se-J{U2iBT-x|J4E>rx!*^c?`UF zwOGmV)WH+mrAQh9c&%Eyryqze?XgNgo#ha|MbEj5iNAwvid|(C%Cv7OdF+B`(tIev zg2MdFix22cB?;fNh1jn0V#ut=c^fXQP)lf~-QJJZozFPGevadIkG;!Q5TOm(&E(1X zvo;eLo!ROs3%e<^VrGT&yFqG4kmJ7HjI}P>68MT+_*>>Y1!t7yMju9={ktjIv)-N zM~}GD*qj)02zlg@feW=Hyh z6Irs-kkRy|-A}0j^l%+_%~?lgD4BLm&mB`;2%M{B|1gI;M$if2NH`T*;DxDbOK?uM zvmOsdTeqUHB^T7)8b!lZZSdpIZ?X`k)d@u)+>F1DTQK0qzptK{l6)Uxg8C(#t3t@b zpkG4w0z?Q@BXsV@lM{;} z3pJ7&e?ixKL&*FLdg@+UN`|VJA2N#LyKLo!Z8DGT&ZDz=c>X@)s}mVu;ULnI){04B zYdyRiG7>1p0KVc|OxOfI$TxSc%o#0LO4m#N_2rE_p66Lq21}`j@{6zMQ^(QS4mDnZ zJs({QJtKA28#FF_G}b0&cl{Y3R-;2h{cQNEYY-%DCLl=Yg`JtNeof!;!fCDzi{+Hd ztv_wAU)ftNru_^X{kqJjwF1Ei>GegIa@w-J5?kQJgLGY>^jQ9DW_iWMh_1UgpSo_K z=u%=%J^9dYOY@)CGcu?SLhcm6x=R-PR(X5Gdc#b>;TckLjfUre`d>Xy{*Q8k4FsDmQ)L~VUR=q5TeuE1 znI2cCrP#irZWsv(Vb>Jvq3Z)PR>K|=Yhyi5giuqL_rGxxr4=lE6m+pN zh`*-_FR*clz^p z7_5G0kM4EPRb;+Y!DLD0J55yVBZ{P|<;_cd!o>6p(!zynB8U!5$qdfv6y4ZPP$7BO zHKy1pa7n${cNBE3i)I4tQ?b$t^Eyq%qTqd__B`&D^>t)sx&PvRi`tm4qJ)PSOnX#)#F z-}5oHmS>a8lTq*!8@6O;Kor}dgFtz2J$Gmw2RYphoaT0P{lxM%_<71&xW>U!Y9 z1n3E>;w^YRaCWb)$yVioSL3QxU0eGaKrju_{4ztLv~htBB}Ekn(r6?S;^n6bnAVZh z8B^1Rlul|w)SXqxNPlxDDa9|9YDEH)h^Fmdf&!tiYe@61mW+l*i!_UCp2=XP4iwk1j=|y_ zVb}I3|Kd>yc&72NI6ve<%`@qJMP$$hKbfw*2QqNrFsohoem)v2jee^^cTFvbi>!W`hgF!TI#OuLS6!eikBC=Zfq)_K<+w^uWrrX?MBLbg!N* zN0sXBTYY1L%|4`3ljCPxmwMLJ31=WITG*PKH-=g}BaoBLuN!8~?m7aH2Qwp>b>;#= z1`4XWCE;VS8JV^!Rq8ugRDR{(++9IXz)XII{9p+E{K;!nt$|$VFIOI-ilNlgQRU%0Yn1KyD zh{4%atM9O_4BX_JvZB(p`??lG$XZo>^TBcW&-YqB)F6_7^);M z2{OG3SN+CiJIFvv_Y<|}S1&`7L^V=TS3N2#+qojc62}7%XJkGV?&Z1R-FO34K~+yD z^>$HhPG5hkKz?_gPmTvANlEWF#lfx+Tfc7E)b-q3uUsX?GpX4b>}Pwy7x1&PgnWlQ zih%LBA3QkZJ%@o`QmTp|>t9Pv4uWk|IW#y1zqpeRK$XTY!Gc9_VpH>E9 z;exX~uzg-Y!V>n<&DPpI=yom7BV`l6jtRlSnZZT3s!`VJP~$u|5%1EhS}Qagu=vNG`1L=8}`!J1*(1({dAy> z4-tac@xYqhUd04F1KNs{U;CJF!=sTT>bx+E9DCvZPE2E&kU<^Y8&$4vd#d}rf0bBt zP42n096VoZK{@3+J#_=b!j?J(k;j|xwT5H4eLDORE)h&;8S~q6zJ@xg{31|A z!VkSqo?C5`K~8MJ=@9rl7&krSGGtv3T7=@ZO-DRu^m;R2#m7Whv^}(^H_lsT8rDCT z^xiYvwZ4E7_-E^cYA)4Ar@k`&wB~yvLL&Iq4RO(VR}oU#>~ri$P9WSP(dfW%4Brc1 zL7SyreOB=c)tNiOH1bC4cPrKeO5?TYtjmjVg|+?|?E zPR?Hhth&klI{K|O7(*+3d&KEDY}nXXfbzB~k$mtmP^fX*a8Vx;k{?r|*U4EJL`dN6 zvzM3e!hvgMw_52Q)^pihbloJD{pUfGZSn9?i8oANIh|@bEx8FbX{ z1L5-9r_f{zyoikma)D4x*svOkf8RCKObW3YMMFbhj79j5f5B!A{#por79bm=z@ehS zC)D}wxiyIMD+QOl$XlR^mV}WHa52}$g&XjS&0reCkz!b{LKXJF8_HF77Q#`%O_!&D z!xe5C>e7vQA}ZyW5%|oq-lv(A>4s>!B+vD1;)ue5FJh4t6~@R@2iPu1YvaMQrsV6g z^Vo;d^W89?pS34c@<^t8a{mP7rNv55xUr!D&gKCrq=hT$@?r71sS20IoHgU$jGalV z=`uA7lbs4VwMSGDPCIQ@erpup25q0w@3VhO!T-F zX+VK46-tS;!e>pyyLe`a6cRG^ zppvuHhl?Q^UC73B{5{Nqh>`yt<`p{*%cH_~x%L6RKDpOq9R_%P_U}*!SC+Ao=`=C(`QbC1^*@D14_^CWC5vnu&QT*ruMyYiA(yX!25WQkN@Q&u*&a% zoNe`p=fk1zwJCw%6AK6RHH9u~wZ43NxV!7EDQ&5E3$Y-TSm}>SK2N=7huf9GylOV< zMR5g%tns2X)_=;a_fLHOE-xWbo;Ub{sE{SXYyCaZZAG1eRR2U-;FFj@#pk4sWht(- zKr5<>8xc(zP)J`SwuLII{Aj4Z7w;>K^tsMI6OT^&K#W6%y05LR1-K2CV)L`vwriRj zdyFW#s#(Hg?*7-k1_Dh_jw_-*)gKCiC-ql02JR_t~EJ^_7I_K~hQ@EK#&m&Ez zQe5Hi51jD`h8iaPWzS#rAJM21zh(4Klg$~2qx5!(wr;Pu6JlJJK1`MH#F7jYi9OXt{(#P7$z()J zMyrIb^8wkyhdO=wHfQ=$UC=iZ{?0jXGN>5$sDv;uuOBhM8w+4+>y81#uKw>yYe~dC zr&{EWBcwm*FBwykOSE37m?9ixDzP$9g#(?LR!hNy5IMJ1-OQrlH8iP0GDpF#@D zqW*cgVv>!L|FKDhAAk`rI#dTe%cAWlchu5;3|Y7F*pB!ZLJkjCWmHB7*aG$`OS%Y* z9O4qAmqe0N#_dBIZOYXT57R49=5tdF0x*65qvdd1Y$SEAN{mV9?{Qg3A=V9%-s(K> zQ(#eo-hxa>Tjh@Nln&xyN`dFCULM{*VM9son(y-CH@U%@Pjj=AvxE}(h5xGsl-q-|0!8r^iq<_@ zsfgpYk9i6C$0fniZ`)a!NdT_g_<3&6xg*4b<)hRAhMPfkJT?;!6+1?ZXVvFs7 zd4z1PR^!jjdfkKcP0mMb$0I3Vmkfz~e8y02GPicPRQB3QsRMif3;xjtU1ELD%sBhx z-nQNf8Q^_uf^bC$^Wv~)iggf33#Tynp=~RD{<(rb!u7NXC)?#Vsb}7+f-6;pBo}4$ z8sv8}7vI10GhB|6IEC?zP~UGlOBKrzm&;b;nB!?fpda^Wlf?;`a^~8;@A+l>Uj3%U zqqX+LSz#rhA1^L*RI~JQ&^LHsHnLx);_e&C08ZG?<0_p+eas%@TPH4=K)P@ziDfQ& z9v_MX@O9?DwG-IOH|Szjkl*^Lr7?enNYDE)*3Wb?KDYOE*?xfJI!Kc?kKvJ9o6xG6 zz!xu>W`xQBhw5crtiY7w^(nBxpJ?w0kGP?9!@^vd*8ppKUSM3QuCQg^F>RrR$o4rr z%SNqJiG_$tddu;T#eVWxYIVom53j^Fz)9fWG&76iVDVv(C^-CAZK1v^V4eyYB*y1X zn48yowtZx4Za1`GtTJWa0V^vzUcO_r-tLc6wVf|>pYa^NP6weU#KPpJtY*`)V_#n8 z?7aJl&vvKy85an#ce<%7g|8uZ4$cv>qdzeS;^GsIj0{)irWaLX!go+E*LyV#qso@~ z;9?Yp#!l8d15@s3zYmT<1@+~dgKr$B&arC_7QumeK9zSC2E2JLc8PmPlIn`dsvii|}OXUn& zq9@Mk=s&BghdflWh`B+>P{)OB;QwQ_AX19PK; zZVB!X59)FJrt=$zV+niT_(}^wlu>{){|IITY+289(9DWyJDDgChRWKa$ z-*5=;KEO2xiRuhFV!U|AhNz7{jU+3&135FJ;owWgp*)-=u0Z+X3D~lxH4tfGoX>l0 zYrf;-WSv&v15dQwtH*Jyt6WN%iPM9E_1$Tc`_XCYLq7aFHEWkE?(w|1_qlPrwxi3{ zCbi8}c5Y=`@b9OeU|rV`8vVz1tV}PT4qCGj*WN(+c_~Wr{>AgDd9w^OFLYcyXZ;Ww zB1!VKtTTZz2i*BeqrZQ- zWQpM>N=;qym)kD{|Y(56;KH}oYDR|f1~!3hZUFu`Bf z=BtFF)~sVp;SnN19k;>n0E(p)yWOad&Tuq&2Xj1ar&wCW0p8hZo8n(AcmQW4Bk6PY zoETyH@fSl*>)}zXP$Gf~;l_yRQ}A zZX>A{#Z&`B7=>oo5>gm4`ElxY0-*V`ix2_cjM5n)W_m8;^}P9rv9-L0iYy+sM=wR5 z+;fuUtKYM&6c}fa5^;CG7oR8X)Z=3eGbPIfcCvh8)bD=|^)!}^;NFF95#8{(+r0an?dtF9x@}3qu;8=2g5NmFGbWYKT#T;ADOo&? ztRLKc7jp(>q?P1{GS2C=b@5YaWL2nw=jG)u#r7`)P~pp?+0?}lm}}-Ip9vY(y5M}8 zwvzl|VZ+UXf$=x2uUzy1bf0?yh<~|x`u{hg!&%q46Hv8N3QwWNv+QTM|%6^uR%DM z8hron2ObZx@83yHub(rRMCB0;De6{)NZ(PVV}gPcX5M6LxpNZ`MUJ^ zUgJX#qksW5>OT5*9XUC*#d06WmfPicrOk*AZ`g!6Oticr>b248cX6~@5o3s+sRphF zV_e?EJEhBi=CM8mz=caW*D~i$-(rVAK8TW=D_(tns<&8J1k2C&fy@yPUdQ7%zDSMc zJ|FNB*sAG6ZDv%N-*_xXeVx&8JJt6w0)c1ve5yJV!RMzUFCd;Fne9sgV@oA%p))7!nLc zFBO!iQyxKK2%I4px*6%|(;XzrO6mEP=Bz5K1yvBq@6teDANX6WSkOs1xA79H+Krv5 zJU^oOvl4yFkjegb0X@y7$IOSQ{tXKn^z3W@PK`-<#rfi2<+`Xh-1C*lIvV!#e$Zgz z#d`JH>p&`e8Zc(~Yv88` z6)H(Jq4gJlnaB^wFC;hM^x?@LYQbsQ!se;fKR5O>)kVVB#h%nSx zvPSvu4)!ke?%1`m&=v1UWa|T!sWi69>GdNOWlbMwYz#s^WZUq$UEOC;Rl2HM`+n$c z^y~1{u+UZRNNnF~0p~CYZt`0#XWE4{w9jk)+rgBf7 zsRK1bUMd!)hdPVRQ@>~zNI+oW=NE~gllS4A5Pp$?GiiAIoH<|yh?BlUyf>1L{zSu5 zb^JUAk9Km~N{xcg0t}z%dvHJ8j>MjhEQuIXtEd|L3fpd0K4JXCi^6 z7Q35j`~G^H}^XUM*8DoSe&jRf-*^ zrC%S$Ome`g><8NGb-wpwMM`l%hge}P4Mj&AtB2w;7xyy!4eM;j>XubZA4GBY&kJY3 zy#M6uK49$^U_3S404_Ttz|N{U5f=QSdU%L24tpsC7a87qzTf+z(ItJm%oNZ`L=e}h z*}__ipuF0IrIQIIv5oc34hHhHS=Q3T?sjg}dj9@tqIvo*%m1Zxz09j>?X$*(`^sUX zy4Rk(zVC0ubkeg>Nz&GHW<>TSjfi*GOiN?8#dvGKjtn(d{QK=mm6&=(dQ1dugftqAj&~DGpv)M|UkgRr%9Q`+DGgSSuwgm?$VHbqVTTbiBW}ebZ29XW!s-s4*O{&8$1l5YRBG-N)o%#fSO}lZy35;1R;l zS`yRPjbEATwA}Yy@5}o3_$uKmmn2sn67?y8T}kZ>mj0gzMvW@#7IkoOS0EwJGN1Nx zz78aF!MX4X+3okth_?(Ygvnkvl?GeM24d#p9}5(DpaIwyo=Yp`4XBJYH`}w=$Iz@t z!+Ji2u)l)6rDE7e51Y9oiDvHBD)3!)0Xi(??xfwVe{L`@1m$EEiyqA|XPPQ1(0ZAz z^ltCD8L$!&-B@Jx{q-9l+48HneJuoZvF=kJb-FJCp8)de>azI_5L{_67`#uk#h>e3 zjNdj16LkV)h`xGDlNgf3E6s8Sz7}VUoIwbHt^-z0jn;e6-Z`7uqq(`jxv(%_Fd^I0 z#s5_yYbt3Zi;GYjK4k+_)$1vwLDoK<#{;o4_}L)>(1dlWfh+ZM6oh=T>3(CnAY6Zy zrDRxDb~TNcR8R=!t1uU$dSi8@cfpH?4h!NO%EGC{0~FRSPgxzK{we^wWYVHXPOJI0 zzJARTFl4Xa3QsGJka5Tzg*44j9g6M?ux~0o`vU`j3T!gw>~66mzo-BJ&!&F=klBN8 z^ZdBC-}Qni^{~rU_8a~1=EDlg8vG}Q)Tg6je;-jOs4{i>;4%v`2ZwQa&U`ANvSX1D zaFzbcr%Wf|&=3||rH6n?p9ZG`(tQVN1~R^v$DIh5!B`|B2>=hOt(Ukv6%PK0?*d>< z{rx}D&k0b@R*{le6rLgS206xRuVfCdj}ypd3KC?DZ=IUcRl+*3|9#jI$KmLRTU+co za^V+(9-VFIYE;ckmF2gY@^hcr9I0&zPM8{Bw3ZeIrr9jU*QIc^n!De7$*9=+ZXhyC z&|MAFnVIjv#j(^9(2u&*Q|rwtDv)&4BsII8N-{}W#}!frOZg}7=i zb%2D;D&v~b>T=&hPlOQ)h`LXXq&T@QLE0YWOv_&J+C-o5|?tySyQN^Be)6E50r`}?OGLiqo}X0gld_$*F0iFy@F4KV&&piYE^biLAs zqbVfSM#HvNA+A92=pRxAtzf$$Ntq{rAO{$(EZ(NO$KI<0=UvCWUavb{aB+pd{+{$`{9t=s9-re+IV=6e zovFb2dx=l6JDBxDh1EBYCUkUhNjZOsIlqU#+mBC5S}yRipMYe_;+gj9M7<48(VE$k zSCA<{S#O8`x(5}eqfu4)W-kT+(nlQEtF1mqM@dYF?B{R@$N?kFDdp-X+&t`+JRa91 zA2y%@U5tdi`G|=@J_9S!RlVSvnm`G%iP>Nc;q0=aQz+v{cp~-Cu~if0#sAZr+R(zs z#Rs3vh?TGwcLL(&P6_}_ii(5{v;puCH$tw#J?qITNM31+_}~Pr2kJkbdqocv^Qne! zn2Jee35rfJ|0#Rg-^(IF0iztNnLm?K(jH46_4rcmTUx7uStrShB7z-<`~=W@!{J3) z5TTZ?1B&o+%SPuFD~!J+Dw{%tCx@-U+>`EOajnIL1D4;&>pG3nfD|n@^uY=rX!3jTl5l%DrUqzB4|$CDgE8L&n`Q$%R16sw4^(vH2dnb&Z1DOGMyQZfkHP3*h;(&e;XR|6y1M=~(dWGNJEod6M zVgXzz(ma~XU31o%6DaNt5r77(KvFpJvZX*2bM||i*XOdyxhPuhDqUvp&9!^mvTnaV zora)38KQ`BcW;m7z1CoQzUA=TqE<>tNlDG55=W&zyysOPz!f~sq}sN&{XCi}J!!L8 zD^D8F^8W(%>&LFP=W!@Ev$f(&{FlP(ApS15xMuv~@H5m*UYQBEfr#vLlgQ z;e9YyL}It`rR9vtK}T7MoyJvwWB8mR-ydX8*kIXXze$!dCM?{gV&mNloHhfibV>aM zS+PKz&6-!S>#ApLG8c$B{&u@7u#f>^T~<4Rk{Cfgc*@(#j6RglK2e3&KCy=;lW%w< z{=AniQin=I{$Jbz5YoYA4YP6FA45ur67n)i^y~#I+_rs*XidMiv+LEfmdD z!FI!%0Bu!1j9?3`(M2?q!cVAESBle}p9VI9->ya|>Sbe>m7mDm9e;*{1V|J`O@gH9 zo;W9qhMK&&Su`xgVUFo(|CIM>f5T2y28t2Pa<7dGF$uTHbYSN77gmV;ab!7sQSqR( zWX#GIXZQ6?fOj1mqok$I7Nd^Q)~|mxJ(85;#R(I=_&hT+?}>yLpbZ1Zd9+;aF_J?q z#~l?VA!pTvOJ{BU=vs|}R7gZ@k?I2NZY9dX?Rw;UyZu?Q^-Eo2&5PHn*@Qz%oi{@T znlaVaAMQtwv!QpzVZ|?@ilym~{HhTxI3rz?xac?d?76co-Z|&%J2HZ#CYRgWyYY-s zm48*ty&KgAhdhRc27e0Bi^FCZ=ns6TO!2X_=S<8nNTz+(>Kt)Qz!^_V7}lz@I>;it zMx(PM%n*n9pBY6!7Y34m4VYoz#QU04(~zf{GtUIEAkG6rD^Hp?LGh~>$gG>_wi)o2 z?4uQ}a2hOb%Aq3g*ldx*$|Ge($jD%*tOfzeExCw@h_=@cN%F9Zi`(evf>VVCdC~$S z8f|Ump@_h|X}>gAhbDq%+02XtQ(K*%K-U^=sv2Q@cyw?WLt9fjafqg9wna}u-eb1p zX}y-h3k%t$JdLjG!|cjR_H38G|8=}_2USe6%GhjKhtG{ue@>iupSX)mlx!qh3E~$+ zQ?{KV+@m+Wi}~D#GWN5-+TdndQXAUdwA&^p7fTG1ZVkJ7>0mD!>cd+Kt3B1{;^!Wo z7gb^lsK*~c*JU7%1kPm^_P$9ml1Dck<#@V*FiixYZHGQa9wK$w`3 ztASpt3(-tVdtm4*r!n$R8^Y8X4Xsp(QEkPn;%^RANmfCgJtgyNYdQA~Ci=%Q!dz&y z2`An+0FVw9twJlr8f{pl!HS{TuM2|y{7*wgXbnNnIY9Mu$Q*5COUQOa&R0D04;yKL zB*eJ>Aw;rjduS>hIZOk58jR^L#;39XG*}cv_-cA8{3&3->GqeBo?hGG=_yI8;Mm4` zG=q=Fo*f@xcmOs%p;d$sq+V<{v<1T5F5@E$>%HC^<4N{5pbP#Ad$}BWfFnhoB~#8FrM^(Cwa#+_IHt*eLI<<#|N>cJL&p3Qc0vum{#NTT0YR9wp~1_L!VB3qrW zneMxNd=gaVuIwqoZ2_XnMgi-IFo3w4YUi{2ui`&e4$o!-07?kt3OQpVCG8NQ0Sni6 zKcHf%Xi1HLlQBV{afgSeZnnK}0poVo1Ueb2DAPE`?51$h7-;SE;c2X$IoYrPF9xbqWQz8iuT_@Cr90g=g`4I|SThCrIP-TCu{qs{{x%C8E z&y;hOkk0tbmjXXBH($|nt14^AYVr3Oex|VPJ+z3!(yMK8G$|pth#a!XK{DkbKCBr% zBO?(L;Y(0YmE8S@UBHhHXkbcj%%m=rVlsYLch4EDg3m-gXbJM39H7XD9!nD9XCtVf zeK91}*!tP+4bK~Vg=~b2{b+H|F2a{> zU0*IWPCvQqvR2op%;1hOPTy+9-EW8}CF|82OBJoy<4+0t!v56VSvRlDJEBI)v&HZBN&mn_aqA z##X&X4IY!CE7zLiBrFRMRqL!OD+}z07k0EMw^KanprM|?u?s`l+-TxtG;1#cb1Nh({U?x!ocW$)v5@^fg zjJ)E`)UeE-MH6s1j?c*n$l)RI?Wz@>0#q(Rt{KvYV9;%VfV=lvm5D?B2Tv$zYM=UdI)LiZva7w*PS zJw6>9?3Yo#-Pb8AyblcV6G3p*^Smx(IDCvfd7?nw$Mz%xG=ytwHK5RFT7ypoeV~&` zd{Og#$c5}ofZ+7*HIkDq0DwL^9#;S@b#ial`%;)`|$Hv z{~6 z#7;)SVH#9MHsiWt;~McSD(aSAmxRq?EXdS^PNXP*)EbEr;ut*mq@$FJ7fx5H=`wLU zc6?lwdL_r@sG!<=#hOH9NdXrRr<6ztW<|#XT~ERP%inzKGcheRH#1vW;yRB=EP!_E zO#N6pVxGOyYqIvWn`ke3)^2SKuv0HrCV2F zJsUm2FfJ}3!9X#a)kB?dg6iDU!Gl*xs2woZv+th+C^H3!o)pHZ{OK7o1!>8kH=30l z)#nHZA7w?7TKWc8D`nvTP5Q>hhS_R+OV!&P^a-x+XKxw0;oQe!_b%zYYtj-rIm$S| zH3SbA-;$_Kf#PqKNvbCxY{;X~evb^6oCu9c8wUDVsyoW?1nIFVc%PnO|1#|$1>8L6 zv#H0_V2fz^T>mv%j2W0U|K4c!sLzPKePlG4sq*>}ElFbpXlEb2+Iv1ff{!0FrD1I; zhCFTymyfX)_rQ4j>h1|swAS>aQ&Hp3>*NM&ixIZ6vV-^nd(b%{MCoQoHZ5S#g_EBF z+Lh11AO=dBYl^MqQs?fx6#wBd%orpwrwXsa=z^m8xBwxbU?eB896kN&vCc+rfVW1p ztK!$emmQHNxW}SReTy|A+TWor*4fna$#Ki^5tK2vN@i4>nyS1LOBOOE zrWFE3qdnQn6UY(ePDSKbvZC@Ie2P$$JNV#A0Qm1?dNIn(P*Jq|fm~Zqk?EA;6Nd#} zV?O=MXppg3UyZ8ViNFT@BnIZ1vCNZ|kKEw7YZ0}5&~82Qk9sN_^C)E{{f>Fqv*(OI zUsuQxV^wInU|VrbA_sntj?~6>8StP;ul45g{99Xn*C2D+C@Wg^IFFAiaA5COxg3nb zs+Tyw8!LVc88OE9Z}5E$p1rka{ZZO)Ol)+w>ru=5v*i4i74R-MkR($tpWP@`kFRTb zfNV7Nv)@OdcKR7#;1+vUJ{rJQG6GmGKm2mt|BP?9S#~$lnzl!NMy3aVzn7R3DW>Wj zwaANmE^Qm5V-jY?XR7tjUM$tkJJR50__S?JGndOo@@9mKWWyqeYP{$-lxWE4M7W)) zoi7h)^2f|~uJ+E?anfJD%y$(+KSH-~Ydlk{728LI6ox$3zbv-GGT}TqS(7G8ZZJk! zH>ct%%cI0Vh#-ML*4)efFPmF9z+d^`5CcnZ?N6-pHF(`4DWf!K z7;Sg`+)t%va;|4Gv7Ls%YzI!qeZE*_HEb0QkNwsd6)(Z##8o2lz_eyC39 zZghv-&CPMW{csLPT!>Od`xEP0pYr%j0eXD*rU;ScR9r;b4 zCDB%+Xb9m`QN$F--QW=Po~2gog0g+NTMuAS!aTx|;WvBO?QXj7euHEGdImtkgf} zR}w6XI8NG}+~#%zD-PWY&30t9n|&@8{GBB*Luy}r_C7O)dL2mpMaYZXw>J&r8vKLm zxvnB4aI`fUQiM+^PTS6O22!-fu1;e`r}Zy#mRyHH|7V$g6lk86wiX7gRw^!*qyC0g zk-Lig9$>)r{m=d?KapJXI_N7mg4O4%g^wqxQw0r=hG_dP*?6o~dRu@x8ytTlK zLNT0XVVmcjPltS2$6LtQbN?v@f-6B_s+WdadW)6D^~UTms`~_XQ-mrdPlpXOK|t-| z9oP@l&|o_v+x}#~w*j?|Vl(~W7|ZGy28KN6kq^b6|K=EU`F!Ss^SbcO2OAnAyU>41 zl=!u2f2_08u8xZB>$N{imw9EV0KYsGT2}kEow&M?Q|iQ=wBJyGPLk{G`xj~|jTP(e z=EG>!&>os5_Uk@LZT3g&t2!+|zTz!QWcYa$Kgnl#qyTu--g79%gXNOPuwg?7c3$qI zrA+=xc~v+MVJWl5ZC6rtFu7%5m>-Hi8AMK20OM3P> z;=(i@G17JrHtwM{F8-m38IG)h@cS_&kYhG0|Ct@kZ~aR7zK$wSMDsJ^gEI>mOAmiT zTv@97xtd4ILB0P+)jNh&+PGoAGuig!CTp^7yCzR|O}1@Kwq27a+qP@6ZLHe&^Zegu zzk7dNUs~(vxbKVS^*h~%U28Ur*pHlXf+Tf6`>yV^WodI~t*wIN@fci0|30ES+SAwI z_3I4Q@^@>t7N0xQsnu@dnt*;jsxPFb(-*#7cdr@?Z z*rAj{2F~R?X?Q1r4D97Hw8KzlAKY$Otc__R9_H8Zpe4a7o(^M=$KrdTOPR@GsIg>6 zj$*{48$bwfU51MU-bHySRFGcnc_5316&BqZ3)pD>adpgT2b)RV&vewsnhf! z3s_if<7vYtC5PI?s?U0D>f{wmTX_xg;Vo&KJ|tv)+h$a6P%23VGm#x+~T?3sKs5(+6q8L2Wf z3(MWpPRnarC$#&aS_E1fxESLh-bguZxYrvnG}VjsbAc8X$gC3HmvXQ29R&kI>2F{b zy+F>w@^CWAJV)HUh;3@j*ZUnhg`R=L`En#?SP!LYTpAs@>Q{X@_j&~)1OMwzz%B8- zoZX5CrNDl5^HVE9W{Qp&Ptd9WwN{guETO!2Wz}$NojSKFP!)9h;%#%X@M7^p>w(w8v} z2xfK$tACQMWF52&Jr4AtC#!iKgq%#4`Z!2{KOR)iZaV05NovdQi;jYq6({aaVsdcc zk0p+++}8tz!ACQ&Ehrz?nhg;`PskHDwk=re1e;W>08fN6jBxMPnY{oCBoq1#9}xjA zb)7LuFC-C&Anws>-Apk2=~X%%|9>U-}Hso%;?$_CIR`gOjM(^N ztxBgLl}0|~m;uThsI-2GsGrv@nK*H5H_;IkOHmKmM#8uLKg=96RgdldI@X33&m1nk zbtEQ}^Mmv^lRq55{PcJ8VvUYby`$IAccdFcrweP&xldd1V!Q5;MStX9?FKd| z*o@W!=XtKLt}Zq+)5+&jW`>36_K^;#Z0xEoy+s*08I&Yy@Lul%$R~?lC&IMo1%RW` zwvT(D%EaLAf6hTA&qeB<ez6M^t`L}YRa zoyY^b6F90`aXRX=4%#-4#=A!Cw!?i!nagORGf0Pw*THw4;!rpkKW4~lDoa{ zP$c#B#vMP~P;ltb(T!K$=d(%8v89KLOsun~K5*bOywTYX@?)rW^1*@OUaRo2=*zE@ z9HH(|6Nk5p^+n2gye>60ebOsGr4+9JpR-&hToEk*p>JuQR*OygKa6dDmQH;~HdclL z{V}}V)`gk=>Z*Y;Y_&}`3W-z!^KHER)bK&1g5pVeDz%Crnx0(;?S<=g-{_n1biT^N zgL)1?eTPTO@OcA{L7%f#pb-0goZc#mFX;!;04>=Hc&+JlvU}m0>tRG;b1=&&gZT zT*~kx0XLwfKcv%WXTEE3BScPC>H`mX1LJ1#^qgrxMP!M0FOy>^0l?H}1;gpPq!&l8 zcG&_+d|Jded#{^KuYcuSXjB`3nu~ggFzMIJ#4MtUhwHNA{zbV_3XouXGiMif4kJbP zSfByP*zl4Zq|vfHTN;Npb8J4!pFekm|C=yBzx^RV@!q!o{!_L!$)K~enXeZ)(R%;9OJc=yilba4kV zbB}PgzU=VO5PuKvSgqoALA6YXPsGkhKnf7{qG79OmYDMh@d&{E@+g2fW$LH?-z)$Q zdf)bT<@AhU$AL3fI_h%9<%-B^M|qBbZi4%DF|Bs{)1BXM!8>kJ)0KCYBy;PQFwt4~ zW>Nzgxhb=?MCITHUApS-w$EhY!E<MizZfvjr18vhMM~BD+697WD^->j2M(=~ z&ikt`ac{YCr^trfO}P+lt!&f_I8pXeEspO#-)%?u8|eedn0t-tz`#^eDt)zV-#vY< zQ!d3t9s_t&7!&H24k34efYLK4>uc(V_rRc){K(e=B8Abr|C<`*aBdjxw4CpJHRQJ- z;8kxk_>{AF?)^j&#I@GsQ>EgX0~sVnZf5L{ufVUs8g6&Lf{uo9cd8(u!|fVEY#A3e zFfH~)qe?Y}gp7~x>YjEv@$mrA?Fh)@@cs52Dhz_SZ`reFV){9hSxM*2^_I;W!Y$zb zGsj%eLu>UkuZ8%OFFAA zjE;^aqr}AjEZp|S4l`o(0K?JNUDxmT`mYSdz2HzW=}JZ1JQ`}>F+H*t8KO`A@GPq~ zH=eAQPDB5kywVd=vd8M6bk67y9#M-P+fHUUY_@xW5F1#+XS)q$Ya4S(DM(|_9ZT%~ zmO?Vezz1vo_d@x*Bb$m;x6j|^%S; zOOBkD>Zdl2T2?0RF6+UyVa_e7E?j4#X7k69eEp@!%QlD6pV};m@iOd!9z&I|G3ut% z;2yez^Mrh_@27u9*_y29o3xxwl^LqM&Vh8V_veMF5Zc9=jRSN3q@8H5$zOM4v?AQH zEj)g5VWT6bOYZvE`}>xs%4r^@A-Xi(O)f@nl&#Y7qg3|tQEW!7R-O{;- z36}tY#1#A*gO949h_*$SB3~;u^n8{Cw_eQdCTY~`m_Iz@8?b&Sii$5tTjmhz3 zj~%fi^zH=gRlivF$Cyj*9sMha!|nU6Pn!D6>f8`gAN8_w0{llS3L)OhMrwR>weAc+ zwK{@RRj1&hwz$jn+0f$f=V>a{w*q8bH1%QRlv3|7g=nFWJZ;;co?bo}}vpo-L zy2N~CNxN>%NM5p1VE(z3PfA#-yFD#?l){uNsQ%=HZB5u(v zgbSCJZe-!eOEIUyMw!V^R0e_;>o{Y)bRu)z**NXD99CL|)XaFuX2tZx3bc4pw4_u^ z!(0t+b6sIAip*dKR@mg6tdyM8Yzre(>u$UdwT4>qQY_K0YCxo*AQ{?R{R<*H{aOU) z7;NZiaRf3H6XrDn2!S#J^!bAXyINCaErJn?dvK3z#=<%cK%;Yis9U2GJE3%h8# zT;1;e0UK8e#n^_2gHGG%J~^q%$UoPZU{4S5l>Jy$N0Q`l8^(LgEjryvj`Rd!riEsO z>FI0c3FxfYe0-o+MghOf>!Zz$@GS&D<I#l1F3YTe4cFsK2%QZ2>D)+zQ4J` z_Rvz3A5CuVA9;NXYEXAQW7v~Umn^v!k;zu? zddaCMU8lokK*Rm3E{oUU3Q+#Nb)H@FgNIII#Sus#o`JakLKc4LH5Z9sTPhv=Lm$M` z^d{P}?Rd=+a9-cK908!B@*npcoNRTz3US*W0Mfj|CF>tHasbqMz#{bq6)7DCo^q={ zUj79G(Q%`!f*dOw)6%zkIxemJDjDyk@h%8_Q+B%l@LPQ`=j9R+|JmE!#M{@+-aQ)S zhlL#)s^G(%-OQzcV^z{**|>!seDT4P!{|AnDO`+KKJ816X3wcdr(nn#~L z9jsHbJTr?4jQ;$L@;y!D&}#J~CnAD9d1|+_gSWE5sY02JWqwH+dsub}!pG|QIv?6+-^-clEQVki`|05RFKWR$FMrhQrkT8``B7k7vqN3IRM96AWDIV} z%9U1`+zR+|mEpE#AQDI^$jos}6S3uL$U3S1Z?&j-qWqliUN2Y$h$|omqH9eHm3#y3 z9m>=P<#PCaKE1a1(k;$$pd9Ser+u8ZHQPzUyA-W-8PS$H1Ar|>+CiMR2YRY5+C>|v zf#1N1HF#?yz@B7RlD(N29WK>e^PJKDKs!17LSzfEV`R~D;a!^E){d=))+ZnHpZY3} zG2X*=QUd$^yGO=s8@N#16f8I&z3B#2TsN@%V4q*2$l()*TBTZ6OPC@K(bcn^yz1Nl zlu!b%p_hPKR`6wjrHfCS!`b#?ty-z${^OlqMfUB{_XR`VM(=iOPpOYY`|^PvpzTuA zVxkLf4POnvZ{)7ZKD+dHXmcbn(>>cD>13zKa0PdGT_8hd1G3iHl{8Hto-U5Bfj6b8 zP*ReAoo;-&ZRUbM9REZ%4`C^(FNrZH`SD`(c^*aH`(K9$PMegG!qCU_FZN~mPD!Mxekx#Xa+Mm zwUoBD*{A{w)?16=!CP?Asqewl3_w_xOXZ#zYh$2JUqDg|fD<&lZrAL3zXSi8djlws zMO`r(IF9Jqwg4*gX!PCPbpcSO0RPe82G@=kr&`4|{|z2tL7+3% z5w+0hVy)4j_9xf5rdP&5^N-GPSsKZT@Uk>RJCBpqEbtOIv&mxGU!ve5WG^T|UDtik zGrIQgPT~87st;Y8KL7c7t1A!E0>Kma&GRj1;eW^T*|>hWPcdpsKh7Vw2=)TNZr zE_Sie(UvPse1$+{=rC9qRX?X9Dw?qR05^a^<624kX$kW6H8U}Z!r|lR;qe5hlhTYB z(?y;&jvPc@Jocj?9*(XrEcw7*k(dNnUW#tZL1}FQZ>3|yHdJ81vX1nyUI>5o; z21J!zJ$+Eo_|A`u_kg~tOccrt`Ett}@Xq+Xwz?*Qcqm^YN;R$Hlljf`quM4qy&30m z0HKb2xpqI4sriG5&mL~x4DcLi_rAYb$|)B181FAL#Y#3k z`1ROamDQ02evb@yWd4&z5H%)xLoogKUspyD2%xlMGxK6l#85CH<~a1<`vN*2OP{o-s0nD4a&a9*fcjav9T6<8C6+Z=NTWB=1|0f zC&8U87HQa-Upfj{mYwDt(*JI%4IWwo;PaHo7jiEKttBbg%R*b3ueY(aN=jE=&sthn zzf%+PY;Vn!h3*a*b$e9MeMsopoO{M|h?&kZn50Vt!Sp`1;3V&kMChN8rmzdgDUmN9 zPXvbBIstOj2Q$~gJ@6Rey5_ZbZOdv|!ZlC{2oN=lZD?}@GI2BCbnVONY*@-ZWm{q# zW#Cm;DdGOJYkTxEx#WS9HwfdVm}tV*8}1C+|MQ=b0WdU?44$#(eZQWWo7-yoC~b)^GqmX#8rrF3N#C6%w5x$jz0lh1uUf zUnw)Ssp9j7TOOaKd8FZdbteXZXu?H#OC=7!XfTRNh!uaOcoy(RSltP_q-rFmq%;Ex zwpgNdyV?AK!G#Y&k%t^^J7epcKF0ccgu51+rC@5qiKY#}*THh(L66mLaqMu)3y+Q8 z{pp9E-o^i6^yB8z3SI*m(uVYkH2`C1Dw_oZ0)N1?;sBg7vhH>j01swB51h*GSlvds zP-;@K*yZ`!Y7wa}jcB~=`IebiiyAuw>CgPM!9%nqF7u(w0}#vw5!Udhj$f%L z`am3VjZIgKj;_$?y{!VW#^J&JPnR`+BQ~q0)dLmO1+Wnrj_LnX7W)9&f6mUsM zs#bPTp8b;tGRiNY@cK|$lh0Zy$;B&xR>^9yeI@+DV`&nA9*W%UbMPh*5}jYzfk&84 zn?hgAQgQmv=2(p8Qh4`5({XBME?1y;_h`O-8+gXNc?{|*Jfkxd^sz87zV;6|aOoU< zjyy-uU?}RjXig^Zj3Z3Sv-U(w8RSgiqHigB83nMv$O@If=}OLiBfyVYyyz)L4wEpJi_6UH;f= zH?h)cENmkO;sPM^C7!b3`H67`SP4AYjt`%IS*rTp#>D#-;zC}4jB}ybn^NmHc%#$) ztk`^`5~_?(2Kv(hc_H$}Mto)_z&dRft(8ChV*s9O=99|gIbK+H#?9;g)Aub*C~mpo zmAK&-yLd=wPp491ugR8nP6CF zIn@V6>ds{Q;Jl%~nfdo56@ZQ72tnwW8sEI{^%-^6u%rSYJijC0e!>%RGe2teP1f&l zBAlJjZU&j2^$$(}&N~??8Hp*Bg}Yg(!PRDmKc@C?1ix}S?%MuToSu|Bts~rs!V|vF( zOk0lXdJ^lQ9*fR${#Qa`9WygxbQFFjmAB1l>q4%SP@+K>pjSL=lt>G2j+2avEun0v z`|^FhB8o^k{54>3sY9VF(9>1;B^L98NzaHTUb09LIzNmaX&C^z2jG={U0zvL z+P>23qC<^{LH&L_>W4OpVR*TRf&awLO*1<)0xa7w2#|&gnO|a-7&6V6fvL{a@-Pkr zkNNI$9ta(M=kTr8Y5FSu%oA|80-qQB<1&{WD4WV#ZWw{-ye&0Do7j%zHkqls^kKq_ z?d+O&?W0hn4E>{G8} zSE0$wSE6Zi%Pn6ry$M$S18p%tR>wu+$a zv3%;hjGJfWJ~y zzq3}bUf+EoF-^aF|0jZTr{ACB9QrFqMqNon$=okC2m<4z89)-vfn?`ZzMApU{_%s6 zjw())9x)PYM2*sSCU6z6GMIUvIiKP!jahjSE>Al0+c2hwe!tqzqRM@v<%5Jbufg(wPOFjpY)-@spV*F)ho{v@ef>Xxu(O zKZqR79te1yE{J4O#y{9dzZx$e*<&TE$0eqrz^zU{p$2;16W!fy2Lf(wLNWH$>blzl zrtoM(ZSo-X^VJN^@7-!^9M$LZ z9cDw-8@?G#px)zM05ZPNq2OEW5C5*Cbm7fWqYZjk7(>cxb3U4FJj>+ojK1hXFhnX* zz;gwS4p_xzXV6;q7W;_kLrYMi2I&)=WCeRs^~oD%V*|S{HH)ixBUakE8TpdCUkt>(kxpHgtnM; zAz>!7Sf@?!13PD6GpG8+^L0&MFGKe)X^~=ZzWup?&WIq7AAve!m{Cq2ZG5CP9ZEc; zwG)8J0)el&v2l(s4o{zU5S?lab~O`i!XQ$@^;Aeq;GJ(&XhiGWpFUgFpeLsGB`;)} zHI&Kn#^lKBx1$3y|HQU3-&j~Vui;mwmuac#)WyeBc^Wa?=!mJ&76L-9(wHR)EofR| zekL}B<;8_y)nxGZ8YEud5qvy&2+xoQa!j+sxkFmp%}`2aU@!{a_ljQlmFQJY5M^)7 zWTXZ9T?sa&1eqjsbqM)Vm)8|;mRL;g=|bWFL&IL` zR^7&q-`m2_viwBd;a|pnXEj4n&I#2!spM3vvxCED_pt2z?_CB3dLXE2Y(K%`sv^W2 z6eYT%m%}crJZX;?sMDrP-EW@UrKX~&-j^xv@aBx|`q*!IRRxe@eD0D}BtI-n=o6({ zbS9F&sW;61zc7CILfZV)w$+M{?*1SM6z{)RHELhR$0t&%R=+j4rmP6abGcl5)cAla z5b;6ee;0M5f^h-UEmtx0Y8a0YA}rj!2^441IG~!6HBKxn37$Bkkx*-)i^z3^`VIrx+S05 z?syBu9XR^V*rzwBdsKZiCp5YpJ;z5ANjuBa3;?*|$;{M!5 zvIs_DthCc<0UGrTOTS}dsqFw-HLQ^Nx$cWA$>;QqHyI9(#qycua0g=O#>t_cWMZ-P z*f1ZgF9MD3WKA=NT@#=Vt4WKk1Y$ZtV`%EzP^{>{1Qnz%HyexGkGB#jwcHi|O7x7h zj_V#3GXKfnFPm46;QV^Zn)uisuiByNmyFB)=8*TtdUIK-5zKhbB;N|xn!FSCZ{3Lw z8N=$Q^u5stTy1tZ3hUMpqW@r{A~J%1#d5VU%dG+#jSUvoMe#UrYkU>&sb>A(OF?!M z&v#}@&@qB_CJkX>*5|g|Sm-m9EORS0jVmvUWwDxuvplAmLTy@!1#8*BcU!*xhdwkN zO=fR9KJ#SqChD8vk)$Z@R~BX41wTiNy7el`p+?%I4!04>7&?B{$|CSOFx_k&iUNGY zy(pcbBIrG^>P)L+K~^$8vW5Bfgs$q8iL5V6okSd#CwH1g6utnx-N@EL=+qWdn8{gH zUZL;nytO(>y){^7L~LZRhz#xM2m=pGg-M`Rr(A~JiYFC0b@MtT}`QgVEDc1EI+ zm0e9kooJ_G>aWxvCN|&^Y93nY4Zljh9)-U-P#BJ(qocX=60&$UBN#K|&QDJ-hh3mN z1nIbd3y?U?vupEs`pjUEPc!Wr8ame11GV}u)4#%5|GEU=vtn}7!q1biQ$P_mmooq* z1Ew3pS%qPN(O-}OBYGZ-f+c+pWPsGgPp;O>WBQcnNZ*ARF0q^Nj zLTME@)m8Yfv+({sGijQ(2d0WhAS;ev2riO=-~XB#KqVQ!!+ph&>4w3zjz(B*(`cS& zeB%HwhZdp3`O;i|G2U6m&4XcBUIM>cO52kMV4_^)RCI-mJr1(2B>(ge%okQ^v#E$t zh85LC3Xw!-o#o+kLUIQy%^24@8N9D>Tng@;oNd3*kihQIzn~p$pOS3bB_!k(ebZYo z0E@2+=pdMCb*bJFRjXp?!Z2T0QSxO&CS@~N5WN!NZp^JNHs6rt z_Vx=G<^%nwSXhrQzeHcE{E%AS7#hOs!M$Nsq4X{*-@uMKxSPtwd|w<>E2cAYZ-FmT zs)s62T!)D+wKMB`hZu#zyWwT%VWdEUT41Z9Uu{t{@*)w!@cuIN}tViHrbVK_r`*E{S{sx54eCs4P@v-eY1hUO5Cpo+8%=|3s!pvx2#kM zwfu9MoU>-c)1G%_#jyIak#$nc#xpyt3wQ^#hOwUh(u&Xwe-E|VC_D9yQ^X`>85xeH zrACdQ?@$hKqF0)1uJ*V0`tdHy9kIbTZ=+BcPs)AZPr3Dzr8W~kiOr_U8IYxY-~ zFaO{pIq}csoalKbeFT-gSs=584&5;0K^ zIyXI({%pMECsY7{dpZdqT<&bkWVPjVXDg|I%atH2K?LQ6nT~$=E z$2RY+5_r_D-La&+cYsiQ7NAIwjk72PVoD_PuJi=oI~(~F+TnuqbNl9IYvk}Dm)@Ok zH!B@AdceRlh~)LIGT2cE3Gd{5d7V+kG0cV#RBo!MY(hz-$L`k*cwqy9sPVv35ZY7G zd_Pm4rI2HW9hU2bGcs;H=jHy06h}m7JEq>Zj3%ysB_n`9lk7tKRtq0|x7KX~Y%hnYQ>5)VlF%E6ai{p$PGE&J7XBhm+MSh?M@R*%Sjm zDGPi_n4Bd3VTevejXRzmM2B&FuJC4-)dprHJgq`Oz|;0ehQ~vY6Ld!&r<5DgBp#v? zlhelP1Hm^OI*IRlCyOZKh!Y&}6Lz<6Y`2UHXgYglOHUyZY@!&W3F=wA`nti^K@1=@QL(9-r{1n_x3zrsd=s_`0 z@58@;;ziHdepwyZ3JiE30!H24_PYI@Et!CMGgoQl9#DsO?}FDlMA><}?Ahg@YgT=f25kZ`aZjQq8?C%Y1FmEm44qo zUv`6rkDSEbv`T5BPR88Mg$O}Jo@SqM24+IGTO!*+IbnMU0@~VC)*~<&i{8G^DC@ut zGzvzxwY8yV65G2j$y~fY+9pgK(51xv$SPSfv2}5AapJh$9OqbPfryRKSI~>2%aKv# z@Kk%3X5l%xqAs-#dvJF93KweiP~O@X2O4_x=DfV!PLf10E+`7xw(r=^p~)=Q#($>}Pb-g$XQO1C9N=E;%mbV-q43R-QQ*pC z3(9}0cJ%(@Kc7iy=JSd;M2;Z1-o0VEw-WiSa$inbp3$7pUsC9_*@;|*9k0OW2-M_q9bMs5e(L}0FW;nqb}oNR3V$@5E@m*6fG_t8Gxt352VoFJWP+$+ zoC&YB(!Ax1oBt+Qd3whzUOzHrWc8+S8{f5&Ks;x~+9XJoR`gVD0SyOX&{aXv+Hef> zbu)B`mF}GN+Gu);o^SEa3h|?gBPaUp(Q5@QnbRb{M!RNS<4yKy(R$h2@vY-g(X{w& zlBAfci;Nj(VA8DCV8~P1m}g~oR~yXNY}d{Z0sQKau`pDQk7vdtT$_k@AnfsAIWKyn z@P%7EM3lYilkcC8l@>&(?x*lIi=NR&S3(BsrGQ98->j~5UaYQh%F(7yA8g#{p7WOr zfKUL8KU;%KX@FiAGcwx3^Avd z{4eKc`(o$XhDV+|jo9KLdMZP62E2`fy5@>Vl%WrOIq&9Y@guX*cG>dyYFg~lp>;;A zMl*y{*iPS&<9c;LttAo6a%S|Mr`XGl$}JiE?>H<^6RAgrw(PfWNdp>ob8#rC-wNFy zPLC{+$PJa1b+JLt#~sK7TUZ$V$QH*Iid5y`BG~CxQDGtT<>DpmibmXthrV**n!bzl%sAE<~Ukfj?dOaZbFOLx^oY`OtEzSvYg-W1I ztPYF13JfIVfTzj)52x-Vj!XM&99?u18N*wf2aB{c14oLKZ%n=DAsAQ#{B6IzWy~3b zS6(YNnl@-{*Y_z3s9m5{KhJZQzs(Lfu^KD!O$!=3jM{8{D$TUA74X}eS=W7$sY&OL zH2v_JHE%E*77x{H&l?@+IjqS`_)8rfN(jLmW+=*TRE@$`v23=@^?WCWfs_z&PDSaHdz#x zw`~IkhO@1K#)ERZqs>3}^bAA?<~p_aZ=R;fYyY6SwxnxdirpS~znA!@Y`u02G zI_!cbwwkrgze_4%h5BWn@|Zw1PVySNO-pzwVzs1r>lg{(HA>j*Br~`%exh%leR`w$ z)?xu}p0c1}6A%+lQC=R4y+fb7e5STQyf+L2_wj3Oy}0Q6`vmc^{NzXmmEToi35x)= z+tpoZ#8Jc;)7X`cd$i$2wCuE!-IDc16ZeaC$FrB=>8kI8Eh=8h9;*r8bNVx!^|FQJ zv$R@r&DxG$YaA270xTlZxe9UUROQ(BdS&4P7?%s2>5NPzhd_BCuD!Z zile^4kbIwZW}PQQ7cb=0106-?>JYGzgxtv~%t_4rYOWZTi~4gkc(&J{Ay5>p%;tudhsDq@2wCoAvU8)9G9f~_7l*JB zE14=wA0;Q*ls_!hz*>Uad?Q?B*TTki_Ld<_DPT(gx6lQcz^Iejo*>k=FZ17!J*>tYb`g9yhMx zz!r#Kse!yNpQLCZrJ>~vjx$jKLPEE?iL??dcoqikV?>x`D$Bh>H7;3~G`v59M&@Qd zDl7E>xAJ}s*GUE+j#3H59s;RnuOgA!gkF+>(xMrGtQ>>ym5|J!B>y)Cf1>ZTJkv&@ zH?soVKt>ZTzeFmBld#bDA;R5o>q9#6;d8IdZgP4^Oy}1g9SvXr_4OHV7JX4%L~v?Q z=yYEykcMnULY)$!SuV5OJXzADeb8**i|x1xppvDe5k6LIbkO00wApkE7bMJw70(&a z*u!|2(C-W;l%wRGmHQFXb+d!DYbDtkO!f#zW|{L4QR=RygptuqLx+e^8HI2rCwC=* zwJPYPpr;T$N*h%Tyx`iGGZnnl@ZsM*{c>TGNkzx32-L6`GlRGO;qN2D1X81#uF z#1{6eJ>BVQdGDpiNS0yJ@;z=oYk?)<$G9LXRV5aPqWTk*;`ofn&ZY+-{9A1AGH8e+ z4nDbz&&-7dWsKy*4SNl@HO9Bk5~Rlsr(=Y_i~Y2 z{`1#^2~1h@5MrRmvf-`3+>{)Ei{5424 zEQfV68Yef$$aI*xWdB!f(_O?b7ux@x_nJP6F@+TffS{j-9hRi`r4Mxr3yxy% zy3uR&qV$OIUPOnu(@j5W->JSqkWmrPZ?FA4R`l08pqOp3Hn|E{kRfWgJld&f=F%ME z;eL3cWqcD77%rgsC@z09NgFkKf#!w}iHVCIN4!g95eq)AS!6dk2UcUq2jYn;-N8;yyEv}q+IqLiqq+vaM2{5J~# z7Th(xH+20fYW31Rxk!C7s+vE&!20ph;c2GbI9J9-xXERsRGp!V;`MJI*4!RZ=UeO8 z)s0S<_&??ZzmPe<8dPi;{qWThxRuypxwuG2sn?A{kWr9hk3y~N-@LF87&ELcCE^q^ z>GQrZ^Ft zI%iSaMfdsd7_PCXE*YYx;Rr!j_e@=JX?bjgFT8nJI-Y3l-I^nguz|7i=rs6HEaLGP z=sJJ-pB{;06g(%+y`)e^4Jqbga0^;$M*n5!H+gB(Zk4x|=62b*0*({$2W~fgF;ZnXEtqO(p;F;f)2Xf&>RtRDX zyOv#(>0{L(LvmpRcBZ9*`n#cg%Pu``0(ja^4nQL%jGd8D$!*dzIg`ekPWxDBx32@I zKbxsjUWZ zl>BA+vPF;S)1?e>nFnB&m1aN9oup5=YGPHF6a%fD&C-CA^sHSx3uLQ^fv2OK9>B$uH&O$ zTfJUKQjINgoAfJht0f(|2m~}o9O0P~;)=k-D0~3HeNQf4U@LVAW1g{MLQNl8yvYTY zbX%bEd%OLTq{idoa-N>b;K%HrYLCX4VW>15;n%@_AvtX~a*Y_+7=`{^ZVGZXGFnuJqI({Z``RqgJ`*Md|^nBgzNoUaey z>A3PEx<&IuV$omT^IKjr#4zMl}8m&3^cZ`rJBliE=x{F3D7SGst9oH2a&XVX_Q z1q?D()047d(`|8*+<>dI0Prd$@3%NGU`Dd;bjj536qXGXLVG!4o@9RAJcNv#_ zJ!KG@dnXwkq(F*7)XB1!Fi$t%Y(OpdRih5j^TR>;ZkaYIa3rNjHS=jegRsaM*Oj;C z3SmeD%Hd%f+pe9oAQwjBo!?n1RNaAD)BojZl?PdAOtJsUiJVKIRxu7?o>TXej2u(> z$?nM(7+!ucl$rjYh}Dl0g-393-)^JO4|Waff102#oP=Mdj~wU_e2W~FDGNM zuAA<-DG6Bk88EKqUT8kjXIdTOtF#L3z4`WVqXf_CN?cHfM7Y4a97*A=W00~;3XCkC z9=^>k^eF5+P1`$nl}wM&tkOU(SumCN_unkIms%&x%>A>8|?Ma;yevWeeK8gyL8 z>?bhldC4uh5xt?a=*-w24W1_qUvs(>7S+622X7nI&_b?S?jc-+#xBFtr1kTeLi{h; z$zqV35T*T(-;WAgwm@f86yD6nzPeDuqa@Rrg-B+gI`=llLCInBtw3ydJ5*k4N*C%w zO77&A1440h=YbyjjAzwnjnLez)V8%$P7Tq}PuF*2YaSY$-K**TB*0K9N%$A3*5s84 zW$VSdg0X4pQ}Sxh=%kGj(tb+Ssb0(542d^_v(rJN1u*eY^YWS0ypvE}I%Bib*CP9| z+=Ko!?G|SJnXGdsCewibcY$kj4pPn@JNj^w>!3(A57qv1T&Hw2VY#8=ge8t~vQ~Tg z@L7+e@{e*!*7NcGJZO286}bKd-L*3P2zZmLv#ZY{Ncc4!9<=e%KaN>NR6+G|-?aG6 z<%dGX@_`@8H$E)z!w`-Zrn{1-WOSJTxl$C-7C(>WyMD5B8Mq^jYmNI@47Z ztTtgzwo?ntu6i^im*46OPM6^1WDBDe>%6MV`76rZMe3Lm8?oTzcsDp3qZ-Mxg=4`) zUR1|6gfS*~Xt*G+5y)4R?#tDc)QXQZ;K98Maq);$mpT_{FTYGD?kzp;8{~sS0XDQ;+ny25?D> z#DrB(J;$@dLO|7VwX#t&)a@;i=Qp?TNm?5$u;&Sjdb-}quwqq7s!YT+6PKnQS9_+X zRfE1vDRL$2i_6pLk6=x0xjW}C$u6Y&EL$-^<_lE90fr0makxYy6K#REDFtKue_$0# z5+!FQB{7qKs`=REx$uYIz6E}TL|Mc8sw6B-M1^d0A;i2Px%(i>b9lJ^n{sbPKd4~! zr=glZ4(1@*14rNeTK{0WDvd%!M4VvVqa9-3|HIT-hE?@1ZCFJbM7lvrO1itdySux) zq(eHTyQQRCq(Qp7yL$th&AUA3{hxC_aPe)|TI)A6&vVa=c_c;P)s?Fq#s>78SS^2=Oyy0m^?j3RaHZ9coFEmWNOz;#AU@Js_L{UxS#K;_? zHL9;rV^GVm!|f4jNoCBYDybkkN_sXr5LXVo` zTGGwH$?~m&hpO1o53At2hbxk+_;`1jH9H>*EhBwbNMzmTlkJrIrQd80#d$rjeFKFT zIyT@_HR(Wv(mVy9ehoFH{QVS-Rl&9x1v!`chRr8h|K{hdjo?YmD;|U#E9Ws0Ibw!M z9YIWk-JX6!#;!N++T?tpzVC3;wcawp?}t6j546tX6hUQue@`j%*lc$mjnS%k`0$gc z=n7@pP|Ip}jao?YUHL{T4v(~F+HlweS6D$=0N5;2%jGSqw8XnENRToGg%f{YU%F{u zf@eUaom@{SQ{r?s^!b@t<*c(tN0dmXSlg3lh&jr<%c-0cA@j33PTh`CB||ct9cv1q z$xZ>?kJE?}N#UTN?Vasxy{s6yv7yqS#UNl-bXJyLv>f8)AWrZVGAcfyy-{`f5sH(k zVM%phg8`!R*xa7v?Yk$6mFyk8kNEB(9KG^KBRT^ffvJkW44itsM$)TCvANo>Lq!a9 zGFgPiU>%tIMU9J!4z)rY*^k)X7G~I|i~?ftYnxlQN24@Y;6uD8_KiXSbcN{6(1U=_ z9X)efhF>Htv0pAsI1&9uWNOCaviGgmqFpoq2`wMtg-&beJDHeS z=8atEO^>L}3_dypre?mTtk~pSyKYn6VZMkF=>nltM-==#>kWB?r|IDg%na3f5w$xuSi%RLhF68PIaM;l$ZPA0;Q z-YzwU&s4~=xbkvrWZk$r#PxrFqFZ;`upcv|0S}`AC_;R%=Y{p@M@w5!2o=N3I2-6d zOa%}d^t251@d0nOGGt@BzM2j8tqHP#pJ zW`$j0WJvMWgViL70S5^tco)~>W^jl6!m|VC`}NIX+5|7p!>L-G^IX-5x&z2YtTien zqsdmUPC6xa&3`i}o68?Z!tQJ}l#CV&^0x7OFu{bg@xzx_6Z%$r(p~{w$UN{huQHxC`xLE}hgM%}TnKtW4uTIw&re!Y<;ai6 zR-4DsC`ODpAz;l)ZvNeqfu31Yof`U(tK6+h?>#nW7pvrjG6NDr211zN9z}_2xyp{6 ztE`!R^2PrC0f44gE7LWD{8R%~R26qRjyvSU5ewIzOxJVpvuYZCC4SFCX^}=VSO03- z=CXt5E1g2k^OPn9h9ee8&e+R-9DFB2wlkZI(RU9y!~eXi?_g`S__y}ii16NOb&b|L&m?qXb%-u$Wh-Vi+t-# za-d!Ql#-o=2sisgodd;6JCy|z{bq6bAsdei;wZX?y_127NfA#ie&&!y-@V+469JQb zP?N1gbx}yjl8S$5q48dY#&~YkOkQ0~6&E7A|M$xoIy$aM zP;IyhAgWACVP5ST zo!vRBWWN%_-04bMf&&M>S{KJ% zFx1NnceACbm6;#p9!gKI+-#a{G|hISAvWM_Alv7J!~UIwr{>~jPBt*6w*2)FK1uxb znl>}27X+cYEJ5dfdS|_s!~ZTs_-%7{^z5fvSzVFbw0l1$R5(QA-q>!~(4U2$Hi~~@ z`%~6I`Z}2wHv-L;`Bd5sm z?Kd?y>;L>&Cj-w0KD`N4UIetgnwe_StSAWfjs}wzcLQU7;ANIp5*?C2yBPU`md2W2 zc)wZ?a8(5Y=gp~3e!28L^gu8(Os=qODG%ji&sVgW$q>k&pDR-&l5o^}Rj`wTUnLSTA zJxum`T-JRw5{0)Oduj$xyGggv^4ux1mc+Z{!syqGEv&kXw&Q#!fYE7I7GhT*o{V0G+Ab;)t+hYT2$r|DjlG`NtT4vq;2d6foc`h$5h(;# zhAZYyZhj;<;jmu*smH_V_(QLz2Uv<792^uc{yvQN;h_tdyIc!2o$Q1mFLznJT)Sod zJy1)(vY&=@ur+uIyu+06jypw}7z4!_nbx@KITmDKkYaeFxj9$7m4F882B_& zh87#`{oER4ceL*3kgh3lbnvYQmMwLe^ULbt~zZJR=BiD}yR zl(>>+rW48^0`rSJK}hL|P}25m2b}YY$;)=BkIOebY?2biSWTalSOei z;6@jWuGRh`GIMs+@}((O?86 zLsvX!p%M&RmMWD90e8}(y1&U7h|xt}KC6woMfXA`V zXC8P%-vwqDd!1b`iC9JpTy#RpN(*B|xi$C3hk7OPdu>;t7f5~6VAzh%kSaE`@B%jbLYvdWWZuBB0gMd5NV7v2o%ku(}(uGW>-e}u{Z_+u zXFt@&5QEWNPeQQ^eljve$xC`)>uX!it-6?qFJA_~?s)K*eR#d3A}^6hTBe$gKU_Q@ zhGMkmdbn&Y&lfs?VT-xFlQa@4=Jh=Z4zgA(M{=d0zoIgwqcgfW##}qKgR61;5*-tb zd`r*g68!Rp{_jTCNu>QUo zSv7Z;lD%mA(0QMSIam=tJ*rw9b{7q5yZZ{}yUi*OUuWrTgmYbKOZ`OUnN30E1PRxu zd$-Cdl?rP`D|4r_m;?Q;d{p-R5=nBo)(qLeOok5qr}x^K=?ztSbE}-s47|mD>T>2O zPaKu{Z;6xfN4)lbRcDy0&q38{vdE|k9AXGH#C3Eq2qf?u29ugx_Lz(;sas=1rIB5g zm22;7uI8xr%7e<7z+>_JFIgu7KgZbj zd%ofdAU(FWQ7v9=@Xy}Dc~}@nrlfa(Y&f!3Z2LF9EXrGZ9{w)GrjXHeS(BC{`KZ<0 z2Hs6kkbJ)_VbDAJt?W&}uTK;W0tJKx){_rMr;KVSg8i^~-W-WB0s@>WP@t%L#r6Kc zvhy9^j(5VB*fybw)0d^^UQK06Qw(2@PhSoi`bpV4K0dyrwSQ9%h}s_`@*{}*0i3-C zo*=aJ{t4C94W+0^34ET{OK9m}_u~ensp3I~BNmCV;Mg8R-<93in!SO|L}NV^d~AFc z`^8V3wzhV!Yfb6i^VJJ^55IcIExZW{1a66c0b|$u`Jc6L`0vZKgHp2y;<>{V?_4>jMqMLKN z!n!m4dP!GLZLdo2kNT6Csx7&!rk?W{!EC%&bI-Xx4O%xhhIj$S(s(ff`@7~4PU3`K z;wyorpI>kKHqK=pa-?PDdbRAOzU)Shl~#v|5B4C#CVyBB{eYBuCctNR;~x$C+j#r) zkC%txedt>S1#suO+5X6Rj15U6=eHvLM;DmR-L%H`c(Nib`;ipofSRw0^hCOllc%*r zoA&11PA0oi|FSodi-##I*hm**gG3+_zk|oja$H<9g0qivSv_k9$Y=1cb{5sI4aPcJ zlsw5O7+Kz!G6uT**+UKW=k*%6hsVa>W0YokS_K}g)iva{-t@(NQ8-(%Ix6dOzKhrz zIhXNZC+LpuKJ~k=7FK!=6E9$7(*C7ozqLDr89*jT?X$AcL{!Vf5)-ZPHKf7RlsNiL z!1s2%UVL&GDB;zosr|>_!gMR(oedKg4}u<0g*5fSRpl!@Zwt&h83!?mKPR(jx*}}# zD_gx)BLXY%kOHZeSI@lZ-I~zdt-1 ztk>Yj4~3|pPvtli#&2d!#;vJ7SsHricS?+U{WRkzyri@8yIOjj-kV_H?V@lusHg-3bf5L!y-$rmo}m6SOn#}Z zCO|RWtRmzXwa@zECL~TBN1G z`_L&Ys+cAZ*aj+)XLLrWbm>HyS>cltBBmOfPqC?~xqEsY8kg8R*TzQdb8aDg3!&$( zic)T+wFgWa#W`Bpx!?~=419)Y&IrS3n1R}wV3qp{0<|@vlP5Dc?XLgV2O=}>2o_zwp^-C$RGDG zdih~2%*`^&puS~P?NNo*;a>4X5zbR_voUl(rf6NCC%nv4kOXNxt~brj@A-lmE}5l? zu&@1kOkgzP7?u!F`1T)#*-x3pNRKZcZu-O9NII1UW9gBcrKtVXiI2tRfCz10Y2AEmxGI10aviv9Ureg&?}5av7I9DA*Z%7`Dic#Zf?2iJPsmKxgiYj|Fp zEPkVb68h`DYS70-%>Bs6WrA9q^YS=Bg?W#HU6kVWAHVsD7E6Z%CN=@zpQD?2As0001-CR1FWb@SB zhc{o&|5$_aMCyeaaKIvmTZ7$}6H??W9vcRV+2*!G4MUWWv+lj3oZE5tduUfzGSfOYD_VC2Dx;7P803mq_jl%(ook}y-X zk4?kJp^_>{GfqZfV|rvopGcAz3r?LceuFTt>3P*__PQ!y5dw+A&CpWq=+z!ql-Z=6 zoz8Z8^N0l~D|GZy={DTI*>-IJx9*#8aPS!c!Iv*C&7nUOTd(V67DR%AlF-lzva)2y z{s6VZCsCs{Ykv0A2A4bD)~=eCx)8y!Rqm2`OtK3gw+RPrHpLvlWR!%#xU!8Td2}`H zm$>f9WG<_fp>X_qW76JK5->j_!7t$BrEW6SgI=mZD^n9CIg*!cA~591ecZX*O>qP2rDr}WZ_azdqk|MU%nD9ty|NxzdeMv`16vI57onL`ZIn3M;7MH#L~n)(=#lS9h-@D<9K+EHX3Tq@ut+WvEpa>ehLbXa_yY)sU5}T zQ#CVb7XV!^_)`C29xE-kqwVQ7CcsnGP_*F*#3VUT_w5VJEOgskp$9+sjq~5kJaW0K zh6K1xb4lu@iCr^X9_D<85M1-ti;d)1F>AiYhoy`aqISz6qA_y`3cRy==RaK~gb7o!_W>X<+2s?Kk24Ta{9f(YlQCdnABBO(nvL<`2h zkTfVDI(xI%#90Wy!ddjnC(fLBWea#{;3Y1MXJ;X|>v9xeRwpW9g>eQEWcDc=+43!Uu7D-a>)KaS63*UC7bBuWWDWSZsO`;+O2Yj|_p z_B7aE@wu<_X}_{Qz~0gBYZU}_@0Z%Gzq-V+gP_)EKiyUw;De`R6^%cRQ8&?C z(Ya4mQ3jB-wV7$Fw1uOCrkR;rR9r3Jk8my-P0Az47ugDEaS!Z7$RwU~PwWlN$sY@5 zZe0sY%n}?UO4NOveYW`!B8G&$GG8s-T_Coky&D_4o55EMm)WdKk~cYm1STFR!|`J) zQ{CUvaZr+Hq5W8PPD^Vc7)nrY4Ljni-97HBq+?aDkwQHN@Ux;M^?3(bbHqWvpPH>F z>-61*fFeA2u1}$K*l}!bEx%7MUHK2mf(3Qx;d4eF> zDd5!*8r>-mw1A=!0u+sLwDWmS8sE2rtA(Y2UEU{g2FJWR3cNi{3WSd$Gu*7vfV)zX ziBUGt%5Pj$lSZ$@x{Hq&$*TCMrXUf%QljFwg;C};fxG(hKDmwV$MM81nP~ z@7p4C$v8|s#CrH2nxy-y$HZTwW@!$>=AA(UB8N5$xt+x`b!vB4A-k#_%parYQ4Q*O zV7M5_f>$PYKxdS*A;bw)4WTs6XMf!Kmb%TLH;Qvu~Qv>uGc)JEsc zUD`llCS?vFowc#U;!~qEnp#dP}C@QDgVlRRE3bXJ7)=9%Fez#tM z&vrbz6!$KZ`0pOeRc`XnGIjm0y;st08pX$|HK(0K2h9uH8g`}21v{>he>O3NFZTQ|UL<6icyOB^bSw;+B=VK*t5Jf#LG%&4*NBGn&1=y%5@hD3Y~l9mUcK})hJ zDiv+&BV6a4l(j5(dE!R5>j>c39b5PJl-`J4HKr{d(^o4|6&ftF!X+J-@Iyum{nK@Y z(go~DFUFiXiod?7UzoFqT3!w~LYF*i6dK!gFcZXh`jPPmtjtGAfgyVP@r|4jRxKO-6^=K`RUfd%yk3pLWiG< zx6pI5m@TJD`hDb`gR=j*doiuFt9YOIb4WZ%NeotJweLbzCii0mDl^n8F9#=yKie&` z(;-Wg(C3%rqH}3rrE>C& zE-bX;^@6rP+%#!#7 zwC+Lh9EnHj{7dGk6StoS%UEm=D0P_Sn|k~P5)bL0CfPK*+}MYNGEOu$_iLQ~caU)# z&DJmezWiPf)Z&pq===R9Y!~eMHzfo|L4f33vVjrD4UZS{!DT>5CWZj7>K(*pcn z>#Mt4h0!lY<41KcXLR3&?^ff%$s2|A+x93T~34)!>7ZSUpu@ z&9?U?@{Pql3fTc5ZNL${eCigjN{09=WE%d7i6u|7|co=G%GFquijpUd=| zbuW=)M0u@^4^_LlY0O4SI&lm)=XK-_#DvoE@EXdLt}a*>h&%4Ou5a(wX+4;n6$^h? zG7uDp=8X``LBkJ}k%=TrAqMhvxb@Q!Czl9?7Vqsc20jSC%>EhvPCi>EY#2)2m0ONfE;W6iJ~H%fw0IDJCHLO}2i* zD6k!ic1}|V^hIfwP#s6;h&PUoZHWn8L=8m&4C`TEgbx+;L-hj*~loH#ck+8JGD0_%82Ojiw8*bNtz0Tq?U&rxh;|0)A| zzg=waq_vPe!MXaX=o2f=4XQoT7IY0@maDgs|9*AoT?f)pIqmLnOLfjrpGLXEB^ama z5t?%i{8g$DP|<9BnI%r}(WmD5Kle`_j|_Y(&J|fSf8|4c%nG!)*f`kH zo{fNYpnNQ9Gm>eRcAjsi@|$GBiFP1LF>!>10|i07<@BHz1dQwMMYg?CHM$T+xZ+g6 z+4IY4q%t9z$Ui;dH73|fIJ0r{UoR}W5{1%(4d!RVk4u$NMQr(F2UBtq<1s` z!fv@G9+yymNU_@G&n1PKs1PiViMq=+$7v#%wl&(M?LmnA01SB1AR1u@*U*%HleovN zH57Ta8U;EC{gJQ_SdR#2#32pF_!f%1EBguBv)CoUDgCou*`FVds8WYH=qTxa=SAc& zU$7=haxiJ;@w1m{7sG;4Vzq+XuFll*FPjwzTTL&9sWb1L*a|SBS;y(qnN6?umkTY> zmo{VMRfW)x*=^K1>s+?N#Q$uZ$x|sR%UL5xsF6@TnY=NdzoI;+&0hR{hRfX|M;4t| zft+UVaP57d%(@JdG6KF?IlQX$m^OE?v2U~4|9&cpz}8vrjgjf(4n_!7Yz}$`bQx_0 z{dXSq$C#i|qLleBY`F79+FrBc@%@=aIzbSJ#UXh*M)RB|FM$OnjaS|X2TQ3N+58`t z#=mOKbS{t9Tjl{NNST=RyTQVl`-Om1o5xtXov0l$jrqKyg2z&szY51iFAHox#H9VR zhgmm)A_hxnSGU7G$p2=6u=Nq{t)k+pta!0sQ{E|OKvL6N#emfd-I9M)v`oKmLA-|; zP-IttgZ7ZqoI11&u7mD&Mz{Jao;Cc?f?xB!9XT6)kO^hvJ^9E&ZMdQFs=Cn8UhWl& zYKFosDcSHo!Zz}%%^+;oM@0>ivCwji^B(jT#vy$GegDB>DKEK`F1sh~g0|-4cU;_p zjLYTh)lw0s?&X0W%RMjegY2P?+!vymtdCu_yXB|gBU=!NefyJ=0^Ik22V(U)xO5U0 z6~SwLGKZy^uJIX?!6Ng#0oN&QtPB;#Be+NhCm%YXeiWt!_W| zvd*q(Lb%;+AvMy-nHATizE5cA#gXC8;iV&hz8qMa!Gu|d@kDR@>D`{a>mF0{)1QA7u=A*6`*R2^M4G}&H~fHvtO4%T9* zGAnnxl9inL%J~3YS($7H?UI8cfKEZX7~=WmA1xq^ij!ube_mT;IQtuRKc3z7X>{MQIeSlS!kMwAb2oQP0WMuZu$C#kfa6eUGKRl1^;fs2nZ zVCbv#mToh=wUWd@ zB9Q2-fsXl3qVgqH{7G%-ncB(#byb*|XTSs)lc9xz}VQbN$D~)y1KXTA!-2 z(mXbKXW3FMcU&16q!qhmbqDs|Q?qz>rs@pUqo; zg5RHbNCg#tizRN#L}x`%CG3Po$~Zd@h@oWDor8(-DKym%mO*$OpEU_&2ZN zsxBvgD$M<90b4qRJwv`BF6LWP?N2g5;`EG&`OwU`aH}Bk7dMOTYA{e4`=CeI-Fa{h zO%gv=hz?h@j7RjznDiqJfS@QE0Vt%!=WNlfAu98h7o*2b4L^@?a16JT;bmSVY8j~n zHyrqM#jD+t_k20vZoU22r1d#P4X@wbZSclcTleLJ^FV($T9ovRl66^EUP3^^??A&X z<#-{tI%RT-uY)%SX~$I1(gI;G(1|P!A;MfX_6Di%_}+9k{V~QdAQ#4*KC&()&xW=O z{CgE{5L9GQMEQ!T8E6nxcK|8awV~;2?e#>cj-pa!%ihvhp{9YC!Ad2v0IBtQ)ou~D zG%Ej_?pb0kvELloo&eg(y1ShDw=2j~W*tc5nj<@qdB(f4R*~9I3*D>D+IVHQDR}MV zf9t^04#!F_(bt9afXtn!jr?xscWzJ&3>)0thm&ihjW?-`jFuboVF3Ut`XbF?Pe@~a z`1~>^9Pnp>Cs-o}ALMr4eka$l{FgCGkU3xN1n4JSSbSF&oDJrV$5wER(M{O!qlDmJ zrK%XvoF~Ev6Ef9GYfZicUyV)YDdYMA%S8YQ@{$C2fvcD6D&9kX+lBYU*KRleyy`c> zc)qvclkgm_#dJo%!ykR|et8hu>)CTWjn~MOehG=^ZMI07M(u(5gwx6Xo+=lYnBo)u z*WD9vX!<|&697R)*JP|>+IkJ{aFqfI8mMG=X3(orLrVX|g{$dE^$cya*=W#!xyGTM60i@`I-^_ttdIUD=bG6jOf&{y|5&tUB|cZyLJMMUi})C%;3+E z|9PMOz=}{~SKa3)vZFb<{4M`w%u&*JN8M7?^515*=@OBGo-_ zU)eG$HtxsutnT}le|!$JatAJ$Y>#{73pu_!!l18I$@7Y7Vwq_oZYl4d6%1o*(Vz5a zgqS6bF}G5#Z-$4+H;BUqyt>TIKmHF8g#mjE-R?}^y3*|(hWa8~Fx-)F>)O^?=o<2a z41W^d?AaT!Pm~<-Q*5%d=kZLhEx?v-P~=X9WuFEBy+_#${xwpraMWk=4Sl=)N_$3q zYEl_kktk$S`ZPOEhhhaeNfW}--r|m?NCev2zm@Nsim2dXVnurV_6bQ6626C!%cz)$ z0MslC4gb`lpn-u#xPOeC?D(G%pq!yFK$rB=77r_qWCCuhziisyt5YD{X*lRn{4G+G zrS1z|7`8yRnQ$n3-=|c6AD2|s;~!vc^Io9(=%UU;<}teO;DQfGBwUjVI>@Dz&1o-p z3=r`2zqGl%AvS}RtL8U`c{Hu|>Z0bb{Mn1Lga68#>nje!ux>hQ|J~&Rp7Mfs>v_c$ z-zDgv-f-cHM20=7CYtcTewK(W{68NE7`+fZ!ql?MB3mOPBlQ90dO}i0#haa$mX?$p zn1vPSTgBVz#l=pOh{3{yy5;O4G)z8lKu??;(N6;DDpCY_cS1niKIVxV&&}aY)qbzr z)JDDv|H|}$%j{J;y$7`8oj~HIYMqxZP4L|f+F0&u2zQf~Jg=qKqKf&N>6nMoXYX-6 zx6o@(w$Dm+uv8iJl*h+6!A1hA?k=PT9Bd;kkmX9g|M>|oaQ4_w$#Z+c*0K>x>4}z1 z4pf;*$!kaR64>zAcXDGspe+5gOl?h(lNHrmOFh$eyBHK`w?8*k@?NfynRnukbaJ9C zi6s)aP{8+*xNi0IJ*gI`i}el!j8<+s)66w#sd`NMBIZM*OjGZ77~=}Q*<$k0b? zD|{T>xQtds35|*8vC$WNbsW4*ODkZiW22~s!RvW3%T~`@zOWcF=)sG%%s*e0j-=<9 z#R~XrQjx%B0|#FO2M#{PA@c}bE!OUkeknt4-zJgy;(t!)_-`2E&;~eZB3bJa0hJ)P z<3UDVrbcl(Uo0kSSzB4=twv54JNsk+c)V1uiUzW!1RP9!8M9aj?-d)TS(qCeLLy2V zOd^{aD|g$&#T`{o*(NyPsmo(3lB={w#QZk#CpJElm*xb3d<84RG;%taCui*Tl(dr5 zp@}6u<2rmvjnTc?tL&^Tf9Y+b;{}3d#L_=5A8ULWD?X?SfRk@s*3xhuD{Nfzf298p z0doicEizzt&+l>ArsXeAA*4Fqqz-!e56FS_S=?Fb4!rqSp7wxU0g6~>j+h9U7SJID zvrc7}PWJV=ZV5pZUEwymlhoi7o31Gn`qK0={zRv*2$}64IVCdt`&JaW+0jM|hYb*P z;ZFejIkwF1q{o?aeH^CEjfbPe9|ic3{Mb$0?D*T=&fA4Z>+?qphX1gj0IlFlg6|%A zRd;{_;qhOOLX)TP>(qaJUJ&N$IUlJDX2Wl`eD3{K6av~yl3=r3gGa;UyE`1%10702 z74im;sI)hGv)W#rMagMt8J8-XiI3^_J#@I5HaB+eY(}zdt%C+xO*@26Vh=ump|q+@ z)14)nYaTj03Z>6rI3|dm&9OBZt6#*GIp`>hXXRL$6LEaOq4Z?SGSiXd^pFC zDeT!D*N!{Hm%e-WsxG@f0F7A?FSErSyCI)N=CciaD47Nrxv?_2^UOg9W$}Bu_y8rZ z|3SN{X)aK(UhoF6Fi{{+wSCCYy&-uhVqQ336mEW;okI~aa_kMz#q}Ef=MO!Ly1wV_yV8H#=Ubf~6bVvE zRj=TV$kgxko~4!qsXsTGJ>| zkmJp$psuXx-ZJtgBRd-lE3j`_W{HI~1)$8$EbjJ#UW+L^J3Q9K_@w$REu7EDe3D$-V63r+ez+TC5E|FH zRdImxm3E*R`Sav7ygz|Yu6%Ld%fq^pTcOh9 zw-7y5endJGOLpD`nQY7tX9efG%sjb;CBkut0?%V~eUNfx7QQshyN!^>*WWz?jqblc z5J+N-j2une!bkRjm#OhSKAJZU!jGxD?PQ)wmPgDh56rQmEAAul`J@W;ZP>+vF zarWiuNCce)u}d44#%s3I`}$Kdb@ziqpK+br_-yNpkH@^_;_CcVIx%QyhHaX-xXMKH>`^OLF6q{SHRYz)S8({$I7k2LJ-&<=XSn6{p zZ0wAQvA!~#W;IWV5&aH1tEDGFl?x6}7Zss)wNklR#GZv7zt5+!=5le)dEPZ2YSY2j41mU z-n^%+^LSJ~0g%cB0BMp*Ojg7rktZ57%{+VDue@T%I$sGU1_#{WVGdJ%HJz=|#EbpF z{PuQ=hnV1^%XdY2<9y>{Cz4F%WLdH)QMU5N0$49Ksy`=_t!Rp)X? zv&w4KJqC=jhr&K?v^t{YqX5+CX%$inl;g}y98R2cTv5__=6@by>3uacU z$kbT^d3EYDnYk(p=TxzOu+uWJG}bP*7irOPyI3i(aUitIo~ln<=MMO}5)X9ZCJ13r z&prO51@IG7xJqmi@OSyY9s2+eyqUGIZ9|2qvNgN)x&t^lv3OKLuQjyXZ392!)+y(e z!=d+s@PkZojK!#e4qy3TUOO_waCuthI94J@rElTBqso1FC`_$=0Utv-_RhobKcKI%; z!`6T(HEy3BSZyzE?hgNuRsSTSsQIdn%spS3laX^ZFYx_IqVKb+n@4K9iFFw3D5hRI zo7{gw%N6lH;!JUaI1}KCCh#=6^b_0pAXhB>U zc!a;piJF;}lZH1Wv&h)UZb!T69YOJkM%c>a;``i0Gg|L%|MRIL3NU`dk?27?$!}@4 zlg>Ne$yz(?bNU>QnzU-w7sy_{BYx)h&HzY*RQS34`ChE8j$V!0U>n`a6s`+~lD7BP zhv$Y_43VT&w;%MyquWrhA^icb+P9m>t*L8IlPZJm_DConaM)ZD1mipLYJ_8!L7wZa z_O{ko+XpVDE7~lyHcH+DiyLV!N&9(d!bp5gCL(uFa+Pxq1vCjES8FPQ`0m2%c-qiv z3cqIV-#fFMlDAfxweA35bLPzWQDK3CZH~ZcIN)_DvDw`vAR^PIt+Cjg0(*s}xhxx0 z{s0`ek~vnw`SB;r@5jTRGn8b3QwGL42t%!$ccfLQbcdhf%OA-pBRg|u8%2(bPFjYl z@BS}KE3^s*E~WOo0nZ0L^_R)*rVqtDm#;Vk^8A6N#9&MI4VYc5cSW-H_p6Axf~1)u zr8Hpx>W59#xd`qyD*J#TvWxr^rbiL?bdf6{r63g<7sJ))bJ8*O!ZDtU zg^vjzPDRGY&K;zqccNbHtg6W`Et7=EZX9~z8hDQD?`2ww9Zis3<`x+t~Ib> z{~2QS`5dJvQ|1uQr`F%MN=i%Xvz(Emf36*iy_I(YUHEJ+`B}1&(IOeA z+@`lNEE4`&E#|jBD9qK>hdyEdst9?h`L?jM$VIgL^9cw{6==gC$kboLKXmD4|EI;o z6+as0VAgXPr@wtrp|M9!H*V7R_vzGn$w#IpR_5eqokV7O04ezxCf;=kui5e(5>`jI z?lVovY7A^3Ek3o=J99Sc0wTdq%l;6wC>%6`hm}tIadOP_IG^X==W&_A60oz1B(;2Q z3k)>L-}fcE9=#Vd3101gwYU(-?QmkSr}G%D5INYa86H^$h_}`}Wjt3&&O<$_Z|=FG z>}eMQ!M3UH$^5NttYu#Nkb057{uGK9U~Yg(5-Xwz_F(*OAenJMHnuXZdw}>&WSKoS zLgssySpd*xpeYMB2%k_1h^PS2%Qe?)XfAkCVxEjEpiMw zU~40mu8wG&^LZC!*H_kP@F$7IzSbX&C54NY^zn6j^j7_tJ;osCy5L|CAJ;6Zbohh~ z8M%U*Bvpa~*c3@}LM@A;600lqdYEUgaM(u<>y5?^918m-<-RjR%kY(W||o&o=f zTA$X!wdP0C+WgGI{yFEnhvCp9Q0W)hXaJu0e;cJLo6*$wVVVb&-1~;~;ZBDNXU=e@qQhbBy2B-xE?+i6TOwS2P3%J!(zQMv)IS2fH6n+uvYMf^U~X*t+hH{DElsl2X6*Z2*B z9bN|n>{66`%vIJ9oDi8=Hqt(4|`DImC=QL)U04v^E*)j12M&!*lhSvM-dcoySFyeIyMpZr^9I1{{ z(FMKj+Sh~cs}1UGrzL&a7@3K9onR0cn7^1F)sV^3{a_ZuEv8ZZY+XLrvKNbaLr+{E z8y88V(TFw2MDn=R;yMk7S0Wy4t_LF%UIvIqIUdyt`dz5!_rQ8^mK$s;Z}$6GV2b6t zz{|t?J4sn*W8qPIzZA+ujqgDWtb|cYr&MPG8=vA_4o(5hxPHM)+;y-#N8`4HR z3A|Br)Fg{&SU<<^F6Qi@W1%}3mpK&GNL)i5e>c#ay@KwB36bE^)G0$P0+Ij6wZOK< z*0>X;B=4szZCP6K-CyHX%z=y`kkC_8kWkDG4=9%jc{A7l1Y8Kt8(-g_ecQhxEJghI zhP-@VT@$DnNlQXPaVw(#E4Ya}kq+Y?kDxfb_tKpfG%^;VhA&^uz*&^?R4;qKlmKVx zI$bz{8>z!%y(x=!LC5HlMh=S_j7H8vQ~)eiE!Z65lqwz%y0$Yzx8$_Be&k)SSfqK-xU+a(D7eesexDsdkJvg3crphgo54aG^%$oQ_c z>tpBYNhK|K#H|A71Y*)Loz_#Ye`B;he%7Zk}?QGxP z-aU?5VWJO%n*L>0G*2_uaxycjNVJ(yqV8I|EU|1_5@S#D6tD=26`H;lwL^J<(yB5DY~=xv0aZ= zvZs!jZ6Ms&{XGpr&+4M$O$=K@V?%O}ItLq_x?tk*P9?%5IC*IyGv_y*> zQKs=)6=`K*;hoslu(<{|S57$^gCEk1mE6!I6MtwXIH&r4#QRWIbSpY-(frJ7)rPe_ zWn0EZeMiu3n#Ws#MlAPd>_(1!hesmbWI#SDia+Ot;FD{bW*aQ>@X;feL|1pnN~w4@ zGS9ihSBC`P=&Kr&9ilWWEA{YPXNZDu+fthU_j4JRh5x&rt=DLf;?D#s*o6v-E@`9~ z*^rE?a1+KZZf744eU1R=O4qP#*#PwTf(E09vkEsu*R<5#naQbnYoG=0zc~tE6XpGt z!ZxswSKL1Xrj<0HLRlS*XC@(d>K>$B$}LXl;p&p3ty}(ag zck&v;2|Y5UZR+70lnDbCeD^i~R3eoiDQN^NR&BFd&H!UuxrX#TvH$tr7$_MC?r3Pl zV#-iA8)m4QYM>(FnK*sMF!zA@e@uOIbY5-u^wS278yk(C zw6Sg5Xl&bUY}>Z&G-iW4wrxB4PQTy#;LofTtepEg`H&PVcDTt2g|ZLp1k+Pahmdzs6k^4B zVCC(AC=n3WI9Xt;^PF%swS{pa8x7U4obDj#r0(p|SBs>`_zUckh5#Mug%Cr%|Kd5W zSr43}R0Pv1~%xx65ctJ+$dJrI@6L>oT4+F^usXiiLGhII7cX(Ijqhsie zxpsHGEArzQb^Cd}0dIX}_ARZA-q` zY&|UE5}mXUbBAui%Q+`Lu0M(JW0$FSS2~k^!oC^-({(_3RV5(yXr%Iai*&)l7ql1Qs%Lfl!A2W>r%q1%3NK zH5zu8<>eB8Xlo?&gqs~Jwb#-}jI$?9>;p$jtyGk&+P>KR^PY2_&kQs3W@@V?>1Ajb z%T^P=9`YTdV4Pn_TjjuzpmD8^l0Z}Ak>HCr@R6MMi=%U(gYHqphip$oLLyQ24QltZHj8J&;(6o?DzI8%zX0$=9wA3H8X1k@X> zdeZjSV|6&@v=DF zyIUZ((|+SledVAlxc2vxA(h&W0~NkGLG+*qHPszIHQMa(7;m=7EpEvjcx_|ovbD6j z>=zo%thg+WdrZHzYBZEvsVQCg7vz#Z2MRtbqN&BFG(28RHw%@}w)+_>#4{#_q2JdYuOvmuEpQKd$=2$>|`EK>M24+LNv6CcUEhyPv-X z=|2fUh>rb!xBG|%UecSimaDcQ^3k?jaq$CDyjXmL;~=-z)q#`PWIwbgyJh#>ay_Cq zLMUf_`{3sa>J4g=E&?(F{ucfk0r$#x?&6XlNd*a!Bwd6=``A#Im>Np4g!@A~CEALs zUi5fcrMh+2RVK!{uIC9v6V5|)F}XHVBKbxKmX!sDHiLC%wAly2o@IwSG-UQ_g9LcS zrI?o=kg9d(>&2+3d;S32m$k~PCG9K9z~dlZ-LStpcHZ{E4?+$V>|hg7R*h)V&WdCw7R<==tH!YX8&0m+5asL z!SzO0vx1Js6(nRGn6dKGp}*O8dmkO$9%)jhCRyj8Nt9fhr@K;$6KURjC8X0C`{aQ%eWMAditjn zE!^DKm`Hk?gpj?*}PIFv>AY2daZz_pU=!h+dcR!J+gp(V%0cBKS| zfK5DdREhtwMpD@`aGX6r=uBfF;d*|4y0g~kuRdT+r)e1A*RMvd~|#d28}DwvXe?1 zD%oHOH68Y1|KhehGwZ7}&@n+FUiII(IcQeG7AfJyoU6d*;U>c)OZ@oa_ z2MyuepRdBPBv3&6VOa1ov+Zq-~oZp)J2)f~H@2)$-f&*nSp8G5wG{*1` zP!Ophmj8y2vEz!q!j{n4=`A3Tp4dpY?C#r5K_!$MJ`-=KsOX>|r?fb)s_!fF^x34B z&LpE95N{Bj%XU$vHd3>+Ipd=hSg4X$L{mvbn6o*=jh>W8WVJ(gys>TcKVMWI7!s|r ze$l+EWn!#mV6AUr31otHqT!*;cxoy=HYZ(fX%Nf#~ol!dZsv{k7b5N?F{1))nx0$LV z66toS1AkQ5OBplCY&RvC_~}w3s}hY*4`F!;#)K=kDhWDN52w!;H8W}Olr-o`I@d~VLUISa(7^Ro=;6_?BW`cD| z>i5^sp~U8zz6#m+DzpY=H}U_i7~2ln)^IwQfDwzfwlLehmJ=QVJe;Hfavn;r$uX3$ru72jd34JTZWE<#H-pq)+&-gL z(p#J8yq%XJj#PS~pIt^~#OS{;Wj;#?msS2A0g3DN^Y4OKIQL!FIGVOWcrGSYUEg*G zpB(Q97QFV9T^;S`_lyo}6=81joSq4Kzg0$kntg*AoNihSxESFC&4HkDH3e!NA&Fp$ z+)iUjisHwlZ%U$zhPuOS2>1Dgg_C!bkf;Kl6o=3ErvGBMl1cE{ME?9uMpg8a9}JG z&rhBWq!6c|7#jf>0b4Q`^KrQv#aOd2&b9}?=&0mjXQ1GL68($|w$*iloKzZok(8D@ zPE&fZIFK>F;NW4#p{b)JHh~_>ZKoEaa>@B5CH+I&-0ce8>OHs5;4&*40!Xbbu;9yV zM6wGu43f3iwfL{fX?pP~B;1(h>z%OV2c;p|X1sjCH-UNOHUcRfx5o@!rgvl#u*`@2 zLX$ll-rRsrag$;GD8IBc`anzk)|Q=>nIOV=~|2{Zk zZY-rjO`VZ>_aJR5X1EpJFwgc58c&2T?!YJ{?bwpj2}SY4q-{N z)d%OC#d*8$D}p)l{EE_&7sX#PY?~f06iE_}V!w;+mm+-|o>tX`hmwQ}lX$Y25bXH} zNn+}v1ES~QqOt0{gQKI=)Kn8A;{lN%ewSZC(#-~6uOL>zv-%zu77i}T%cUH1d&-=I z>UhSl_W@J(HA{{~`7bf|!xQ)Q<=7-$76i%#_hwawxcRIO@fvmW1&t}Jk)SH=1 z#b+bhpM#acl!|az%9O8yhDC!NBNHC0z>Y6unX9zz2Z94Titq!5ZXMq*uy;@CZ1BID z?m1B&2`_oE>1`DTWQ7Gc##+X{?npo%3V{m<;>Fyq2(`|r_2e7SU zRjPrz`5$Z~IwuW44j(iBoV9)A4iC{b$c0oR3c0?|T z6>S7l2>6JxhDhPcIA%O>RSzYjLixJ4YNS43K*gu|m~v5RSBfy%K*!V6kbI@-mebYj zefj4G4nK#MsfZ=OhPi6a-`+sg{3(~zJWn{sOwv9IenZpoM*I8Y?VG2pmeH zSq$_NqLySBDyGofH!f&8B#jVmbD>a{?dk5iMv61qK{>@dPVGP2kgfEk6fd zcdu+NJ&6Dg`BlpIkGOvFhQY*Oj7~QYj?*Ey!ZJ@)UqlPy)Jsvsz!!Zp__8yoH>n%+ zMB`=hwmw3z{gspKRw zj1n`|znsFu3KV!tTc3U1X=qR2ABzF1A(i8+yUTQXx1J%br4|o`UxXVRT_pGWke$Ty zfvZiTVH}l?)FA?j9aD?rkQo>E2{SV=xO zM6_xgivJgfVCqJv%h#kJ49^g?LMpiEV@@#>xx=7)FbIgZ@*t63TFR3~lYBrjk1Ka1 ziX?_)a9AU_E!fJ)qv2Da`L?HSl?!DcH0ZJwaTo0pL~nzyAOH*TAZd9>tZW)S>5(UsD6DZ;K=){l~^YTqk-=2W_AJIwkTb4BWTj!Ny0f|+v-pQsviNf> zlG_4VTpGBBwOPwt^Hmj6WQq3Q?huWPnV&9oftsFlT6-zT2lvP%K!lPOwP5vsoQ)uH z+z{(HP|SnIc*H{^B6$fGY&W1O@c|n(dDx~*$mlI_`{nny_LUa16fjATi&uKyi`VCc zIso5{y_DoUVLE5J{zMPnyFU(<+>xBF)GLv$6$&v#`>Ab1ZyQd%vHOcFy#yxjz9Gc>aD8?@luj-H)Qcc+4tSoM8Xa0yzFV zfKUJme0>2$NL7^jm42LU`+?NCGB;x zmgfyey*}kJfrjc3Yv8cp(I%t*TFTB0+*QFh)+rmEHbVf7S8`v?_jQ@JJaZKTJpsTr zm>eIoM^LS(_`RjhYiST50x|{P_5TX+MYN?rrw}*@ppmWT>LdU7?{DQru8~a zPI0zG+~p{*a%)d|r0SHbk?dpoQc?61Ko6A&#=kAvG?I^cuOFW;Zo+AXD0a0fENS4* zxj!FGH|EylSOaL~+65^=ayh+i+sZtDzQqiS7!2>0Rayp3yL}%85nH#UUb?K+`UtG+ z2@|?tW5|deiuJQj;IB_lzB!%a5#%2*vqbv~UEKqmEENX&$K%^bICyTC1iLnZ`#^Mw z093N4W{n{p;s)qQX)djYCf8$}0DC^;-8quX*2^3fQV%VsWyZBpQ&Mj$ z2PKzL{u{oE>qby|w&diOu&-e@a%v?T65TA4h^<9uHLDMw)f)cvh-V&bNffC?Uvvl6ur%v=>)kx>?z3MaYGxIIYwj(O|SkD;ZHg^`HwfXqTBN z3KFjKCH}+np*X6wKH;FnN5|__mClp{^$2^(HOUCfbg<=9hV>5fIpxBew zv^1l^Yuh`VT_fYF;{hcPCS#W?!H4di1v!)6ZgSZ>_^x}d2LN+!7?@c3D@HTopJI_I zReXgEAg{-5+3)a?>OGP{Nz#34(LH6JUMd#f*=EfNqVEXMhq&MCm9_KNf+-Q0iyMn+e44S^$kdVsrv%LYR5$8l0Aj8`w0eS(aVB>}^@I*wP*zbJ)jADb53 zw)$%`?t$v!bJ&NTPEM6SO8=>?iNfXNh;s)!cQb(&ESvHRWX-(1mw)V)v7u{ZWpK+L4cX#_JhW zUxmrNsVjN)$*62`?j^=}^Dv1vu+%INwx}z8muaP~?Xhl>ZY)HI z5QKJU`N}va*hzx?s9hcZR7S<5{96qz7+oOWniwe1o73($__%~AIjuQd*6E8n%42fJ z*q>kTr;ZUxAQK|7c^e8p*Zzo4I&1#`U#=qTaidk75=PD>`rsqe`NP-KPSK}X*E9Iw zt2gic7(Z5SzW{EZ^c3pE`lSd;>(#HS=X^nc6mxCN7D1ppp9*6I4(QBZ5&Ykj|Go0w zwNX`DkT_&8b}*}{U$QxgiHX@sr+!T?^z=mnJ?QTr#nMhzUSQkxGl1zX8?m5)_G-@( z(ZuO?%X$F-+N`1H*P&T~yFtPxR9`ETcsN6o+=T{T<;A9V%WBp4HUBjIO*xlw!KJeOC#fKs+TD3(k({EaY`}5HgxCRtP#WzKl(Rc ze(&v^_w=r9L5Mb5Q7%G)mFIg=^&d3yd*r_xe(qfz4Gl@0u}ppmcC?S<=(XB@n!{dR z2BQ)zllREoQYimDdrJ8e>imT1ECo{|5;h0}eCs@!oV91w^uN1S)jGQy8RTVsv|NA-G0HCKSoot(;!8m+W!;Q zCVP&1fg2V3=`f9eLzd0;u(Q4(NBN6!)%GF>@#+(?ZZ`5b{vs6oXL~Lf6!?=t_96~e z%IgVVIyo*3%Z?7iXYJ1K^Eq~A`RD@99aNDo=*1&h|AV8K>!eZ4)G|eixD^=5_4V;H z^-2bck}L(0-4sldC-wDCamf1pTT)(Lc|Ahii$#E=8AQ6bsu+&*3S>R9o6EP!{KD(Z zx~hy*DV$l`z|LQfX_!ZUh=nEM_XU3?fGiD8Mm@5zFe5>Q`5N2A!kP^W9cN$e=VVUA ztPv%KxVJ>i%j~;4Ri`ju|u^G48D+iD97I(usB9 zVpjM-sAJUxI^xqWX(5oCU|SifJ(RnKGLmQveeKds3e${)$9%Z{g+DCL^ROAwpD}?9RnrH$vw&Fg+7d$&UUciWy2}leoj(Ql z@eL?5-rFe5wI&gS+ND>ov#7pA^lOsR+fxA@k&R>($1U9MXacf;`Q;JTR0qcl2X-3G zw@$Xohkc%y+3cU0<7*C8Y~2X@xcQ(K9Gl9O+l@0`UKKj4ya8S1oD%ba(1(D&CajLn zON*&yRuBETBLuwP@Y{f-J2DNZPIG7oa}EaAD;8ARP!`uxC|)-d$RJT+v-CI>t)sm0+UUwJB6)m}mxYLJ3Q!JNIIzu;Yx{bA!V?D1*^{Oz8tM_adSqtBg69qs@%?2B z7HA8&nmVgGGo1l$IE4g3jPTyW63Vl~|)*+AQ6enSzLo8?GWR4$w7}2Q6(6*>lY&H^w{)^A| zK7`j|-!vzgucyESF!Yj7POgBGsd7_Fi4TIA2Z047FqtGBW2FQg{IjG(RikGr4q7ce`*CYb7di-RD`32s(o_98Kg#JrOX)@n@8p$ z2f~h$*)X+HZ@BaIATu7r@lJBU{sVQGE&(1yN8xa_HpPBR9*)DCISfA%$MPuU;7~?e zUH|Gn|4c7Y9~9sFA->APBp@VQiKz$0$!d^Px-?7;`3Hv>|<-F zeE!$+5BMbMSL-E_j!VdhGqE)MncfC}WoZzT2%ZJa(N%M|O&Ipd0K1_1uGK88>9>l+ zo^-qyR@~<22|?Zfe&un!q%|ZCZ~Ddp&@}L=ZUS+|I$q)Bs~|_>18m2_sItiFy|AlOW!)FeLPH$mn9D<3O^3zORVxs8L{^pfR)$Wz>|JlTTf5Ywlhcc$qdD z&mfHAkLU#gT}%C?qzeTUW9`;+kGDwHP;6v10Oyd2w2CpB+X5&wTAGuUR#1@Y6ak{b zF4**57yP~EQRTbZN2YSYq_s77?4gX`$YGBTeKXJh2Oi-QjUmmjQc@qH4z(T5TjtXc zxP@Uow^17QPpHr0^>wY+0Wm#pmK`ywChbY~mcM<_#d`f|1K*N9DUJPaf z+a6x?P&JzIA+c%t;=*6wzg~f@QW10GX_xbfHpqWT-Iz;cp*SrP0 zRaIsyRamI4JT6o}6{Tlu(jrT6wo*}vdl{Mi-pNd-3iWYq)RHe~1Q2fABs(tbkFhbQ zS)%4%DttKJ9Snwan@<<0_PF8jPrbbnw)<5fAszx?O`OkJuq<@ zC)!@&VlG(amyUonYKc8g7CYs=ZVT77$G6H9X|T}4aEH;xeacRE;$?mlUf4!@taI56 zZJVZglEqp2!a?UW*5mN7ah&yT(f^r#J}J$$d5mD*@$LzSsje8J^#ymt=A2UWkknM> zxUebjY$@SniquRJXR&SoXK_U#6toz_`F8L!-Oh+Na9*U#Zk8}qNYMF!I926E+9LU% zmwS3gqeOwDHxkt`P+LT#^(Ubsa3SmSYig=e1s~&}Z(w^9OgxS+d-?9v=zUT(_5^n+ z@DDDK)HISeY;(v$W#E?~`v3V&6vK5ogPi7vJ8^6iykZJ}eXEcm(IB~~y!uLq-?!jT z@_6-{yREp5GQEoXdspO2jupVTXu{yaUp!yFJ_heBN`-@fg(arN^q)iEojcCg^P$CHFib* z9T_l|%VwrSm-HnY(9nY%7!-)`8_i04HCFnQ7V^%J``_HkfpSsr`?nuv%{~Zo^Dw?jNU$d+}Im~+rzXKW+ zj4bFb8whE0E=I`{3}k|&WRD4@LaLBmT zJjK(pCQcXUx4>r@T4d~kjE0(($}goaUB*1%eSD-eyN2ECU>Xs;|KsGh8LwxdojKe= zgdWc2i=*@QxW2gEk*0JD{ey#;scK)roZ&?5{_3V9{WdoyHGSEsR6RX~VVfoseNtc> zd*V~@X)&?VHyzsLqji!R5&bXdc$wVoQ$^w?c&nPlYHDN&H7qCPuerK%dr=mt@v%7v z^i8dI%ek`4p|@r1R8OjOR)TNWrV87Dl(4|QBBwKQk2A%aln<#+u`sv<_>bZTHqp4z zUR&^fQUXBXfdW!-_nldBpud*y?SCSh@N-|;uUNzznsr+$!<3P8%n%<5{{LJ0F+);I z4lle;wx}=)7D~37IvSoccPua?_xU(a9^S-rsEE*{=;S%OJq>NSQ~_<>D8dKHu|A;m zPX1q2mO^t)OSb7BKZfusSims@+|7#($N6Ij2vCilGs8F>L&;nB%AkNRvOI*yX(EHWs%d;D(k-EFplR1e%8@hX0mW#yt-+CmPi z^W^cYuWxLuf6_sRpvuq_4X)MJFVN-f55zmS%h`l$OREzzXZltpHRdFC;*N$@O`OC3 zkI)l%Ea6*%vzRJ}aBUa}`HCJ}@)?F?vGFAO#ov?J2*Z=HVw3L0QBr8hpsd8Fgm4Ds z)Ztn|Mux}+SH|D$EQQmT#CpZh__n$3X%%ps1NOES71p56$|6e zeUlH09mu0Nk{k*1FML=1IGeb#xKi6pOVWL&(3AGJ2e$(e#Kgq|+k}su!`?KWxhabJ zEqY~g#{rFGOauo*Y%N|c$?k`GXmMGh00Ej9|GvT{V5D@2IvTpv91oPPg#o23R6OXC zTt|8I2u2=KV(ED0+~kPkZ90LRUqZUmcvN}F(-F4~w@#h2!XuQ2Dc^>%5=B}A-CN44 zcVRx9J{h=v1X6o2k4>S)W0294Lfg`6wfX-ZIfPdQeS~%v4qy}9rF=WV0m9Fz`LZ9) zd$vdjd90WG?eLwBA~5e6A44g7wgeJ`?hYXb^AGE|2zXoF4_^Y6smWeh(manTvh>L1 zfpzX9^gSw1-*#B7$G0oJ9pZ_WehllOnqjQ*?ayvcgV9@hmmx`DbL&LHBR$*n&1^~O zp0jTK$_l9MP{AE{WKxGEIceuo%g_F%;lGPt$?-n{8AaVVHaPIBekyiXW|=fYXucGZ zD+l)u_>3}18IC7{Y9A@hIq14-H)ApWqv}>J#_{vP15Npa5B%2Txx32v`UopqB`G`6 z?D9O&)>Jh-FO7sg-=auvi_2*@vXLc1|7MYqlQXs1(KfB1INTKZN9jU8mdOf8?DO*g zg}P@lAY3-^q`OTdkwDZB;^}#t zI>HTffYiCRW*%$z{hk-B)kR3DgQM}y;nMSArOnB~^!zLsrD>tVAWbb@Sg_OkajWP< z$7WQti7alfa+*q&|NC{3?1qR~K77c}sH`Z~v#_?NVnwg1^b~PuNAEzx(WTi7emKGB zn_tQot?rkeQO>j*e%m2}pYmPYE~n)kK^(ZQ4cg{LEd#M-zXUy?{)gPOfD0`)ee)s&CB;PGJuf&V)m^X z{EU};F^!ns0i>eAQznUmueBHvN5B;H6{x^>67Yf)`eVwF471#cV8u*iaO4!^;!o39 zClyv}@2XBDHqkIt6WE9_H6$hMYoA)T{fccZC-W&IpCBQnQ=25j#S!2x$k#dk2&sK? ztQIJ_HT%H(^F5&?gJq;-C&tXR`!^Tze%#ICYL`bGET-vf(u3(&NZ%~0IDBQD*{zh2 z7+vw1VEv{%(`at}edqq@c#^f-&Up_KuZ(kQHb&2`MAkF!IH{)Pf(V@FVt#I97SGQ> z!8YvTVJ0+kkCuAb0=iDtEr?vl4Ns#Z-w4m;*t0G=HOA@Q3p}3K1J*Aw;iP4^{=)MJ zLvdKV0CA5X!M_YU5fI@eF4=kF;GUzz9GDx>xP;`s4_@>&b=#fyB1g9=)|we5WYDfy zh96R9T?kPdaEnR`(gjij8>zQW_|HNOB}W?s3Ajn=Q+bhLa%7i05143Ku|F*}Fn7_< z%YHTQ+(=oVaQ6%Fy(H-bE~FmKQ(IkgP#_>Fya)T&i>}F&L2^isByEuEl<=08Q*5dvoX&m{Nhk=bom4vW4yMuW0jG`TJ-&Gx6qD`3WMZsTA0RVX zamCrgiSZAds!pe^AcLv(Ih9sx71Qd-7k*`E)Z%LH@Aw?!&e6l74EUupjQ~lgf6po* zAO6H8FbWfYy1#zB==9DjtSne@dGA2mf>J^zs(X4L9ohuK_?*ucM=Ro=8*eRquqXHX zcGK9NFEi$sRq4HWI&G#JUk`y?<79TUO-`rx4}(C=-6A`O)RUF75@0hTV}+=hl-bcx z-w=tn;PO6Rk%v*a$j(L~Ev{w?6_4#{?m9!Kn?nR%`;GUG{_u$9&1ZV8-L+G{`~2w% zlJ^D&5gC;!Tl15D(bRY{i8jU2o=)J9<2P)uO7p2oFyzUuGA6@we!)I<^>^!KTnv;t zz#ASXi5`eQblcve1@as_Jf0ip>9g9JTcxoXKdQc-H!bc@q_;b4yAPgm$0DO3Dfbrs z4w0(BVQ#L<$&r62?0tRpbUt4=dfeIM4T`j|IQDeCM;G3_-BZ4vI~~v9YBcVTHOFt@ zKAP!pIfkU`@_tkR@~@C~raCyQvUt;&3=-vvA#xl>my{chSx@c+vlIsrh|_|`wJKGn z^DF~GDGH>^R5wNTW$;g@ntTdMXHQOW5qU>e`e(J*f638BMGL)av{pMk%Tz2mv3a~4 z_^MBD=(c%-F*AD~t{g7iVsD&@fznG*T=3WvQd4WKcNxII{H9RIxSKt2S{Rubae$c1 zeqRA1Gm^IVHBhZr5cU4Jz-EO)hw8=-W$JYU?%%Hgb4g zh9B{(`8iF76$%=Xe4&=xl{E2#6rf@pL~UC>?|H81P@nDpNE8s#8JSjbdC+w(Y11AQl9^E&-dPX z&GQk7M|}Zn2^UbNk5+v_ZZ6?)e!jnBie=2?XM+&kPsO`mkNlep7Wh45pOmg{CvD`|`RcTO8`E$5@znel|AyX%c0r5hhK%(l~|Px&aM)sG_{ z58-CYkyQMw>w%{&+prOq-}4l30%!9!V~|Y+3oNC*m)}t#a8jZnFRepp8dsY-*1-n_ z`Ip}vrg?6)%^tLyI$YKspa>Nz5x)q}rrW_R<^)8whuy>FRN<^31*lK|J{sVS2|aneq2RohKe~J8DnW!tw?AD7wZ_})LxN* z)l&!xRjO9hfGXwVDuz-*SsLiON8)X&s5TbEW8Rd%qQKqBVA1gIuLST z^3ij*C^e2;az0-Rg4+4`M0VEmNGv*}`g|0*B(T|xcfb99CVRS`Q(1KC;X9NgCNt#v zU?^Y2;9U4GNc8;lX8|ngY}HR=zFm1)Ijw2)46oH0XNJ+^bn2R1HfN5%3XA;=!&6#9 zVyV+^wb#QXt=?1|tbDCD&$D2Jg;e5Xp}zC}2O;B|8YVvaT%%qE`RDWPlS{v6;740< zxb#swRmzo|Hq+M;&X>90d=zK%_kn~`GMO~!L0Pcp4Yt?&02w+`&BjyPThJp|n~Oj- zr?(o%tJP6D@6%^2NYPny=KbHvJyAIlx7eHrxB>By0Z7o8oOTk=bP9f^(H&pN#zS>+ z(Xf72K|TdeQV{;)hXqRBPcfIj=IAwZ6zEWRlMFQnVItQG1&F#@~&v&#W2*9P}*soZku@LZ3fhQPtW zx!|)=0UW@`m(|fk!2&@m|L;X=a#|!q^j8P+ZD;4xwa~jiPOP z6Wj!nX+qvk_Zfej6|!8C7JR`z96ZL-t-iIF7Ue-Kim`2Esq)*&>13O}uvfDvC2u67 z7F`C->A1F4%SyY8*M&Lcy}tVe~x6GX*CJsRHZhWI zB9s#}eXz3{iL%l@8!4Mq38NDsE!0dqD9ATKSoSP3$%y3?VHNs_tROrke#m4iB!CS9 z>)*L2o$zb*9I6jOkX@TNUFPyae2Au1mOT$p_Ti* zHw&Ngu)OSdUi-}i^q%f#@8~t_akC;))Oua_$5t3v_tfv%Ww*w{tc+l*{5aOH4%2Bs zfCuL}kq+&##_%w1E@q5$lHw9_b~Mqs93%OOXR#h3h0$Dhktv+r@>2@d(b^4+Xvf&8 z`tK1n+25pM3$MH!bF@|+Z@Cyulfqu+pq3OMoVpvw9qagzDbwZbCG0!Z810E-Z-+m0 z#>9g4Z~>Z$j^cZQiLUIiR?0}B9VM2dL%2)MrQGq{=Zc^`82Hxdt5F}_=W3FwAEW8Q6T-(koG2OLUwfkUe;s*w%>2l!u^ngTR{SaSWr(5GK3EXl|2-vMY z^O5p?&qQM@!lP~Xhl5dZWPznlpe^$y)cL0;&mI3#`(0|p!~Cy@Kj?_HQ@w^XX_x9p zL-_cLGN-^dph#;pj|;V4I@bsak`8d-uvucqIsIk|jCGahqKZ4&0G#W)ina(6 zD<%|bjVo9ByyP(-gmTh4vQ?s}(OQ2ETyLWzcjp2q+2ZilCo%!ZiePxwJ`)O!WQe63 z#i~ojYccyJSn9#j%3Itk<+kbd$MO@cq&Nw61+{og-2nK6b)Wqq<<}>uv!lC(hWl9= z%6F#r&CNggP`i|`@Eh}FLR$dU`^OIuBLZXs>L~{)IEMp8hQ*(&>Jeplpikrn`^6gP znx)Y`0YmcK*pBto%uvm$(kmO~{-2@xc%__?%a?y`p*AVC*nqLNJ|wBY`>W=*l6rE8 z5oA0TCq37bgsfWvV{jY!!SWb0__xTBpx4Zb5K zK6-2Cm>4;;*5RhDLw4pm+$e49>_lpw!+}bnUOEF{SQlJ2YjZMf)p$e<8*^(i=^tTC zOBk85X7xatabnm91yrX6cCl$*t20k3Y$8Y$MMZK9j2-LaGq9W-LX$|m)%v{&=1~lr zRR`OvG4?#46vz8LrvsyFV=1%s1{~gTdo(HmRM?AaCp#x%1!4<@xXVbH@%C7`+z@aG za5gq}(2bCYP;xTg(%0%3Xjz$YIv=YwLPQu6eQ|vO0cZ~oY~$|o6KNdu@l5$P9YIY_ zPHdY(v3#beW!4J89%EXyr4r4kwkB&SY)0RmAUQoa@7*5tVy8(8pA-hLE$uS*#nSYW zlooydT@pSqduna=C5@$eQ?@M6cqLugS#cHsn+M{`77{~eo1Z}GdshVUNWrvX({ym+ZowH}CGh~di6J4-umvk?5 za8XxJ4J()9M&&vJ>eDjzrM-n%VumSjQ7=3*hlwH7ZL$671P8g=1;qPY1Tby0sgT+} zNTGu7s@+p3H}Q$b^lnZ%-Vpk~@8scqiZwy3L@`4%q}T(qOKfZ`swLu5DDdVdoyUo+ zH}s`~@?Y*Q;fHsRb-#QGG8@LHTN3yX${`(Jz|*czcp&a?817&?S!q(X~;<=F}c;Fvh|LTrp8B*wRo%qE5$}|tK|Kt+lfWnyXoo#N6b6gaYEW6 z;0`P0ix($w74?w*OBaTpLw>Naq}z-dd?XC@UeRvT0h6S*I3Rv&S8IbCW@;Z)93 zu`+cABAT6T?Q82?*lK;E)M(y3&I#cS0{!caPD@*+-!bs<$xxx@E3{xH=(yc2T;1+< zUcW37B@2HfMqCmkRkz0;} z5?O?TIbjj6c6{rEy}n={5l&w*LNzlW;b-&T58gU_#?oQXEYEUx*9g|`E$2TbrMJ6} zF3i*`FC!$;r@wkkKRrcYueN#N_|EVK$ru`k0;~)~1L`7bh)|bo7ME2ICY0eoA?U+{ zUt81tTV01;U8224 zwSheKmdz?OAu`fDDaZ)Se%q-UbCByi9vtpWozFJ;`|8uA;bSJ%*<*1SQ);3Nb*47q5;2Z_f>0Edd|**4?VP2y z%A+E&ci9vkD!{Tj&_VanegBnF%QBTV+d;*Xq#Mwv<9InzCRb0Ssy^2`H^ zGGz}2hPM3Ih=hQwZmad!nogQ8SN_zk34IJDAF0bd=j=bk7r)hCo+w+q$ zbvCr~(VHO9#)mj+E*vL~XXw=F59hBNN4+}1jOZ;AIN;)C?CuRl;PPGu?8kK_()!|X zWOrp%@!gg;otWAArvJq*UFKC(*^B}5ll2?;Emz9)91202B--y5&=Nx}*xL#;=IeGF z56wAX#l}IN5zkHxk37*kkL1h0&$nZ>?#Th4=>mB}Jp`DLt}MSTMdXT$fb|+F=-` zR4z-4q46sTOE56>JFb?c5T~&Vi1ZQ3>y-_MQZ;q=@FT$ANR3`mQ~Gr_?k}V^FQn`g5T~vs z&ABm@9>P77q7FP{J-9%PYBX7`wMx0F zHYX*0WDbE$t2cGK-K-0#{t_#y*u9n&rqC9ZAs!OU|H;C_L?m)x)@mUHPowIczx%Ia zJQOrbb7NSc(Cd8+A>h5MMIxuI!E)YLcy~|xaTD7{tHaXcpxJXX+h)295E>@GfG9x;w(2LGZH8wEO_|rA?#(QG!k-#S@4A+KIOkI=AXHd!pc|K7K^HD{ZnN zEKQ^A%0|Ak_a4}PwQQ$ZrE?N|iKQ9lf27CG;%rD9+mAcJjU!{xCeijlopYZQI{MuB zfXKaS(T4NV&{E3Dj~>Vy%I{Fi6j0iiO{Xl2&;5Ea??lL$bY~G9_N>s|J}RjsA&d2C zM+2G)YEGDl;;n0&sSb5nB|=;huk~3n z`SvcQ^={}yAj>qKhlRrHPT-fjHhCiItDC5y)ooLyQkpV3AESqxaXQev0-djv|D;s# zpLrJGS@j*GR&RihW{#q7r5goLUX`sRs@7^dEmMPKIB=6CI1!96Hsi7um_7mnk|R!2 z(-&bWeS`dYY8`&VZXCJLZ%c>qJ<2~&)zNh-+{S=Pv$FV+{S|^V# z%%I`r5h~fcwfOA+3djx`grHLB?9OFO4lyq;uSk4crljiVmdj}s=v)~|AX!I+sQ>L# z5N&V_*uA( z>GGNo3m{*B3z99T9AF=h$UKF~42g~o#?=)B%|Hni0Y)GjbTg9LlA`EBqy>DwQ`3{c z6fuDWF-nY{b&7eMGZ`WDLxi+*Ez?$`!aa8mwxHzlT`%X?%Zpy)f{lcy^_oeVkw_~o zT59guzG2n)F!^beuz(0j`YjOz_4I?14={;huskE>g__EDt6qJ#nK|6fx4m)%V!x>Qmh)3Zhf;!~?Dtl2flz^bc#H0(KR4(Yh zFv+%bGtot#t*5iT`0)9@%NPgzR^4w-Wj?VR8Wxa|kPf+-TUeAN6Y+qptE~crca0Pa zjwcx$%#@Ncy3=`J!ve5e!FQQ#yOC_vXf0Qb(5}iXo1{wp&24%p2FRP-bn5)Rl>SD> zL9NEHT)2d|Cigl5_fI{gdAr@=(e3$YFWIzbnymq$QAx6gd7{K?x2WY6UV-h1VHz%z zWlrc%O+PNN={lCblz)Bz&xxU2jpnB?SLcuYORL;*j2m0fDpCPTJ#8-$&PzeAf~dB_ z<50OJ?4n8X--+is%oXEF@&10g-reFCCWgm^Q#kYaGM}p>tMj{a0l)X7G&HL#t6j~A zw+*>vWhQ1IkxuT{V<$3`B8GAJHk`MwZM`3-f-1VI+l+SJu8QWG$_>3zB{R& zc|vb%Z1ZTjWRC=`CTHv2B~IjQq|13Gv#EZv#$Np9$$-scWxi&@D-{`S4LS@G`6oB| z#|G0`{pNn6(ftmdx(AS;dA>Owo&3A~aWPje&MvuDPa|k*Ko9JQ9;`5)!>W<5~A-@EXU!CYTml*TS)$C5b9VO=AL{ z8{zmx=_7}h_IieIrY6B7s2l@SRXYRedSo%-L9M@c>0Um+<6qn|%yf(^k#S5ox8cty zMfsCVf9KRNbSVf=>+3_mgonh1Mj;~?A@dQ(eu_t<$wQZ@ z6%?G0ED%PbAYN0*q`v-(C38^JIO(^>B?~=txES-y zGz;?-LEm5IENqmE!;0&?RWTm-=7YmvRlhVJL;T!3iCoT1)P?8nm0Zp{qD+;0ykDx0 z=H5dI6BB*GY5e$TEc|dd#7#|K)0)_^UkS!l4GL z3I`4ns#sE7S?!dVGGr(`@fovGZtqmp7Ildt*${r5V_d3q2qFxZKn5pLXo?}=)oFhR zXoCT^E-CK;;WFd#^!N+Q*-4^x9a?yo4=vj&q2vlHrYmOA$O9w4cy0*DeLb-VVGEeJ|P?^}R zI4ScP=ROa17waZ#!Wz_bt+54d73&F+f_=)&Z1PBXIVo=F*exVc=FME&z+3R7=&{bVYCV`$00I zTg>AVfTY%u5%sYOCExi5UXtUnkgA|%T3HAE3^*Jrm#elLioRve^}Uv1`jRv>QgXst zerTC2IdrJF+beycJw>>~9hg+4BPXjP{nWg&&-70H&+rfaV1#j$E$a29yE)t!RO_ni zF}*~r&-g1}T~T4OlRhH`Jub0k1+p)F67B=Jd54^-t}lWD=v~Ad`5zmK2!6;>fN-TW zW*j$^tvhd{1-!LRBX;LLmtOHWk>x16StRtFChIvT1!-Zm_7WHJcwww|W}(K;4h07D zF^{Md=;_gy$gBy?RvE(K6(Af={+C2))ZFH8*48Smxk^cc!<;I_Q0W%bei_ay9H&299-xLQZZQpOgTX!g6!-;=+oUR>U}6 zv2Ob}wep5-VS%@eZx>}|{}(W?-+_;|pL0%X;ny%uy6^7MWm}qYrk>a`B?5a;>S)d{ z>3&pZ4$MhNAMeqA>{U%aB>F88A>tSz*T<=&!4iEspAs!n=um)e6=soTOS# zoKE=<+ZVonpEneNv(t;}Z+<*mm{-Q&t=$ixW7`bdad)EtHEU~S; z8p*?&qRv%zwJpc$f$Aa>zwQ!{f_b>My0Ex7XS#E>9K$Hip7PxIK);hU*jztwo|hC+ zimAscDkh7UJ`=+}_S_nG?KYngCT4I=&;iLk7pt9X#bKDup-}d8?*s2`au~My28pf%6 z=XDdQqSZUjaWB=zyS|xMrR1o}(XK#wA2jFWby_DI`Ktw~x6-qu&fHLySMv{IWz+TH zTi88v6-c>DGA*{wY8t&C8W3O6wMOOQ$iWRye?3YI(jYaOBl1M0AbWRzNK}?7E-JKD zG-44T7ADLkoTatNFCzkkTT<^{*O*=_iq;68o-fj++gBaC!`F~+Arh^6YFK3&ofMR| zFYaJNwqf5j{f_gY(M`>dz4}8{$DdEwp0ctRb>>z)9=RWHrdTFQ*~WX(kcXk7QW(M5 znPs`iVR0H7cCh7j1>-ucKQFrhqz?+yYtx5fYEyG*7UAedXV6mK)Y-SaqWXR%bj0qP z6ekFII&&oZ&*D!*jg=Yh7RKe+Xn@-F7ps5SHb6$>{F~eF^?N|I)WRuhI9|P7kv=;2 zd{tRMLg^Ap5@@vRt1=C#7@zw9&($+1QEWXgbixCI$m>6U#+{_iCiuXLOI=KQ_d{|-4rFt`;fI*3YDl~DF|{|< zFe2a&KNf^axM&NvKNg9Qu)>K--(w+{joB_YpP>&pSK020iI(Y%3-uMLrI#!#yhmPJ zoG1Z832Czb+0CSHfddfCVh+q_s?(71;45xzsWjLwoodm_!pjR?>ONduv07i*Qy*;v zP`S>VL_<-zm0UtbL_#T2VTz%)Tl$7hPY($vwc8EQ_sf54ci3|N-b14`+?Eob2j=4T zi{AY-c3ulRQE-Xv&E?(Yhc&^^Et{-90Z=^cY5cM~K&A|6;Tf3CoGD_k-kEX0v#!4= zQWtb`k&V{eJn1|@XgFK^bv7%m(NA2^_KVy0P!5_5v+2q zrNvBf814CLDRsr*-26F~&sKW_n1)RK-RJu9ko-0c2s1qxt? zj+BD>7JA>{W}%%$Y##X{vAT{v$4X5SGCVo#Q;}jwq})*{4BF{81KePEvzzrR6MtJ zF(*qjVHOExU*)GKsm{QKNOkV(GBea&+|95}1MRUl@*3`OPr!mn+Zt$BZY{Sjn?N{{ z&SQf_KW|f<9wK#C25EPL>#2I~ev>fUFOt&wkEaF$jf z_1`22%lN>!PCJr2*nd=diaCga9GZ9n*`R0J?8i_nmHsbRJH)u?Wn9_0U&h2H0b25U zdRAp+*_=UuKL~xSUQ(m;d%n;9{Lp5W=7srXlX;-LEkLu~%IA|PHz!4CC^@QC=1Hup zplD2BgVg5Lm=PZ#GEbYwb;W*u)Jt%xnmTg74^#_9oq0D>H=rws#8I`A{6atpWMpC# zzTBj#idWEDA*%KSbPf`8nVyfen}(NGMRP#}!wVwcJKyJ-oZ8c#rr5lnCt~Kn5;D!A zy2_F0$l$6XHWrdof*r%{RkXr?*>PdTBB&~39N6{n%tu-7n&O7*GrTfzgvd@GtW~gF z7VUn4?R*H~o`|MQ_^F&gEqRkrIiR@v0ZWC3PZ3{vLI$@S6o;TyJp>X#Cnr}o`=vZp z=E;weds9Mz14<37EiAoB2--gx5VMeNTtYJf74}oyQY}*UfAFenOdKt*kV#W&nx7r= z3ZFO`^vy`BXCzIl(UbRoAo?0a-A=zZ&iUNeSoL=LK#SiwG;V@Z4!@6B@#CS40$fNR z6-3E6uepjVb=Db{V+~sM^{G%z)u&YfcJ50WMwaUix* z@3oZwFT(C|MA25Vh3eWIHfMGXo=!5fdfvYPmSxL;eS_({^v0QTpK76!;pdoh;dY&c zszr9Bbud^3YAHTAYg3e2s_X^#M9aF~I8M4A5f_^AhiNW^t+=VWx(bqjkUdKarfMlO z`L73urjdur@9^HL3pnxtdg7gLoutdQ+QPO=>}R72f`JPet+76ZcXnVl}xE`!70s^~m0Dtyfx4xu4 z$K5PnAz9~5sBgKj;l~fFCsGF#pjCo(tYj)2@^9nZ{l%p}JgRzPmuL~Rm~Q?pXZyy0 z_nBlQY_H3~ES-kU$EkGaf#Y{z5$wH#ZFpK(Lw6LRopf@MMWTK zwHgWn7URZhX?OQ4Vz8z*MpQg4`=a?6eG$m7qGH@0*u*Haw%)p3C+ty&|~ic;+s zxdO`hnu;Lek-Ycq=KRFuRvd#Lk-`RIq%;9QlB41V@?GrTB)Qan$QB9x z@wE>b?XR0HP<-~u#aAi?t>w0jS?<9x309p=U@0*) zL<$gkmE(1lSCu@{3D%DHQ)tGTpF-g^SmJ71oZ_O(v9)^z3P*muyw(R_AboCz%9NIa z%=}tvZjw=3TD%2WaN~Fy;&)V_wXl)^(N(Ax0fmA2$3!bBc&8ZSnT@ErbAoi|vDl#m z=U~6N`lO#+V@T#eP}w{)hP^*6Q)Of8!S=q^bk1yhxg;?gHdp*z8qQ86P7 zn&##iLGe_0uk-IJR_VUNAJHGzj{B~evn-PL63bHRcHD%T$+C}^8p5HjifG4{9ICC1 z!5_itInG}paalxveb=9g?uVrm?7>~d|A$}6Vl00QGTHpCJR%a58eaTfdIFZEg%cqP9qXY6mNjf z&Wz!Kk#mRlW8V^YwAwGgRSK|yij0nAU|>GJo$ie@8U_ebE*07+N9XzZW%Cw0bk@7q zn34Qe^)=_O7fV8&CiLGnI9WTJeI%$qLOpciv2C(;-^&mT9xh=mRHGX93)!~yii6jl zk{doLi*^~==*1F^i@gvpLIE;;x)VvlHK+&_{U*0{KV)y}Yk%V_*TG2p#ufr>dGK2bG#$COnDbrY^ zu=u>aH-}>rg48b=*$<^-JYR=?49D1jMWMCZ0!r53LktDDXgXW!{qo!*eLe$?FLN-S z&o+iiTl*+bDYN-heQV|*k|AtRcvMnjDMZ}Y$>HbS_;x{Tm;^}A$Ui$!vEjZFwXL zXp)hKrl{mt%o~~*VFav^6&ek0Gsd}VL9~(zCJ@53kba?}fr%#W{zNE7wge&;6hUfL z+FIe$Rgyrt@>@tE5q3=T`>&>6PxNKniE14}?><`rm`#n~e+BjOFl+24KPo$?1*`xD zlU!gu!wp#NZtgr2Ppc6PoZ0Jb5V=+ty{Sy!8~f^x8_o{4w2fzNc*a-jlijs>oQPRC z@&G5QW>;k?ymf>7acJl5n@J9uB}^=AkF^$UkhDO?QvF6#kQK+Q>B7lWY{y+yMrN~X zW>(hglo%0?cL&+0!+Fu_Ir6IE|nre0gs&?t2OJk&`Pb4keDTAn%{12m<}vj zTisI$k;fU8ba=)OmWMi4Vy()K8;(?)j5r6aF6_wFj+TZ5?C9mpN`@}!Cz+^bk6A%} z$sKhXnIEkn-s}B#rJb7I0_J+<1y>@;1Vo3sw;GLI-dD0fBarDJ<_@P*#oFrr$hUTW zNif9@yLFMns{vJ1&D&+b$%1bTm?_4^qr9wlkzoDRV7(I&7*%;DNQ%YX;#MJjJy-ZK zDRRjL-NfjH-AeN=*Faes+r(-=#KY;uPI6cMR?+Aga2E;#fG(llf!dS*3afv3WQ(TzQG|OqnIjnORo>A7K6}Hk%bY~0w150%hjUsWU8{gUoWPn zug4-KM1D!WI7k3MTL>|&afc(DD~yY~x-taSU%1#qp4-Sm_JE0%Zfs=qVbsxWM7qJr z@qtpWkX)b`WTl(HrG3m`8<3jFywjzFQRQLxHkV6NR>o}Foh_qME6F}+OUxr5AD=TU z*MOK%s!Br1G~fKZ8eXI|q6GWlDi~{_@=g~z#AA+n6VBNlQzcEbTlWI+26xG_Cv%Xn zUu9=GNvOX1OIxFpB&;N{A;68m&S|s`1zg8V0(c|OWM)9{@z2hPm}2A)2Z~B$ zSAEZu7BEhcT~iHbT5Gn>TTjJYVXJY@c>Mu#%QL5A-Yb6an7hG!;sF~B1hU$tr~IINm~4)G4c0lkbe>%*vK}D@!HCkVm@Nc z=fE}k9$@xXuK5>;mVnl{_vT@y?m8!%<(YeTb%bmP<`VZ2k(qv*W*H@BusR0R74@Z| zdKk?pp;VTgk@0$Jvg3G9dQ4QJrqM-GLW&^m6x>wT^k%=_&i(OmC=>>a?76Ta-hyeU zccpz_2DOp;8NnqqJj!&jBO9nb_5mv-q*bI&qb9&y`v#NIZ2fVU1yOoZFIRS@8Ps6% z>)dpyCO9`VRHSDQdrwM>*~?R0UVptNe%4TPeL|K#_SpmG+S}+kSeE67bmB)~8oSSu z`Q1mKuTqJl=39DIG9v~?Q9Yx4U?c6YO~!d4Y8>?;Jw-%85i%9){|Okw_a_4yxQ>$f zQe*{h5<(xv)ZvFD35Ca6(xpTC1POnu;P(3V28l{qfpa{5dKT}_g1)gN&XXjRBvr!{ z9N^D0pqZFXHa&)Ah$opm4^8EqWX#>RJ1j(PT`5ErY&4;An%J3;A`9u1rLMOlP0M{K zL@h-!F%k+4URYp<4R8Z5ES^A8fRoVjBKcLtC`5Rm#wO84NCY@{lN#^Y(--S)<4tq? z!aLj9%Zkgev@EsPSLe5c@8#@73c}{wR!E~n7#bcHoQsegeYiuodN`1sk}3xEtwL;N zCM7-h#{uz^DB6D~=*yZpu?tL*8ubQJYeyCx`S-@O38-eqGJVpniF0(W?)UFY8uW^O z%&dSc#OxVLc6nDChT8o9B~<{-TROi|dQ0sev>v9GB@!nZsMnN&36!fONj5uQiF`wZ z4By{Bd28?oPhOXcv3?H2I4^Emip8!_D4g=L`2co9JhHv_7P^=b=V5-p zYvJfm#fHrYENo}-=ll=b5(# zorEa`f#l50##_kAPwAg6WL928J1xHQ2r{Kr7oBWW%!-@=@;-mqaF-3a=J2mN`Wpf3 zKpBG!x!ooh&z-eRN9=f6&MWPRx^tNf7jLyIy*YT_F1c*4l{h?CX7&)}B!L{6%2RJ= zG=J`yeHfI8+Y=qJeybt!fAOG$ZQj4pLhuQ2P743sf$6AXI}bYpPuN>6E`qJ($BP3Y z_G3vbdqH<=;CE)qYYX@uH2b3%zI0wh6{GOWbN1VGLMBh zr{5xd>N7!d1MVRB+k$D+9{srN_77z`Lzmq|Is#m$QMW4%4fZtLofn~=2k2?E!-AAxJRwiuH3V<|d@Sd6h4$1hJPCc4CJ%FZnX7@#fA_Wi2Vg!ejEDFm_^QTv*BI!)K2hR;Hfz9JOY8aTe*jfOc|EN?~c0k1S--ixHW!E*5seNGdeus7pu`@2mwFXt{q$iH;R3jYVZI&fG}u zU_|z=4Hp|l#yQH>QU4GI;nJ9z1rHSR!tTIA^@Zeh-Yk63X=9}nkU*!;(K|jgZ7SJ4 z(lwI&U-AR~6NsuB|E&d0F3z2e&_szd6VGUhl&;v70mL9Y*QxK`5 zg6pkWQ-IFIHYgCy0w07TQg!v|<-}VZDY4HsPS{!i0{*Y?6m6yl10RlTL0wx;h79RguRyqQK`pV zs#)BoGHoF!6YO{)kF(Q^xm+*Y!x7_mh-KU&S&@#7_gBrUlKBrb)5Jk@8 z2fa$8W7kzlxUOFsWL7~$=~n`jk2;=P!B6~k{`_d^36OY(zOc@$I^d@*S9_Cf6TTIc zR`(!ggy@9(fd0f<);FtVDQrdo&HH<;g}Cg3n$yl_tUkD0hqn{j7;%6Lm#lr>e!;*@ zq)>^}nCW#noxT5%+S**s+5ujQGu&dFic){ZmhhP0;h5@#<~~=mj3=wmsduTOd_!@n zJGt4^<1z>T%~ibM8p~5I#s>OvE1?){{PU~>Ce&xpdc4N)u_O!%f?E@=( zp4`jpIBu@&UwW}D1N}lu9)y)JYjDHlGp~NN z(RKN%-&u}C&#qV*piFB%*|gsDMw^@HYR;z_w9PeN&2-HsfXQ8!%o#&w;q?G~y7vOl zvVPo<1NMyfgj@1iHn^~X5RW6d*xE;c=wwm0KxAu^GyV#|KFX7%zg>6V>+8*GhR3ZAq*H756)M%V^*Z8JtfQPx<3*FQGCl{&9P@&H946RAD z#91Y#>$cE$UkmLSpVRRQTeNyjQVeY~0{pt|wpmTCP9s6fFt4gxU~#(g@#tJHQL};| za+8W!h1nO~LRM)o!Ev&Bci)|bqRl`YsyGaW0&^0k#9TkE`o zOVs;%24*(9J22QE7W>c~60|Jzk*0smw8q1^JC^T9StDa6U(tSnG+%v$sfcwoL}J)l zH_?$T`9Hz&s~ksoZPig}^5-m8bi*&5)M}}i{1tvK^wLE{4;FUpj3+y>LOCnhZK~cu zTAMQ*x-mh@x8I8uthHreHtEAM@QA=Y5 zs?ZMh07|waCC8Mz^GpDptP{%!ov0Sxyw3XY+Rj)ij#jE`^J6<5acR*&BIo=V@D>rC z1ec?wyNj5Q>k?J|gs3J6<#a`JQs*idwNWMAbCspfaNuaa^IlJlj#0)?l|Qt5{=7gG{5F+f}SYp4`X=B>&Q410#bMuBa@dKdpf4xJD$oI%m2saw#O93=pyY#3iX?5|qs8kYQC#mmS zl=F`!#*Y`oYK!Hai9wXNR_do8uO= z&WdD59LQD}!YGX4#l^@gi6;8^ZVV@J0E5>V;D7_Zg@Ci2RMbkK3t^#Trc;=vJ-6>1 z17jvT!qOp>{Z!i`RsYfu>6pe0`?0jnBz}Xqm6JNgV@oP2N-K}zfoV98N}kO{w>@O0 z#8ZW(w~2y`m4>j$PAM9dR~05koHpj4%Ept_F#jB+E;*sey7(-cmVu+wt^Bk+fKm56 z9wzVB1+V%p#nKwZR##)Uv~!x&D&)e@oN zJv{ROU9AK)%|tt*OkUSxM|gY*rC=-7d#_~-ye$n;Cp@GSN5@Y+95V@CXa%ScD<_h5 z&3-Y1X!0yKPEMqErRPmf_xt(rwGI$ZRUPjPR>fIt;iE)jm9$^u9(Y)}UPY=XqxJ;e zT*SO_4_>|(I~6^jLkLr@@Mmhts05QC#nM4A!8(?G`w)6FPxhnc%A`ksIyWxZx>KoH zJTp_YP5QNrAQ+_xb*i{aE=S{YXA~*OZw?9%T&U!$#m`B9yX55OQg)4DNOJNB^Gr*f zkTWSNEv-eKm#qjECoSdAUgFp^Qf#4z4?d?{$=8pHi{!39Hx}B|&>zU+@uB_x`$L{@ zji^nTB3YRAIXyXD3JIJ-WuswgdOlbsA_2{6?ecwFy(5GS2j-Ff0y2nyWT9C(|dVE?!xIRKR)SVtN0#6PPF}C~&Nl zydT+tWw5{h%9q8P0tGWFp>y4fqV}0q1{#J|rtV&586`0ri)`i@38$uL@`K%iv5UAGQ?OHv`LIx-)o{`fWj*Y-Oo>PO? z%AKbdF*YwT9eX^O%RG0@K7IX?vsTITvNE*PoW$86i&5En`OKK`9b@muM%t&HU^Yv& z0L zXquCU>o5tE&nMs`3zbg1aYO|ZO4Y+5T7pLv!XApuWrwc1+IME@HnfXxA)?z`xQ`vd zLCb3Cn3`IPK0Bpl+6ss$hgi$66|c06=lf<;jEzcLo9kTYKmQe2wWWmNDw zpl+}xq7&$D92*xH+m06+xN)&tC&LA1SJgAU1P9VwbcE(`yFN_W+(lJvn#07!n2jUU z!h3l7ZIHKcHJO?P_Wjt!-OtFGHhBHzE3^JlX^i%?>@13EpaJW#YeZ{5`|7G5N=TlX zcKSPGqBUckP7m;rTM!lzhqe4E8w(V9O9npPVm&pp?Vw|OT(_a8#`=0 zT40v4Uk6X6c1$WZsnAht{VU6 z={X@l+N=fFz$t7QU2hf=1y_J$4nI+q_!42#?`sV)1O4<;fv%7s?Yd{3Ox5 z_*zvCNlrL{Lgyt7PxC0=1Smh;x&n2rckEyFG{qb1B160FMTd{y~`kyv1I&Di1@IKWGeGC*vSidkdC z2|e;{;OFG9>XQg07Q}dHej;)OB;o&%K^U6MUEDnY)GbC_lgZ;`u=IX4yU`aM8lz!8J3MtsVp65SS=S4}F>7~>Jq|edm zBO}ZVH50Qm)6zlES9l`iXy4Qa^uU5hI+QceNT!Sws_G;~Vmg=Oo_R{Z5|bt{i6PRZ zzl0ohIY&3-S8Kj?#`ZS73UIxz7aV5VdzV5zNeh+V2@v});#A#=*1_DIVU(>#fM!;9_` zZW?X;0; z17)K+`cXdBs@4pM`G0^2z+z05Un{%ilUlHM~!6~K zyo*Gejl(IDG)8E49$JCnBu-772QMBM?j>%i8ImFbzDG=yp1~v3>OyJ>iQG<7(0D|t z#0$>}&esVej?yDt`og%AG&xLUqmsOzPPQQ+9$u1MhRCjD^OIfP_zbew4Uy|~;!btM zEP3coW`Di%wD~?t5gwEM{l52rP5^0q*ydO`8#zxf8S%e2<1e0G92`ER2Gi>(jD^&x zQ?1>tX>|FS%8cM#$7fPA?PZqD?ihh^_4q5@<%bwj=5hz?h-X(z{QtWHS9!K;71mi% z_=9)h9GOyiG2ySGL^=$_rO%d_edXEXZ^(LRg%T23swnsLx0mVQU|t^&EV5_|1&>^w zj|_&xCw6eMid)On+8}>X;|}9p{0dhkiaNWVD3I2cM!sA;Tzt+}Z=p|($f;KB{2~)P z6kt!undWGHqIe=cltO&A`fcGSj$SxNqZ6f?>6qu6lBaRh#!}ruE-n=xF56={VkAxg zA-yVZ_q9)QMoQ9hRYTkJ)s#$)nhV(0y@9>;3SPx}9h6CtWg78YF`~p10nzlSYs^*~b66ZA4hDKD)?CrP&g^Q~&Q?d@ z27=2F{FjMja#s(tkvXl%D)TT`6@z2wYhIWtvx^DduKz?sCl9R2I(&p)Fg_0sJTv{t zC)k!@k|10GsaC@qE!SghUQIH(fnKJyUvTHo&cEIhnaUvaXW1yMXXRHhI!r4dZB&*Y z7W5cO-ajE1^M6L{?>cw2w(6G_-{<-urOiBPako41!i`tk%rBeF+n&x@Fm@!Sjb#m6 zi@bv2$SONf_QIHh#w*7@> zCz}$FTcD7&XUlz7pB1lkPkb%#-mev{sIEgWfCqw{(mk})xvwW~qh(!bbrk!Cz{AwP zmcCBWBZLSi;dw%c6kpTTs1+qO9Iy9lY3-YuQb&jmsBzl{=)p#hv|ckfFWZbBUJX=w z7su$VLYHFOIc4Ho@VhtW476k8KWS@ChyJN4{5*?vfv|h1p%8;_^V8B->Lyp$@B8a< zjAUIjRXj1NujsV0QMhK`yRoT&#Tn2_pY*r_nfwSHHDBzWKdnpmH=nj$c^FuBU-LnC z*>Yy{f+;qWX}U9)?|EEz_<;}kV#80QekLbOjuQR2?w>WPiAZe;;fEEXTowgx{_$i^ z#^Sxm@|uVVLMd0Hhth)raF6iRXE>6xO{R7?wpn31G@9ifWZF|%>k$uR1xv{>u@lS; zptA7kUb~Sr9Hb>tR2;^7>sP*edGR$oJFJf=xb%dhpYSGncwCS%5_L>c;rT5<`DN~n z$t^A{YGlI%Y!x&5XO<)Iv3X!;2{SoI4I|Hd*}~!=VE13QnWRA#;L1+K9a$Lq2R3x# z=@_6y0CXmRQzxU)4ma4X%2kTh@NOwf`{azGbcnKLX<%wFrshQqtyrP#bh<%h#Y#UD zDY2=2<#o4XMVL%RC{IW8m;egxY^nL2`Gs+6d=PoUdBTFX!e4NnbZq+7jlU{W(QO(C z(@8P*?5$c}5K1@+Me1|%xdJ~Cqj*zJt&D^UQey-{3O+xCr)VxSh3QBvw%$a>$A;4* zW!l;`=hq1ig-1n|wRChviI@)4Y-Wi5+Y9hqSd^%_K1-?NO9<`3Ar_l_7_s>lvtDr3HCn={=`y*o8cf_ja2dWsu>=W zWB0=n))XMC&v3^yPj@%^ROgI6B7OIS~oPfBl6DOd^=|7PY#yRG64Uk8ZhSlUqO`?(H&Xs zW31s+32vZCZe;-uMRU13VkD{Hn8wOR?&d3KI>Sg2N2d-}p;IOXJg`(BDc9Dtf{ zyu6<~vrFuue-DQeL%g^sh*vp;Ah-`>N5y_SqHvk6UBhP!K{VLvX!+@R6;qurn}HRw zMO>!^XQ@S!WNpgUiBj%9wqi$@pN#D>;i*8Dc#e9ZMIyNwMI0hu{asutaD%KZX|qx0 z&6`upsCA6?i*fi6h$?eYk5n4KJ8T|)s2mrv{9A{t)1hPb(8^>CY+r)E{~Qh)U@0i{ zZEXc*yeAs;0ne3W3&nHJYu9q)ca|Gx?#QzVa6>%D$l^OF~Mf$

&fvx5=du*(m2pL6-Q%I9UCE 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 Date: Mon, 23 Mar 2026 18:26:00 -0700 Subject: [PATCH 082/196] fix: crate name interpolation bugs for custom projects - Regenerate iOS/Android project scaffolding when the library name changes (detect stale projects by checking xcframework path in project.yml and JNI lib name in build.gradle) - Auto-infer ANDROID_HOME from ANDROID_NDK_HOME when only NDK is set (strips /ndk/ suffix to find SDK root) Fixes: xcframework path hardcoded as bench_mobile.xcframework, bridging header importing bench_mobileFFI.h instead of the actual library name, and Gradle failing without ANDROID_HOME. --- crates/mobench-sdk/src/codegen.rs | 34 +++++++++++++++++++++++++++++-- crates/mobench/src/lib.rs | 29 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/mobench-sdk/src/codegen.rs b/crates/mobench-sdk/src/codegen.rs index 09ab658..6ec6afa 100644 --- a/crates/mobench-sdk/src/codegen.rs +++ b/crates/mobench-sdk/src/codegen.rs @@ -759,6 +759,32 @@ pub fn ios_project_exists(output_dir: &Path) -> bool { output_dir.join("ios/BenchRunner/project.yml").exists() } +/// Checks whether an existing iOS project was generated for the given library name. +/// +/// Returns `false` if the xcframework reference in `project.yml` doesn't match, +/// which means the project needs to be regenerated for the new crate. +fn ios_project_matches_library(output_dir: &Path, library_name: &str) -> bool { + let project_yml = output_dir.join("ios/BenchRunner/project.yml"); + let Ok(content) = std::fs::read_to_string(&project_yml) else { + return false; + }; + let expected = format!("../{}.xcframework", library_name); + content.contains(&expected) +} + +/// Checks whether an existing Android project was generated for the given library name. +/// +/// Returns `false` if the JNI library name in `build.gradle` doesn't match, +/// which means the project needs to be regenerated for the new crate. +fn android_project_matches_library(output_dir: &Path, library_name: &str) -> bool { + let build_gradle = output_dir.join("android/app/build.gradle"); + let Ok(content) = std::fs::read_to_string(&build_gradle) else { + return false; + }; + let expected = format!("lib{}.so", library_name); + content.contains(&expected) +} + /// Detects the first benchmark function in a crate by scanning src/lib.rs for `#[benchmark]` /// /// This function looks for functions marked with the `#[benchmark]` attribute and returns @@ -985,7 +1011,10 @@ pub fn ensure_android_project_with_options( project_root: Option<&Path>, crate_dir: Option<&Path>, ) -> Result<(), BenchError> { - if android_project_exists(output_dir) { + let library_name = crate_name.replace('-', "_"); + if android_project_exists(output_dir) + && android_project_matches_library(output_dir, &library_name) + { return Ok(()); } @@ -1036,7 +1065,8 @@ pub fn ensure_ios_project_with_options( project_root: Option<&Path>, crate_dir: Option<&Path>, ) -> Result<(), BenchError> { - if ios_project_exists(output_dir) { + let library_name = crate_name.replace('-', "_"); + if ios_project_exists(output_dir) && ios_project_matches_library(output_dir, &library_name) { return Ok(()); } diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 38ea7cd..8d0889c 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -4941,6 +4941,7 @@ fn run_android_build( release: bool, dry_run: bool, ) -> Result { + ensure_android_home(); let profile = if release { mobench_sdk::BuildProfile::Release } else { @@ -4961,6 +4962,34 @@ fn run_android_build( Ok(result) } +/// Ensure ANDROID_HOME is set, inferring it from ANDROID_NDK_HOME if necessary. +/// +/// Gradle requires ANDROID_HOME to locate the SDK. Many developers only set +/// ANDROID_NDK_HOME (which is `$ANDROID_HOME/ndk/`). This function +/// strips the `/ndk/` suffix to derive ANDROID_HOME when it is missing. +fn ensure_android_home() { + if std::env::var("ANDROID_HOME").is_ok() { + return; + } + if let Ok(ndk_home) = std::env::var("ANDROID_NDK_HOME") { + // ANDROID_NDK_HOME is typically $ANDROID_HOME/ndk/ + let ndk_path = std::path::Path::new(&ndk_home); + if let Some(ndk_dir) = ndk_path.parent() { + if ndk_dir.file_name().is_some_and(|n| n == "ndk") { + if let Some(sdk_root) = ndk_dir.parent() { + eprintln!( + "Inferred ANDROID_HOME={} from ANDROID_NDK_HOME", + sdk_root.display() + ); + // SAFETY: called early in single-threaded CLI init, before + // any threads are spawned. + unsafe { std::env::set_var("ANDROID_HOME", sdk_root) }; + } + } + } + } +} + /// Load .env/.env.local from the repo root (best-effort, for commands that don't resolve a layout). fn load_dotenv_global() { if let Ok(root) = repo_root() { From 6c187fde5b38bff255df6559b85986f5f81aff48 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 18:28:21 -0700 Subject: [PATCH 083/196] chore: prepare 0.1.18 release Bump version across workspace, docs, and workflow defaults. Includes fixes for crate name interpolation bugs and ANDROID_HOME auto-inference from ANDROID_NDK_HOME. --- .github/workflows/reusable-bench.yml | 2 +- BENCH_SDK_INTEGRATION.md | 4 ++-- BUILD.md | 2 +- CLAUDE.md | 6 +++--- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- README.md | 4 ++-- TESTING.md | 2 +- crates/mobench-macros/README.md | 6 +++--- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench-sdk/README.md | 8 ++++---- crates/mobench/Cargo.toml | 2 +- crates/mobench/README.md | 4 ++-- 13 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 0617200..f749ff4 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -66,7 +66,7 @@ on: description: "Mobench version to install" required: false type: string - default: "0.1.17" + default: "0.1.18" mobench_ref: description: "Git ref for mobile-bench-rs (overrides mobench_version when set)" required: false diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index f8c7322..ebfb1d6 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.17" +mobench-sdk = "0.1.18" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.17" +mobench-sdk = "0.1.18" ``` ## 3) Annotate benchmark functions diff --git a/BUILD.md b/BUILD.md index 041ce44..b34fa1e 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,7 +2,7 @@ Complete build instructions for Android and iOS targets. -In `mobench 0.1.17`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. +In `mobench 0.1.18`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. > **For SDK Integrators**: Use the CLI commands: > - `cargo mobench check --target android` (validate prerequisites first) diff --git a/CLAUDE.md b/CLAUDE.md index 3ce208c..4ac3b95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.17):** +**Published on crates.io as the mobench ecosystem (v0.1.18):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation @@ -180,7 +180,7 @@ cargo mobench verify --target android --check-artifacts Use `cargo mobench build --target ` for local or CI builds. The CLI handles library builds, binding generation, and app packaging without extra scripts. -In `mobench 0.1.17`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. +In `mobench 0.1.18`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. **Important iOS Build Details:** @@ -594,7 +594,7 @@ The workflow supports manual dispatch with platform selection: ```toml [dependencies] -mobench-sdk = "0.1.17" +mobench-sdk = "0.1.18" inventory = "0.3" ``` diff --git a/Cargo.lock b/Cargo.lock index 09e2f96..57f671f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,7 +1017,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.17" +version = "0.1.18" dependencies = [ "anyhow", "clap", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.17" +version = "0.1.18" dependencies = [ "proc-macro2", "quote", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.17" +version = "0.1.18" dependencies = [ "anyhow", "include_dir", @@ -1522,7 +1522,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.17" +version = "0.1.18" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 7960d32..ff769be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.17" +version = "0.1.18" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 8acaf94..72399ff 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.17` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.18` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. CLI flags override config file values when provided. - In `cargo mobench run --config ` mode, `--device-matrix ` overrides `device_matrix` from the config file. @@ -206,7 +206,7 @@ fn db_query(db: &Database) { ## Release Notes -### v0.1.17 +### v0.1.18 - Added a shared config-first project resolver across `build`, `run`, packaging, `list`, and `verify`. - Added `--project-root` and `--crate-path` parity across the main CLI commands for custom repository layouts. diff --git a/TESTING.md b/TESTING.md index 7090060..0726b8e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ This document provides comprehensive testing instructions for mobile-bench-rs. > - See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide > **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see **[BUILD.md](BUILD.md)**. This document focuses on testing scenarios and troubleshooting. -In `mobench 0.1.17`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. +In `mobench 0.1.18`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. ## Table of Contents - [Prerequisites](#prerequisites) diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index a408ad7..70b76c7 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -4,7 +4,7 @@ Procedural macros for the [mobench](https://crates.io/crates/mobench) mobile ben This crate provides the `#[benchmark]` attribute macro that automatically registers functions for mobile benchmarking. It uses compile-time registration via the `inventory` crate to build a registry of benchmark functions. -In `mobench 0.1.17`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. +In `mobench 0.1.18`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. ## Features @@ -19,8 +19,8 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -mobench-macros = "0.1.17" -mobench-sdk = "0.1.17" # For the runtime +mobench-macros = "0.1.18" +mobench-sdk = "0.1.18" # For the runtime ``` ### Basic Example diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 90ee17a..b1fd953 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.17", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.18", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index 250c1fe..882aa13 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -15,7 +15,7 @@ Transform your Rust project into a mobile benchmarking suite. This SDK provides - **BrowserStack integration**: Test on real devices in the cloud - **UniFFI bindings**: Automatic FFI generation for mobile platforms - **Configuration file support**: `mobench.toml` for project settings -- **Config-first CLI integration**: `mobench 0.1.17` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root +- **Config-first CLI integration**: `mobench 0.1.18` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root ## Quick Start @@ -23,7 +23,7 @@ Add mobench-sdk to your project: ```toml [dependencies] -mobench-sdk = "0.1.17" +mobench-sdk = "0.1.18" ``` Mark functions to benchmark: @@ -86,7 +86,7 @@ This creates: - `android/` or `ios/` - Mobile app projects - `bench-config.toml` - Configuration file -The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.17` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. +The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.18` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. ### 2. Add Benchmarks @@ -391,7 +391,7 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.17` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.18` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### `bench-config.toml` (Run Configuration) diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index e309168..48c59b1 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.17", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.18", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/README.md b/crates/mobench/README.md index db5b6ba..cc80228 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -45,7 +45,7 @@ This creates: - `mobench.toml` - Project configuration file (when using `init`) - `benches/example.rs` - Example benchmarks (with `--examples`) -Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.17` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. +Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.18` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. ### 2. Write Benchmarks @@ -514,7 +514,7 @@ default_warmup = 10 ``` CLI flags always override config file values when provided. -Resolution precedence in `0.1.17` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.18` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### Run Config File Format (`bench-config.toml`) From c455e31177d46c1418f16ae770b25c763e48a6a3 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 19:23:21 -0700 Subject: [PATCH 084/196] feat: built-in device profiles, multi-function ci run, reusable PR workflows 1. Built-in device profiles: `devices resolve --profile low-spec` works without a YAML file. Hardcoded profiles: low-spec (iPhone 11/Pixel 6), mid-spec (iPhone 14/Pixel 7), high-spec (iPhone 16 Pro/Galaxy S24). 2. Multi-function ci run: `ci run --functions '["fn1","fn2"]'` loops over functions in Rust instead of YAML shell loops. Accepts JSON array or comma-separated values. --function (singular) still works. 3. head_sha checkout: reusable-bench.yml accepts `head_sha` input for compile-gated exact-SHA benchmarking. 4. History bundle: summarize job generates manifest.json + copies all results into a mobench-history-bundle artifact. 5. Reusable PR trigger workflows: - reusable-pr-command.yml: parses /mobench comments, checks trust, dispatches benchmark workflow with parsed parameters - reusable-pr-auto.yml: dispatches on bench label + compile-gate pass, with configurable gate workflow name --- .github/workflows/reusable-bench.yml | 124 +++++++++++------ .github/workflows/reusable-pr-auto.yml | 130 ++++++++++++++++++ .github/workflows/reusable-pr-command.yml | 121 +++++++++++++++++ crates/mobench/src/lib.rs | 156 +++++++++++++++++----- 4 files changed, 463 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/reusable-pr-auto.yml create mode 100644 .github/workflows/reusable-pr-command.yml diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index f749ff4..8179838 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -89,6 +89,10 @@ on: required: false type: string default: "5.0" + head_sha: + description: "Exact commit SHA to checkout in the caller repo (for compile-gated runs)" + required: false + type: string secrets: BROWSERSTACK_USERNAME: required: true @@ -131,6 +135,7 @@ jobs: uses: actions/checkout@v4 with: path: caller + ref: ${{ inputs.head_sha || github.sha }} - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -228,28 +233,19 @@ jobs: RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} run: | set -euo pipefail - mkdir -p target/mobench/ci/ios - device_spec="${IOS_DEVICE}-${IOS_OS_VERSION}" echo "Running iOS benchmarks on: ${device_spec}" - for func in $(echo "$FUNCTIONS_JSON" | jq -r '.[]'); do - slug=$(echo "$func" | tr ':' '_' | tr '/' '-') - echo "::group::Benchmark: ${func}" - cargo-mobench run \ - --target ios \ - --function "${func}" \ - --iterations "${ITERATIONS}" \ - --warmup "${WARMUP}" \ - --devices "${device_spec}" \ - $RELEASE_FLAG \ - --fetch \ - --summary-csv \ - --output "target/mobench/ci/ios/${slug}.json" || { - echo "::warning::iOS benchmark failed for function: ${func}" - } - echo "::endgroup::" - done + cargo-mobench ci run \ + --target ios \ + --functions "${FUNCTIONS_JSON}" \ + --iterations "${ITERATIONS}" \ + --warmup "${WARMUP}" \ + --devices "${device_spec}" \ + $RELEASE_FLAG \ + --fetch \ + --output-dir target/mobench/ci/ios \ + || echo "::warning::iOS benchmark run failed" - name: Upload iOS results if: always() @@ -279,6 +275,7 @@ jobs: uses: actions/checkout@v4 with: path: caller + ref: ${{ inputs.head_sha || github.sha }} - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -383,28 +380,19 @@ jobs: RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} run: | set -euo pipefail - mkdir -p target/mobench/ci/android - device_spec="${ANDROID_DEVICE}-${ANDROID_OS_VERSION}" echo "Running Android benchmarks on: ${device_spec}" - for func in $(echo "$FUNCTIONS_JSON" | jq -r '.[]'); do - slug=$(echo "$func" | tr ':' '_' | tr '/' '-') - echo "::group::Benchmark: ${func}" - cargo-mobench run \ - --target android \ - --function "${func}" \ - --iterations "${ITERATIONS}" \ - --warmup "${WARMUP}" \ - --devices "${device_spec}" \ - $RELEASE_FLAG \ - --fetch \ - --summary-csv \ - --output "target/mobench/ci/android/${slug}.json" || { - echo "::warning::Android benchmark failed for function: ${func}" - } - echo "::endgroup::" - done + cargo-mobench ci run \ + --target android \ + --functions "${FUNCTIONS_JSON}" \ + --iterations "${ITERATIONS}" \ + --warmup "${WARMUP}" \ + --devices "${device_spec}" \ + $RELEASE_FLAG \ + --fetch \ + --output-dir target/mobench/ci/android \ + || echo "::warning::Android benchmark run failed" - name: Upload Android results if: always() @@ -595,3 +583,63 @@ jobs: || true fi done + + - name: Build history bundle + if: always() + shell: bash + env: + HEAD_SHA: ${{ inputs.head_sha || github.sha }} + PR_NUMBER: ${{ inputs.pr_number }} + REQUESTED_BY: ${{ inputs.requested_by }} + run: | + mkdir -p history-bundle + TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + + # Collect all summary JSONs into the bundle + for dir in results/ios results/android; do + if [ -d "$dir" ]; then + PLATFORM=$(basename "$dir") + cp "$dir"/*.json "history-bundle/" 2>/dev/null || true + cp "$dir"/*.csv "history-bundle/" 2>/dev/null || true + fi + done + + # Generate manifest + jq -n \ + --arg ts "$TIMESTAMP" \ + --arg sha "$HEAD_SHA" \ + --arg pr "${PR_NUMBER:-}" \ + --arg by "${REQUESTED_BY:-ci}" \ + --arg ver "mobench-history-v1" \ + '{ + schema_version: $ver, + timestamp: $ts, + commit_sha: $sha, + pr_number: (if $pr != "" then ($pr | tonumber) else null end), + requested_by: $by, + platforms: [ + (if ("results/ios" | path(.) // empty) then "ios" else empty end), + (if ("results/android" | path(.) // empty) then "android" else empty end) + ] + }' > history-bundle/manifest.json 2>/dev/null || \ + jq -n \ + --arg ts "$TIMESTAMP" \ + --arg sha "$HEAD_SHA" \ + --arg pr "${PR_NUMBER:-}" \ + --arg by "${REQUESTED_BY:-ci}" \ + --arg ver "mobench-history-v1" \ + '{ + schema_version: $ver, + timestamp: $ts, + commit_sha: $sha, + pr_number: (if $pr != "" then ($pr | tonumber) else null end), + requested_by: $by + }' > history-bundle/manifest.json + + - name: Upload history bundle + if: always() + uses: actions/upload-artifact@v4 + with: + name: mobench-history-bundle + path: history-bundle/ + if-no-files-found: warn diff --git a/.github/workflows/reusable-pr-auto.yml b/.github/workflows/reusable-pr-auto.yml new file mode 100644 index 0000000..b835e58 --- /dev/null +++ b/.github/workflows/reusable-pr-auto.yml @@ -0,0 +1,130 @@ +name: "Reusable: bench label + compile-gate dispatch" + +# Dispatches benchmarks when a PR is labeled with `bench` AND a compile-gate +# workflow has passed. Callers configure which CI workflow must pass first. + +on: + workflow_call: + inputs: + benchmark_workflow: + description: "Workflow file to dispatch (e.g. .github/workflows/bench.yml)" + required: true + type: string + compile_gate_workflow_name: + description: "Name of the CI workflow that must pass before dispatching (e.g. 'CI')" + required: true + type: string + bench_label: + description: "Label name that triggers benchmarks" + required: false + type: string + default: "bench" + crate_path: + description: "Path to the benchmark crate in the caller repo" + required: true + type: string + functions: + description: "JSON array of benchmark function names" + required: true + type: string + default_device_profile: + description: "Default device profile" + required: false + type: string + default: "low-spec" + default_iterations: + description: "Default iteration count" + required: false + type: string + default: "30" + default_warmup: + description: "Default warmup count" + required: false + type: string + default: "5" + +permissions: + contents: read + pull-requests: read + checks: read + +jobs: + gate-and-dispatch: + name: Check compile gate and dispatch + runs-on: ubuntu-latest + # Trigger on: label added, or check_suite/workflow_run completed + if: >- + (github.event_name == 'pull_request' && github.event.action == 'labeled') || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') + steps: + - name: Resolve PR context + id: pr + env: + GH_TOKEN: ${{ github.token }} + BENCH_LABEL: ${{ inputs.bench_label }} + GATE_WORKFLOW: ${{ inputs.compile_gate_workflow_name }} + run: | + # For label events, use the PR directly + if [ "${{ github.event_name }}" = "pull_request" ]; then + PR_NUMBER="${{ github.event.pull_request.number }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + else + # For workflow_run events, find the associated PR + HEAD_SHA="${{ github.event.workflow_run.head_sha }}" + PR_NUMBER=$(gh api "repos/${{ github.repository }}/pulls?state=open&sort=updated&direction=desc&per_page=20" \ + --jq ".[] | select(.head.sha == \"${HEAD_SHA}\") | .number" | head -1) + if [ -z "$PR_NUMBER" ]; then + echo "::notice::No open PR found for SHA ${HEAD_SHA}, skipping" + echo "should_dispatch=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + + # Check if bench label is present + HAS_LABEL=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/labels" \ + --jq ".[].name" | grep -qx "$BENCH_LABEL" && echo "true" || echo "false") + if [ "$HAS_LABEL" != "true" ]; then + echo "::notice::PR #${PR_NUMBER} does not have '${BENCH_LABEL}' label, skipping" + echo "should_dispatch=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Check if compile gate passed for this SHA + GATE_STATUS=$(gh api "repos/${{ github.repository }}/commits/${HEAD_SHA}/check-runs" \ + --jq ".check_runs[] | select(.name == \"${GATE_WORKFLOW}\" or (.app.name == \"GitHub Actions\" and .name == \"${GATE_WORKFLOW}\")) | .conclusion" \ + | head -1) + if [ "$GATE_STATUS" != "success" ]; then + echo "::notice::Compile gate '${GATE_WORKFLOW}' not yet passed for ${HEAD_SHA} (status: ${GATE_STATUS:-pending})" + echo "should_dispatch=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT" + echo "should_dispatch=true" >> "$GITHUB_OUTPUT" + + - name: Dispatch benchmark workflow + if: steps.pr.outputs.should_dispatch == 'true' + env: + GH_TOKEN: ${{ github.token }} + WORKFLOW: ${{ inputs.benchmark_workflow }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + FUNCTIONS: ${{ inputs.functions }} + CRATE_PATH: ${{ inputs.crate_path }} + DEVICE_PROFILE: ${{ inputs.default_device_profile }} + ITERATIONS: ${{ inputs.default_iterations }} + WARMUP: ${{ inputs.default_warmup }} + run: | + gh workflow run "$WORKFLOW" \ + --ref "${{ github.event.repository.default_branch }}" \ + -f head_sha="$HEAD_SHA" \ + -f crate_path="$CRATE_PATH" \ + -f functions="$FUNCTIONS" \ + -f platform="both" \ + -f device_profile="$DEVICE_PROFILE" \ + -f iterations="$ITERATIONS" \ + -f warmup="$WARMUP" \ + -f pr_number="$PR_NUMBER" \ + -f requested_by="auto:${GITHUB_EVENT_NAME}" + echo "Dispatched benchmark for PR #${PR_NUMBER} at ${HEAD_SHA}" diff --git a/.github/workflows/reusable-pr-command.yml b/.github/workflows/reusable-pr-command.yml new file mode 100644 index 0000000..3b71b61 --- /dev/null +++ b/.github/workflows/reusable-pr-command.yml @@ -0,0 +1,121 @@ +name: "Reusable: /mobench PR comment dispatch" + +# Parses /mobench commands from PR comments and dispatches benchmark workflows. +# Caller must have `pull-requests: read` and `issues: read` permissions. + +on: + workflow_call: + inputs: + benchmark_workflow: + description: "Workflow file to dispatch (e.g. .github/workflows/bench.yml)" + required: true + type: string + crate_path: + description: "Path to the benchmark crate in the caller repo" + required: true + type: string + functions: + description: "JSON array of benchmark function names" + required: true + type: string + trusted_associations: + description: "Comma-separated author associations allowed to trigger (default: OWNER,MEMBER,COLLABORATOR)" + required: false + type: string + default: "OWNER,MEMBER,COLLABORATOR" + default_iterations: + description: "Default iteration count" + required: false + type: string + default: "30" + default_warmup: + description: "Default warmup count" + required: false + type: string + default: "5" + +permissions: + contents: read + pull-requests: read + +jobs: + dispatch: + name: Parse /mobench and dispatch + if: >- + github.event_name == 'issue_comment' && + github.event.action == 'created' && + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/mobench') + runs-on: ubuntu-latest + steps: + - name: Check trust + id: trust + env: + AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} + TRUSTED: ${{ inputs.trusted_associations }} + run: | + if echo "$TRUSTED" | tr ',' '\n' | grep -qx "$AUTHOR_ASSOCIATION"; then + echo "trusted=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::Untrusted author association: $AUTHOR_ASSOCIATION" + echo "trusted=false" >> "$GITHUB_OUTPUT" + fi + + - name: Parse command + if: steps.trust.outputs.trusted == 'true' + id: parse + env: + COMMENT_BODY: ${{ github.event.comment.body }} + DEFAULT_ITERATIONS: ${{ inputs.default_iterations }} + DEFAULT_WARMUP: ${{ inputs.default_warmup }} + run: | + # Extract key=value pairs from /mobench command + LINE=$(echo "$COMMENT_BODY" | head -1) + + platform=$(echo "$LINE" | grep -oP 'platform=\K\S+' || echo "both") + device_profile=$(echo "$LINE" | grep -oP 'device_profile=\K\S+' || echo "low-spec") + iterations=$(echo "$LINE" | grep -oP 'iterations=\K\S+' || echo "$DEFAULT_ITERATIONS") + warmup=$(echo "$LINE" | grep -oP 'warmup=\K\S+' || echo "$DEFAULT_WARMUP") + + echo "platform=${platform}" >> "$GITHUB_OUTPUT" + echo "device_profile=${device_profile}" >> "$GITHUB_OUTPUT" + echo "iterations=${iterations}" >> "$GITHUB_OUTPUT" + echo "warmup=${warmup}" >> "$GITHUB_OUTPUT" + + - name: Get PR head SHA + if: steps.trust.outputs.trusted == 'true' + id: pr + env: + GH_TOKEN: ${{ github.token }} + PR_URL: ${{ github.event.issue.pull_request.url }} + run: | + head_sha=$(gh api "$PR_URL" --jq '.head.sha') + echo "head_sha=${head_sha}" >> "$GITHUB_OUTPUT" + + - name: Dispatch benchmark workflow + if: steps.trust.outputs.trusted == 'true' + env: + GH_TOKEN: ${{ github.token }} + WORKFLOW: ${{ inputs.benchmark_workflow }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PLATFORM: ${{ steps.parse.outputs.platform }} + DEVICE_PROFILE: ${{ steps.parse.outputs.device_profile }} + ITERATIONS: ${{ steps.parse.outputs.iterations }} + WARMUP: ${{ steps.parse.outputs.warmup }} + PR_NUMBER: ${{ github.event.issue.number }} + REQUESTED_BY: ${{ github.event.comment.user.login }} + FUNCTIONS: ${{ inputs.functions }} + CRATE_PATH: ${{ inputs.crate_path }} + run: | + gh workflow run "$WORKFLOW" \ + --ref "${{ github.event.repository.default_branch }}" \ + -f head_sha="$HEAD_SHA" \ + -f crate_path="$CRATE_PATH" \ + -f functions="$FUNCTIONS" \ + -f platform="$PLATFORM" \ + -f device_profile="$DEVICE_PROFILE" \ + -f iterations="$ITERATIONS" \ + -f warmup="$WARMUP" \ + -f pr_number="$PR_NUMBER" \ + -f requested_by="$REQUESTED_BY" + echo "Dispatched benchmark for PR #${PR_NUMBER} at ${HEAD_SHA}" diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 8d0889c..7f4f912 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -655,8 +655,14 @@ enum ReportCommand { struct CiRunArgs { #[arg(long, value_enum)] target: CiTarget, - #[arg(long, help = "Fully-qualified Rust function to benchmark")] - function: String, + #[arg(long, help = "Fully-qualified Rust function to benchmark (single function)")] + function: Option, + #[arg( + long, + value_delimiter = ',', + help = "Multiple benchmark functions (comma-separated or JSON array). Runs each in sequence." + )] + functions: Vec, #[arg(long, default_value_t = 100)] iterations: u32, #[arg(long, default_value_t = 10)] @@ -2354,15 +2360,46 @@ pub fn run_request(request: &RunRequest) -> Result { }) } +fn resolve_ci_functions(args: &CiRunArgs) -> Result> { + let mut funcs = args.functions.clone(); + + // --function (singular) is sugar for a single-element list + if let Some(ref f) = args.function { + if !funcs.contains(f) { + funcs.insert(0, f.clone()); + } + } + + // Support JSON array passed as a single element: '["a","b"]' + if funcs.len() == 1 { + let trimmed = funcs[0].trim(); + if trimmed.starts_with('[') { + if let Ok(parsed) = serde_json::from_str::>(trimmed) { + return Ok(parsed); + } + } + } + + if funcs.is_empty() { + bail!("At least one benchmark function is required. Use --function or --functions."); + } + Ok(funcs) +} + fn cmd_ci_run(args: CiRunArgs) -> Result<()> { + let all_functions = resolve_ci_functions(&args)?; + fs::create_dir_all(&args.output_dir) .with_context(|| format!("creating ci output dir {}", args.output_dir.display()))?; let metadata = ci_metadata_from_args(&args); let targets = args.target.targets(); - if targets.len() == 1 { + if targets.len() == 1 && all_functions.len() == 1 { + // Fast path: single target, single function — original behavior let target = targets[0]; - let exit_code = cmd_ci_run_single(&args, target, &args.output_dir, &metadata)?; + let mut single_args = args.clone(); + single_args.function = Some(all_functions[0].clone()); + let exit_code = cmd_ci_run_single(&single_args, target, &args.output_dir, &metadata)?; let summary_json = args.output_dir.join("summary.json"); let summary_md = args.output_dir.join("summary.md"); @@ -2390,10 +2427,20 @@ fn cmd_ci_run(args: CiRunArgs) -> Result<()> { for target in targets { let target_value = *target; - let target_dir = args.output_dir.join(target_value.as_str()); + for func in &all_functions { + let slug = func.replace("::", "_").replace('/', "-"); + let target_dir = if all_functions.len() == 1 { + args.output_dir.join(target_value.as_str()) + } else { + args.output_dir + .join(target_value.as_str()) + .join(&slug) + }; fs::create_dir_all(&target_dir) .with_context(|| format!("creating target output dir {}", target_dir.display()))?; - let exit_code = cmd_ci_run_single(&args, target_value, &target_dir, &metadata)?; + let mut func_args = args.clone(); + func_args.function = Some(func.clone()); + let exit_code = cmd_ci_run_single(&func_args, target_value, &target_dir, &metadata)?; if exit_code == EXIT_REGRESSION { regression_detected = true; } else if exit_code != 0 { @@ -2436,7 +2483,8 @@ fn cmd_ci_run(args: CiRunArgs) -> Result<()> { } merged_csv_rows.push(format!("{},{}", target_value.as_str(), line)); } - } + } // end for func + } // end for target let root_summary_json = args.output_dir.join("summary.json"); let root_summary_md = args.output_dir.join("summary.md"); @@ -2699,7 +2747,7 @@ fn cmd_ci_run_single( let result = run_request(&RunRequest { target, - function: args.function.clone(), + function: args.function.clone().unwrap_or_default(), iterations: args.iterations, warmup: args.warmup, device_selection: DeviceSelection { @@ -6301,6 +6349,29 @@ struct ResolvedMatrixDevice { tags: Vec, } +/// Built-in device profiles so `devices resolve` works without a YAML file. +fn builtin_device_for_profile( + platform: DevicePlatform, + profile: &str, +) -> Option { + let (name, os, os_version) = match (platform, profile) { + (DevicePlatform::Ios, "low-spec") => ("iPhone 11", "ios", "13"), + (DevicePlatform::Ios, "mid-spec") => ("iPhone 14", "ios", "16"), + (DevicePlatform::Ios, "high-spec") => ("iPhone 16 Pro", "ios", "18"), + (DevicePlatform::Android, "low-spec") => ("Google Pixel 6", "android", "12.0"), + (DevicePlatform::Android, "mid-spec") => ("Google Pixel 7", "android", "13.0"), + (DevicePlatform::Android, "high-spec") => ("Samsung Galaxy S24", "android", "14.0"), + _ => return None, + }; + Some(ResolvedMatrixDevice { + identifier: format!("{name}-{os_version}"), + name: name.to_string(), + os: os.to_string(), + os_version: os_version.to_string(), + tags: vec![profile.to_string()], + }) +} + fn cmd_devices_resolve( platform: DevicePlatform, profile: Option, @@ -6308,25 +6379,46 @@ fn cmd_devices_resolve( device_matrix_path: Option<&Path>, format: CheckOutputFormat, ) -> Result<()> { - let (matrix_path, config_tags) = resolve_matrix_for_cli(config_path, device_matrix_path) - .with_context( - || "config_error: unable to resolve device matrix source for `devices resolve`", - )?; - let matrix = load_device_matrix(&matrix_path).with_context(|| { - format!( - "config_error: failed to parse device matrix at {}", - matrix_path.display() - ) - })?; - - let selected_tags = match profile.as_ref().map(|v| v.trim()).filter(|v| !v.is_empty()) { - Some(tag) => vec![tag.to_string()], - None => config_tags - .filter(|tags| !tags.is_empty()) - .unwrap_or_else(|| vec!["default".to_string()]), - }; - - let resolved = resolve_devices_from_matrix(matrix.devices, platform, &selected_tags)?; + let profile_str = profile + .as_deref() + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .unwrap_or("default"); + + // Try loading matrix from file first; fall back to built-in profiles + let (resolved, source) = + match resolve_matrix_for_cli(config_path, device_matrix_path) { + Ok((matrix_path, config_tags)) => { + let matrix = load_device_matrix(&matrix_path).with_context(|| { + format!( + "config_error: failed to parse device matrix at {}", + matrix_path.display() + ) + })?; + let selected_tags = if profile.is_some() { + vec![profile_str.to_string()] + } else { + config_tags + .filter(|tags| !tags.is_empty()) + .unwrap_or_else(|| vec!["default".to_string()]) + }; + let devices = + resolve_devices_from_matrix(matrix.devices, platform, &selected_tags)?; + (devices, format!("matrix:{}", matrix_path.display())) + } + Err(_) => { + // No matrix file found — use built-in profiles + if let Some(device) = builtin_device_for_profile(platform, profile_str) { + (vec![device], "builtin".to_string()) + } else { + bail!( + "No device matrix found and '{}' is not a built-in profile. \ + Built-in profiles: low-spec, mid-spec, high-spec", + profile_str + ); + } + } + }; match format { CheckOutputFormat::Text => { @@ -6335,14 +6427,18 @@ fn cmd_devices_resolve( } } CheckOutputFormat::Json => { + let first: Option<&ResolvedMatrixDevice> = resolved.first(); let output = json!({ "platform": match platform { DevicePlatform::Android => "android", DevicePlatform::Ios => "ios", }, - "profile_tags": selected_tags, - "device_matrix": matrix_path.display().to_string(), + "profile": profile_str, + "source": source, "count": resolved.len(), + "device": first.map(|d| &d.name), + "name": first.map(|d| &d.name), + "os_version": first.map(|d| &d.os_version), "devices": resolved, }); println!("{}", serde_json::to_string_pretty(&output)?); @@ -7961,7 +8057,7 @@ project = "proj" command: CiCommand::Run(args), } => { assert_eq!(args.target, CiTarget::Android); - assert_eq!(args.function, "sample_fns::fibonacci"); + assert_eq!(args.function.as_deref(), Some("sample_fns::fibonacci")); assert_eq!(args.output_dir, PathBuf::from("target/mobench/ci")); } _ => panic!("expected ci run command"), From 13a40b4962904ebc65daca3409b229489223909b Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 19:24:29 -0700 Subject: [PATCH 085/196] chore: prepare 0.1.19 release Bump version across workspace, docs, and workflow defaults. Includes built-in device profiles, multi-function ci run, head_sha checkout, history bundle, and reusable PR workflows. --- .github/workflows/reusable-bench.yml | 2 +- BENCH_SDK_INTEGRATION.md | 4 ++-- BUILD.md | 2 +- CLAUDE.md | 6 +++--- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- README.md | 4 ++-- TESTING.md | 2 +- crates/mobench-macros/README.md | 6 +++--- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench-sdk/README.md | 8 ++++---- crates/mobench/Cargo.toml | 2 +- crates/mobench/README.md | 4 ++-- 13 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 8179838..51d40fe 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -66,7 +66,7 @@ on: description: "Mobench version to install" required: false type: string - default: "0.1.18" + default: "0.1.19" mobench_ref: description: "Git ref for mobile-bench-rs (overrides mobench_version when set)" required: false diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index ebfb1d6..0b67ccc 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.18" +mobench-sdk = "0.1.19" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.18" +mobench-sdk = "0.1.19" ``` ## 3) Annotate benchmark functions diff --git a/BUILD.md b/BUILD.md index b34fa1e..6e46584 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,7 +2,7 @@ Complete build instructions for Android and iOS targets. -In `mobench 0.1.18`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. +In `mobench 0.1.19`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. > **For SDK Integrators**: Use the CLI commands: > - `cargo mobench check --target android` (validate prerequisites first) diff --git a/CLAUDE.md b/CLAUDE.md index 4ac3b95..78f5725 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.18):** +**Published on crates.io as the mobench ecosystem (v0.1.19):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation @@ -180,7 +180,7 @@ cargo mobench verify --target android --check-artifacts Use `cargo mobench build --target ` for local or CI builds. The CLI handles library builds, binding generation, and app packaging without extra scripts. -In `mobench 0.1.18`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. +In `mobench 0.1.19`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. **Important iOS Build Details:** @@ -594,7 +594,7 @@ The workflow supports manual dispatch with platform selection: ```toml [dependencies] -mobench-sdk = "0.1.18" +mobench-sdk = "0.1.19" inventory = "0.3" ``` diff --git a/Cargo.lock b/Cargo.lock index 57f671f..6ccaf31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,7 +1017,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.18" +version = "0.1.19" dependencies = [ "anyhow", "clap", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.18" +version = "0.1.19" dependencies = [ "proc-macro2", "quote", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.18" +version = "0.1.19" dependencies = [ "anyhow", "include_dir", @@ -1522,7 +1522,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.18" +version = "0.1.19" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index ff769be..09e5934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.18" +version = "0.1.19" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 72399ff..22e89d3 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.18` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.19` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. CLI flags override config file values when provided. - In `cargo mobench run --config ` mode, `--device-matrix ` overrides `device_matrix` from the config file. @@ -206,7 +206,7 @@ fn db_query(db: &Database) { ## Release Notes -### v0.1.18 +### v0.1.19 - Added a shared config-first project resolver across `build`, `run`, packaging, `list`, and `verify`. - Added `--project-root` and `--crate-path` parity across the main CLI commands for custom repository layouts. diff --git a/TESTING.md b/TESTING.md index 0726b8e..5f34594 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ This document provides comprehensive testing instructions for mobile-bench-rs. > - See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide > **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see **[BUILD.md](BUILD.md)**. This document focuses on testing scenarios and troubleshooting. -In `mobench 0.1.18`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. +In `mobench 0.1.19`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. ## Table of Contents - [Prerequisites](#prerequisites) diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index 70b76c7..0629b18 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -4,7 +4,7 @@ Procedural macros for the [mobench](https://crates.io/crates/mobench) mobile ben This crate provides the `#[benchmark]` attribute macro that automatically registers functions for mobile benchmarking. It uses compile-time registration via the `inventory` crate to build a registry of benchmark functions. -In `mobench 0.1.18`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. +In `mobench 0.1.19`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. ## Features @@ -19,8 +19,8 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -mobench-macros = "0.1.18" -mobench-sdk = "0.1.18" # For the runtime +mobench-macros = "0.1.19" +mobench-sdk = "0.1.19" # For the runtime ``` ### Basic Example diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index b1fd953..c6a56ee 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.18", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.19", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index 882aa13..a6df2ff 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -15,7 +15,7 @@ Transform your Rust project into a mobile benchmarking suite. This SDK provides - **BrowserStack integration**: Test on real devices in the cloud - **UniFFI bindings**: Automatic FFI generation for mobile platforms - **Configuration file support**: `mobench.toml` for project settings -- **Config-first CLI integration**: `mobench 0.1.18` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root +- **Config-first CLI integration**: `mobench 0.1.19` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root ## Quick Start @@ -23,7 +23,7 @@ Add mobench-sdk to your project: ```toml [dependencies] -mobench-sdk = "0.1.18" +mobench-sdk = "0.1.19" ``` Mark functions to benchmark: @@ -86,7 +86,7 @@ This creates: - `android/` or `ios/` - Mobile app projects - `bench-config.toml` - Configuration file -The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.18` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. +The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.19` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. ### 2. Add Benchmarks @@ -391,7 +391,7 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.18` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.19` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### `bench-config.toml` (Run Configuration) diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 48c59b1..dbf2b61 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.18", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.19", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/README.md b/crates/mobench/README.md index cc80228..41adb60 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -45,7 +45,7 @@ This creates: - `mobench.toml` - Project configuration file (when using `init`) - `benches/example.rs` - Example benchmarks (with `--examples`) -Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.18` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. +Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.19` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. ### 2. Write Benchmarks @@ -514,7 +514,7 @@ default_warmup = 10 ``` CLI flags always override config file values when provided. -Resolution precedence in `0.1.18` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.19` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### Run Config File Format (`bench-config.toml`) From c937c44dc85ced33cf71cde7cb3c04e113abafbd Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 20:10:21 -0700 Subject: [PATCH 086/196] fix: use continue-on-error for GitHub App token in reusable workflow secrets.* isn't available in if expressions for reusable workflows. Use continue-on-error: true to let the step fail gracefully when the secret is empty, and check steps.app-token.outcome instead. --- .github/workflows/reusable-bench.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 51d40fe..4479c33 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -552,14 +552,15 @@ jobs: - name: Generate GitHub App token id: app-token - if: inputs.pr_number != '' && secrets.MOBENCH_APP_PRIVATE_KEY != '' + if: inputs.pr_number != '' + continue-on-error: true uses: actions/create-github-app-token@v1 with: app-id: ${{ secrets.MOBENCH_APP_ID }} private-key: ${{ secrets.MOBENCH_APP_PRIVATE_KEY }} - name: Create Check Run - if: steps.app-token.outputs.token != '' + if: steps.app-token.outcome == 'success' shell: bash env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} From 05141c4110d4ad900f3aeeea6bcd364f2de8fc4a Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 20:20:06 -0700 Subject: [PATCH 087/196] fix: pass --crate-path to build/package commands, fix Android SDK setup - Pass --crate-path (from inputs.crate_path) to all cargo-mobench build, package-ipa, and package-xcuitest commands via env var. Without this, auto-detection fails for non-root crate layouts. - Split Android SDK setup into setup-android (sets ANDROID_HOME) + explicit sdkmanager install (avoids packages parsing issues on macos-14 runners). --- .github/workflows/reusable-bench.yml | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 4479c33..356b15c 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -213,11 +213,12 @@ jobs: shell: bash env: RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} + CRATE_PATH: ${{ inputs.crate_path }} run: | set -euo pipefail - cargo-mobench build --target ios $RELEASE_FLAG - cargo-mobench package-ipa --method adhoc - cargo-mobench package-xcuitest + cargo-mobench build --target ios $RELEASE_FLAG --crate-path "$CRATE_PATH" + cargo-mobench package-ipa --method adhoc --crate-path "$CRATE_PATH" + cargo-mobench package-xcuitest --crate-path "$CRATE_PATH" test -f target/mobench/ios/BenchRunner.ipa test -f target/mobench/ios/BenchRunnerUITests.zip @@ -302,12 +303,16 @@ jobs: - name: Setup Android SDK/NDK uses: android-actions/setup-android@v3 - with: - packages: | - platform-tools - platforms;android-34 - build-tools;34.0.0 - ndk;26.1.10909125 + + - name: Install required SDK packages + shell: bash + run: | + sdkmanager --install "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;26.1.10909125" || true + # Verify NDK is available + test -d "$ANDROID_HOME/ndk/26.1.10909125" || { + echo "::error::NDK 26.1.10909125 not found after install" + exit 1 + } - name: Install cargo-ndk run: cargo install cargo-ndk --locked @@ -363,9 +368,10 @@ jobs: env: ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/26.1.10909125 RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} + CRATE_PATH: ${{ inputs.crate_path }} run: | set -euo pipefail - cargo-mobench build --target android $RELEASE_FLAG + cargo-mobench build --target android $RELEASE_FLAG --crate-path "$CRATE_PATH" - name: Run Android benchmarks working-directory: caller From 2a8020c464090a5dabf6a4423170435399303ce6 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 20:22:49 -0700 Subject: [PATCH 088/196] feat: add self-test workflow, pass --crate-path to all build commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add mobile-bench-selftest.yml that dogfoods reusable-bench.yml with the repo's own sample-fns crate (fibonacci benchmark) - Pass --crate-path from inputs to build, package-ipa, package-xcuitest steps (ci run doesn't need it — build is separate) - Remove unused CRATE_PATH env from ci run steps --- .github/workflows/mobile-bench-selftest.yml | 40 +++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/mobile-bench-selftest.yml diff --git a/.github/workflows/mobile-bench-selftest.yml b/.github/workflows/mobile-bench-selftest.yml new file mode 100644 index 0000000..4ac8b3a --- /dev/null +++ b/.github/workflows/mobile-bench-selftest.yml @@ -0,0 +1,40 @@ +name: Mobile Bench Self-Test + +on: + workflow_dispatch: + inputs: + platform: + description: "Target platform" + type: choice + default: both + options: + - android + - ios + - both + iterations: + description: "Number of benchmark iterations" + type: string + default: "5" + warmup: + description: "Number of warmup iterations" + type: string + default: "1" + +permissions: + contents: read + pull-requests: write + +jobs: + selftest: + uses: ./.github/workflows/reusable-bench.yml + with: + crate_path: crates/sample-fns + functions: '["sample_fns::fibonacci"]' + platform: ${{ inputs.platform }} + iterations: ${{ inputs.iterations }} + warmup: ${{ inputs.warmup }} + device_profile: low-spec + build_release: true + secrets: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} From d59f521d085364ddaf852df903f570ab86acb62d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 21:27:08 -0700 Subject: [PATCH 089/196] fix: ensure Rust targets installed, robust Android NDK setup - Add explicit `rustup target add` fallback after dtolnay/rust-toolchain for both iOS and Android targets (handles cached toolchains missing targets) - Use full path to sdkmanager ($ANDROID_HOME/cmdline-tools/latest/bin/) for macos-14 runner compatibility - Fall back to latest available NDK if 26.1.10909125 not installable - Propagate NDK fallback version to build/run steps via GITHUB_ENV --- .github/workflows/reusable-bench.yml | 58 ++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 356b15c..fad953b 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -142,6 +142,16 @@ jobs: with: targets: ${{ inputs.rust_targets_ios }} + - name: Ensure iOS Rust targets + shell: bash + env: + TARGETS: ${{ inputs.rust_targets_ios }} + run: | + for target in ${TARGETS//,/ }; do + rustup target add "$target" 2>/dev/null || true + done + rustup target list --installed | grep -E "ios|apple" + - name: Cache cargo registry/git uses: actions/cache@v4 with: @@ -283,6 +293,16 @@ jobs: with: targets: ${{ inputs.rust_targets_android }} + - name: Ensure Android Rust targets + shell: bash + env: + TARGETS: ${{ inputs.rust_targets_android }} + run: | + for target in ${TARGETS//,/ }; do + rustup target add "$target" 2>/dev/null || true + done + rustup target list --installed | grep -E "android" + - name: Cache cargo registry/git uses: actions/cache@v4 with: @@ -307,12 +327,34 @@ jobs: - name: Install required SDK packages shell: bash run: | - sdkmanager --install "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;26.1.10909125" || true - # Verify NDK is available - test -d "$ANDROID_HOME/ndk/26.1.10909125" || { - echo "::error::NDK 26.1.10909125 not found after install" - exit 1 - } + echo "ANDROID_HOME=${ANDROID_HOME}" + # Use the full path to sdkmanager in case it's not on PATH + SDKMGR="${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$SDKMGR" ]; then + SDKMGR=$(command -v sdkmanager 2>/dev/null || echo "sdkmanager") + fi + echo "Using sdkmanager: $SDKMGR" + + $SDKMGR --install "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;26.1.10909125" 2>&1 || true + + # Check what NDKs are available + echo "Available NDKs:" + ls "${ANDROID_HOME}/ndk/" 2>/dev/null || echo " (none)" + + # Use requested NDK or fall back to whatever is installed + if [ -d "${ANDROID_HOME}/ndk/26.1.10909125" ]; then + echo "NDK 26.1.10909125 found" + else + # Fall back to the latest available NDK + AVAILABLE_NDK=$(ls "${ANDROID_HOME}/ndk/" 2>/dev/null | sort -V | tail -1) + if [ -n "$AVAILABLE_NDK" ]; then + echo "::warning::NDK 26.1.10909125 not found, using ${AVAILABLE_NDK} instead" + echo "ANDROID_NDK_FALLBACK=${AVAILABLE_NDK}" >> "$GITHUB_ENV" + else + echo "::error::No Android NDK found" + exit 1 + fi + fi - name: Install cargo-ndk run: cargo install cargo-ndk --locked @@ -366,7 +408,7 @@ jobs: working-directory: caller shell: bash env: - ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/26.1.10909125 + ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.ANDROID_NDK_FALLBACK || '26.1.10909125' }} RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} CRATE_PATH: ${{ inputs.crate_path }} run: | @@ -382,7 +424,7 @@ jobs: WARMUP: ${{ inputs.warmup }} ANDROID_DEVICE: ${{ steps.resolve_device.outputs.device_name }} ANDROID_OS_VERSION: ${{ steps.resolve_device.outputs.os_version }} - ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/26.1.10909125 + ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.ANDROID_NDK_FALLBACK || '26.1.10909125' }} RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} run: | set -euo pipefail From d5948fc1f734937ca54b281953218f8e70c9158b Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 21:35:55 -0700 Subject: [PATCH 090/196] fix: resolve ANDROID_NDK_HOME once via GITHUB_ENV, simplify target setup - Detect NDK in the install step and export ANDROID_NDK_HOME to GITHUB_ENV so all subsequent steps inherit it automatically - Remove hardcoded NDK version from build/run step env blocks - Simplify Rust target add steps with explicit target names --- .github/workflows/reusable-bench.yml | 38 ++++++++++++---------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index fad953b..6bc5820 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -144,12 +144,8 @@ jobs: - name: Ensure iOS Rust targets shell: bash - env: - TARGETS: ${{ inputs.rust_targets_ios }} run: | - for target in ${TARGETS//,/ }; do - rustup target add "$target" 2>/dev/null || true - done + rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios 2>/dev/null || true rustup target list --installed | grep -E "ios|apple" - name: Cache cargo registry/git @@ -295,12 +291,8 @@ jobs: - name: Ensure Android Rust targets shell: bash - env: - TARGETS: ${{ inputs.rust_targets_android }} run: | - for target in ${TARGETS//,/ }; do - rustup target add "$target" 2>/dev/null || true - done + rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android 2>/dev/null || true rustup target list --installed | grep -E "android" - name: Cache cargo registry/git @@ -324,38 +316,42 @@ jobs: - name: Setup Android SDK/NDK uses: android-actions/setup-android@v3 - - name: Install required SDK packages + - name: Install SDK packages and resolve NDK shell: bash run: | echo "ANDROID_HOME=${ANDROID_HOME}" - # Use the full path to sdkmanager in case it's not on PATH + + # Find sdkmanager SDKMGR="${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" if [ ! -x "$SDKMGR" ]; then SDKMGR=$(command -v sdkmanager 2>/dev/null || echo "sdkmanager") fi echo "Using sdkmanager: $SDKMGR" + # Install packages (best-effort — runner may already have them) $SDKMGR --install "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;26.1.10909125" 2>&1 || true - # Check what NDKs are available + # Resolve NDK: prefer 26.1.10909125, fall back to latest available echo "Available NDKs:" ls "${ANDROID_HOME}/ndk/" 2>/dev/null || echo " (none)" - # Use requested NDK or fall back to whatever is installed if [ -d "${ANDROID_HOME}/ndk/26.1.10909125" ]; then - echo "NDK 26.1.10909125 found" + NDK_DIR="${ANDROID_HOME}/ndk/26.1.10909125" else - # Fall back to the latest available NDK - AVAILABLE_NDK=$(ls "${ANDROID_HOME}/ndk/" 2>/dev/null | sort -V | tail -1) - if [ -n "$AVAILABLE_NDK" ]; then - echo "::warning::NDK 26.1.10909125 not found, using ${AVAILABLE_NDK} instead" - echo "ANDROID_NDK_FALLBACK=${AVAILABLE_NDK}" >> "$GITHUB_ENV" + NDK_VER=$(ls "${ANDROID_HOME}/ndk/" 2>/dev/null | sort -V | tail -1) + if [ -n "$NDK_VER" ]; then + echo "::warning::NDK 26.1.10909125 not found, using ${NDK_VER}" + NDK_DIR="${ANDROID_HOME}/ndk/${NDK_VER}" else echo "::error::No Android NDK found" exit 1 fi fi + # Export for all subsequent steps + echo "ANDROID_NDK_HOME=${NDK_DIR}" >> "$GITHUB_ENV" + echo "Resolved ANDROID_NDK_HOME=${NDK_DIR}" + - name: Install cargo-ndk run: cargo install cargo-ndk --locked @@ -408,7 +404,6 @@ jobs: working-directory: caller shell: bash env: - ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.ANDROID_NDK_FALLBACK || '26.1.10909125' }} RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} CRATE_PATH: ${{ inputs.crate_path }} run: | @@ -424,7 +419,6 @@ jobs: WARMUP: ${{ inputs.warmup }} ANDROID_DEVICE: ${{ steps.resolve_device.outputs.device_name }} ANDROID_OS_VERSION: ${{ steps.resolve_device.outputs.os_version }} - ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.ANDROID_NDK_FALLBACK || '26.1.10909125' }} RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} run: | set -euo pipefail From d7765a90407bd97bea2cafac01f32979eca41469 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 21:54:29 -0700 Subject: [PATCH 091/196] fix: force rust-std download for cross-compilation targets Remove 2>/dev/null error suppression and explicitly install rust-std component for each iOS and Android target. This ensures the standard library is downloaded even when the toolchain is cached without it. --- .github/workflows/reusable-bench.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 6bc5820..6ed2556 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -145,7 +145,9 @@ jobs: - name: Ensure iOS Rust targets shell: bash run: | - rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios 2>/dev/null || true + rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios + rustup component add rust-std --target aarch64-apple-ios + rustup component add rust-std --target aarch64-apple-ios-sim rustup target list --installed | grep -E "ios|apple" - name: Cache cargo registry/git @@ -292,7 +294,10 @@ jobs: - name: Ensure Android Rust targets shell: bash run: | - rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android 2>/dev/null || true + rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android + rustup component add rust-std --target aarch64-linux-android + rustup component add rust-std --target armv7-linux-androideabi + rustup component add rust-std --target x86_64-linux-android rustup target list --installed | grep -E "android" - name: Cache cargo registry/git From 35dc50e088d55c1c953ecfa62c76208d98f59df5 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 22:46:49 -0700 Subject: [PATCH 092/196] fix: use rustc sysroot for iOS target detection, bump 0.1.20 The iOS builder's check_rust_targets used `rustup target list --installed` which can query a different toolchain than the one actually active in CI (e.g. when dtolnay/rust-toolchain sets RUSTUP_TOOLCHAIN but cargo-mobench was installed under a different toolchain). Fix: use `rustc --print sysroot` to find the active sysroot, then check if lib/rustlib//lib/ exists. This respects RUSTUP_TOOLCHAIN and toolchain overrides. Falls back to rustup if sysroot detection fails. Also adds a debug step to the workflow for diagnosing target issues. --- .github/workflows/reusable-bench.yml | 17 +++++++++- BENCH_SDK_INTEGRATION.md | 4 +-- BUILD.md | 2 +- CLAUDE.md | 6 ++-- Cargo.lock | 8 ++--- Cargo.toml | 2 +- README.md | 4 +-- TESTING.md | 2 +- crates/mobench-macros/README.md | 6 ++-- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench-sdk/README.md | 8 ++--- crates/mobench-sdk/src/builders/ios.rs | 47 ++++++++++++++++++-------- crates/mobench/Cargo.toml | 2 +- crates/mobench/README.md | 4 +-- 14 files changed, 74 insertions(+), 40 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 6ed2556..a3f73bb 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -66,7 +66,7 @@ on: description: "Mobench version to install" required: false type: string - default: "0.1.19" + default: "0.1.20" mobench_ref: description: "Git ref for mobile-bench-rs (overrides mobench_version when set)" required: false @@ -216,6 +216,21 @@ jobs: echo "Resolved iOS device: ${device_name} (${os_version})" fi + - name: Debug target sysroot + shell: bash + run: | + echo "=== Active toolchain ===" + rustup show active-toolchain + echo "=== Sysroot ===" + SYSROOT=$(rustc --print sysroot) + echo "$SYSROOT" + echo "=== iOS target libs ===" + ls "$SYSROOT/lib/rustlib/aarch64-apple-ios/lib/" 2>&1 | head -5 || echo "MISSING" + echo "=== which cargo-mobench ===" + which cargo-mobench + echo "=== mobench version ===" + cargo-mobench --version + - name: Build iOS artifacts working-directory: caller shell: bash diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index 0b67ccc..643236d 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.19" +mobench-sdk = "0.1.20" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.19" +mobench-sdk = "0.1.20" ``` ## 3) Annotate benchmark functions diff --git a/BUILD.md b/BUILD.md index 6e46584..14c00eb 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,7 +2,7 @@ Complete build instructions for Android and iOS targets. -In `mobench 0.1.19`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. +In `mobench 0.1.20`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. > **For SDK Integrators**: Use the CLI commands: > - `cargo mobench check --target android` (validate prerequisites first) diff --git a/CLAUDE.md b/CLAUDE.md index 78f5725..743cc59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.19):** +**Published on crates.io as the mobench ecosystem (v0.1.20):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation @@ -180,7 +180,7 @@ cargo mobench verify --target android --check-artifacts Use `cargo mobench build --target ` for local or CI builds. The CLI handles library builds, binding generation, and app packaging without extra scripts. -In `mobench 0.1.19`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. +In `mobench 0.1.20`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. **Important iOS Build Details:** @@ -594,7 +594,7 @@ The workflow supports manual dispatch with platform selection: ```toml [dependencies] -mobench-sdk = "0.1.19" +mobench-sdk = "0.1.20" inventory = "0.3" ``` diff --git a/Cargo.lock b/Cargo.lock index 6ccaf31..037431a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,7 +1017,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.19" +version = "0.1.20" dependencies = [ "anyhow", "clap", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.19" +version = "0.1.20" dependencies = [ "proc-macro2", "quote", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.19" +version = "0.1.20" dependencies = [ "anyhow", "include_dir", @@ -1522,7 +1522,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.19" +version = "0.1.20" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 09e5934..0ea70a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.19" +version = "0.1.20" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 22e89d3..7c90b08 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.19` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.20` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. CLI flags override config file values when provided. - In `cargo mobench run --config ` mode, `--device-matrix ` overrides `device_matrix` from the config file. @@ -206,7 +206,7 @@ fn db_query(db: &Database) { ## Release Notes -### v0.1.19 +### v0.1.20 - Added a shared config-first project resolver across `build`, `run`, packaging, `list`, and `verify`. - Added `--project-root` and `--crate-path` parity across the main CLI commands for custom repository layouts. diff --git a/TESTING.md b/TESTING.md index 5f34594..09e71dd 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ This document provides comprehensive testing instructions for mobile-bench-rs. > - See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide > **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see **[BUILD.md](BUILD.md)**. This document focuses on testing scenarios and troubleshooting. -In `mobench 0.1.19`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. +In `mobench 0.1.20`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. ## Table of Contents - [Prerequisites](#prerequisites) diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index 0629b18..d3b284f 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -4,7 +4,7 @@ Procedural macros for the [mobench](https://crates.io/crates/mobench) mobile ben This crate provides the `#[benchmark]` attribute macro that automatically registers functions for mobile benchmarking. It uses compile-time registration via the `inventory` crate to build a registry of benchmark functions. -In `mobench 0.1.19`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. +In `mobench 0.1.20`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. ## Features @@ -19,8 +19,8 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -mobench-macros = "0.1.19" -mobench-sdk = "0.1.19" # For the runtime +mobench-macros = "0.1.20" +mobench-sdk = "0.1.20" # For the runtime ``` ### Basic Example diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index c6a56ee..cbe2a3b 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.19", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.20", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index a6df2ff..5f9516e 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -15,7 +15,7 @@ Transform your Rust project into a mobile benchmarking suite. This SDK provides - **BrowserStack integration**: Test on real devices in the cloud - **UniFFI bindings**: Automatic FFI generation for mobile platforms - **Configuration file support**: `mobench.toml` for project settings -- **Config-first CLI integration**: `mobench 0.1.19` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root +- **Config-first CLI integration**: `mobench 0.1.20` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root ## Quick Start @@ -23,7 +23,7 @@ Add mobench-sdk to your project: ```toml [dependencies] -mobench-sdk = "0.1.19" +mobench-sdk = "0.1.20" ``` Mark functions to benchmark: @@ -86,7 +86,7 @@ This creates: - `android/` or `ios/` - Mobile app projects - `bench-config.toml` - Configuration file -The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.19` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. +The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.20` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. ### 2. Add Benchmarks @@ -391,7 +391,7 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.19` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.20` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### `bench-config.toml` (Run Configuration) diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 0648d9e..7b56d95 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -616,24 +616,43 @@ impl IosBuilder { Ok(()) } - /// Checks if required Rust targets are installed + /// Checks if required Rust targets are installed. + /// + /// Uses `rustc --print sysroot` to locate the actual sysroot (respects + /// RUSTUP_TOOLCHAIN and toolchain overrides) instead of `rustup target list` + /// which may query a different toolchain in CI. fn check_rust_targets(&self, targets: &[&str]) -> Result<(), BenchError> { - let output = Command::new("rustup") - .arg("target") - .arg("list") - .arg("--installed") + let sysroot = Command::new("rustc") + .args(["--print", "sysroot"]) .output() - .map_err(|e| { - BenchError::Build(format!( - "Failed to check rustup targets: {}. Ensure rustup is installed and on PATH.", - e - )) - })?; - - let installed = String::from_utf8_lossy(&output.stdout); + .ok() + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout).ok() + } else { + None + } + }) + .map(|s| s.trim().to_string()); for target in targets { - if !installed.contains(target) { + let installed = if let Some(ref root) = sysroot { + // Check if the target's stdlib exists in the active sysroot + let lib_dir = + std::path::Path::new(root).join(format!("lib/rustlib/{}/lib", target)); + lib_dir.exists() + } else { + // Fallback: ask rustup (may query wrong toolchain in CI) + let output = Command::new("rustup") + .args(["target", "list", "--installed"]) + .output() + .ok(); + output + .map(|o| String::from_utf8_lossy(&o.stdout).contains(target)) + .unwrap_or(false) + }; + + if !installed { return Err(BenchError::Build(format!( "Rust target '{}' is not installed.\n\n\ This target is required to compile for iOS.\n\n\ diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index dbf2b61..a271957 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.19", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.20", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 41adb60..f6105aa 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -45,7 +45,7 @@ This creates: - `mobench.toml` - Project configuration file (when using `init`) - `benches/example.rs` - Example benchmarks (with `--examples`) -Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.19` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. +Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.20` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. ### 2. Write Benchmarks @@ -514,7 +514,7 @@ default_warmup = 10 ``` CLI flags always override config file values when provided. -Resolution precedence in `0.1.19` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.20` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### Run Config File Format (`bench-config.toml`) From 4c1466d3a8376f27da6b81508ab3ff95452dbf4d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 23:48:59 -0700 Subject: [PATCH 093/196] fix: add --crate-path to ci run command and reusable workflow The ci run subcommand now accepts --crate-path which gets threaded through RunRequest to the underlying mobench run invocation. Without this, ci run fails with "No benchmark crate found" in repos where the benchmark crate is not at the workspace root. Also passes --crate-path to both iOS and Android ci run steps in reusable-bench.yml. --- .github/workflows/reusable-bench.yml | 4 ++++ crates/mobench/src/lib.rs | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index a3f73bb..276a0ca 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -255,6 +255,7 @@ jobs: IOS_DEVICE: ${{ steps.resolve_device.outputs.device_name }} IOS_OS_VERSION: ${{ steps.resolve_device.outputs.os_version }} RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} + CRATE_PATH: ${{ inputs.crate_path }} run: | set -euo pipefail device_spec="${IOS_DEVICE}-${IOS_OS_VERSION}" @@ -266,6 +267,7 @@ jobs: --iterations "${ITERATIONS}" \ --warmup "${WARMUP}" \ --devices "${device_spec}" \ + --crate-path "$CRATE_PATH" \ $RELEASE_FLAG \ --fetch \ --output-dir target/mobench/ci/ios \ @@ -440,6 +442,7 @@ jobs: ANDROID_DEVICE: ${{ steps.resolve_device.outputs.device_name }} ANDROID_OS_VERSION: ${{ steps.resolve_device.outputs.os_version }} RELEASE_FLAG: ${{ inputs.build_release && '--release' || '' }} + CRATE_PATH: ${{ inputs.crate_path }} run: | set -euo pipefail device_spec="${ANDROID_DEVICE}-${ANDROID_OS_VERSION}" @@ -451,6 +454,7 @@ jobs: --iterations "${ITERATIONS}" \ --warmup "${WARMUP}" \ --devices "${device_spec}" \ + --crate-path "$CRATE_PATH" \ $RELEASE_FLAG \ --fetch \ --output-dir target/mobench/ci/android \ diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index 7f4f912..f3f059b 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -655,6 +655,11 @@ enum ReportCommand { struct CiRunArgs { #[arg(long, value_enum)] target: CiTarget, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + crate_path: Option, #[arg(long, help = "Fully-qualified Rust function to benchmark (single function)")] function: Option, #[arg( @@ -2168,6 +2173,8 @@ pub struct RunRequest { pub target: MobileTarget, /// Fully-qualified benchmark function name. pub function: String, + /// Optional path to the benchmark crate directory. + pub crate_path: Option, /// Number of benchmark iterations. pub iterations: u32, /// Number of warmup iterations. @@ -2300,6 +2307,9 @@ pub fn run_request(request: &RunRequest) -> Result { if let Some(path) = &request.ios_test_suite { cmd.arg("--ios-test-suite").arg(path); } + if let Some(path) = &request.crate_path { + cmd.arg("--crate-path").arg(path); + } if request.fetch { cmd.arg("--fetch"); } @@ -2748,6 +2758,7 @@ fn cmd_ci_run_single( let result = run_request(&RunRequest { target, function: args.function.clone().unwrap_or_default(), + crate_path: args.crate_path.clone(), iterations: args.iterations, warmup: args.warmup, device_selection: DeviceSelection { From 5b87c3c79f74b22088d6b6347e297df865b5f806 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Mon, 23 Mar 2026 23:50:11 -0700 Subject: [PATCH 094/196] chore: bump to 0.1.21 for ci run --crate-path support --- .github/workflows/reusable-bench.yml | 2 +- BENCH_SDK_INTEGRATION.md | 4 ++-- BUILD.md | 2 +- CLAUDE.md | 6 +++--- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- README.md | 4 ++-- TESTING.md | 2 +- crates/mobench-macros/README.md | 6 +++--- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench-sdk/README.md | 8 ++++---- crates/mobench/Cargo.toml | 2 +- crates/mobench/README.md | 4 ++-- 13 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 276a0ca..69ad007 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -66,7 +66,7 @@ on: description: "Mobench version to install" required: false type: string - default: "0.1.20" + default: "0.1.21" mobench_ref: description: "Git ref for mobile-bench-rs (overrides mobench_version when set)" required: false diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index 643236d..8b5ffd4 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.20" +mobench-sdk = "0.1.21" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.20" +mobench-sdk = "0.1.21" ``` ## 3) Annotate benchmark functions diff --git a/BUILD.md b/BUILD.md index 14c00eb..e6afc26 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,7 +2,7 @@ Complete build instructions for Android and iOS targets. -In `mobench 0.1.20`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. +In `mobench 0.1.21`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. > **For SDK Integrators**: Use the CLI commands: > - `cargo mobench check --target android` (validate prerequisites first) diff --git a/CLAUDE.md b/CLAUDE.md index 743cc59..1a703e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.20):** +**Published on crates.io as the mobench ecosystem (v0.1.21):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation @@ -180,7 +180,7 @@ cargo mobench verify --target android --check-artifacts Use `cargo mobench build --target ` for local or CI builds. The CLI handles library builds, binding generation, and app packaging without extra scripts. -In `mobench 0.1.20`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. +In `mobench 0.1.21`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. **Important iOS Build Details:** @@ -594,7 +594,7 @@ The workflow supports manual dispatch with platform selection: ```toml [dependencies] -mobench-sdk = "0.1.20" +mobench-sdk = "0.1.21" inventory = "0.3" ``` diff --git a/Cargo.lock b/Cargo.lock index 037431a..1369c9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,7 +1017,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.20" +version = "0.1.21" dependencies = [ "anyhow", "clap", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.20" +version = "0.1.21" dependencies = [ "proc-macro2", "quote", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.20" +version = "0.1.21" dependencies = [ "anyhow", "include_dir", @@ -1522,7 +1522,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.20" +version = "0.1.21" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 0ea70a7..a83fc6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.20" +version = "0.1.21" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 7c90b08..2e8bb23 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.20` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.21` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. CLI flags override config file values when provided. - In `cargo mobench run --config ` mode, `--device-matrix ` overrides `device_matrix` from the config file. @@ -206,7 +206,7 @@ fn db_query(db: &Database) { ## Release Notes -### v0.1.20 +### v0.1.21 - Added a shared config-first project resolver across `build`, `run`, packaging, `list`, and `verify`. - Added `--project-root` and `--crate-path` parity across the main CLI commands for custom repository layouts. diff --git a/TESTING.md b/TESTING.md index 09e71dd..810ae70 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ This document provides comprehensive testing instructions for mobile-bench-rs. > - See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide > **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see **[BUILD.md](BUILD.md)**. This document focuses on testing scenarios and troubleshooting. -In `mobench 0.1.20`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. +In `mobench 0.1.21`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. ## Table of Contents - [Prerequisites](#prerequisites) diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index d3b284f..eacb21c 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -4,7 +4,7 @@ Procedural macros for the [mobench](https://crates.io/crates/mobench) mobile ben This crate provides the `#[benchmark]` attribute macro that automatically registers functions for mobile benchmarking. It uses compile-time registration via the `inventory` crate to build a registry of benchmark functions. -In `mobench 0.1.20`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. +In `mobench 0.1.21`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. ## Features @@ -19,8 +19,8 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -mobench-macros = "0.1.20" -mobench-sdk = "0.1.20" # For the runtime +mobench-macros = "0.1.21" +mobench-sdk = "0.1.21" # For the runtime ``` ### Basic Example diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index cbe2a3b..0752b8e 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.20", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.21", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index 5f9516e..ea78cb3 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -15,7 +15,7 @@ Transform your Rust project into a mobile benchmarking suite. This SDK provides - **BrowserStack integration**: Test on real devices in the cloud - **UniFFI bindings**: Automatic FFI generation for mobile platforms - **Configuration file support**: `mobench.toml` for project settings -- **Config-first CLI integration**: `mobench 0.1.20` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root +- **Config-first CLI integration**: `mobench 0.1.21` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root ## Quick Start @@ -23,7 +23,7 @@ Add mobench-sdk to your project: ```toml [dependencies] -mobench-sdk = "0.1.20" +mobench-sdk = "0.1.21" ``` Mark functions to benchmark: @@ -86,7 +86,7 @@ This creates: - `android/` or `ios/` - Mobile app projects - `bench-config.toml` - Configuration file -The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.20` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. +The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.21` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. ### 2. Add Benchmarks @@ -391,7 +391,7 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.20` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.21` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### `bench-config.toml` (Run Configuration) diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index a271957..41669b8 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.20", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.21", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/README.md b/crates/mobench/README.md index f6105aa..74bf263 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -45,7 +45,7 @@ This creates: - `mobench.toml` - Project configuration file (when using `init`) - `benches/example.rs` - Example benchmarks (with `--examples`) -Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.20` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. +Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.21` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. ### 2. Write Benchmarks @@ -514,7 +514,7 @@ default_warmup = 10 ``` CLI flags always override config file values when provided. -Resolution precedence in `0.1.20` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.21` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### Run Config File Format (`bench-config.toml`) From 51331c247d615f87dca12276d2f66f45626bab54 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Tue, 24 Mar 2026 23:02:01 -0700 Subject: [PATCH 095/196] fix browserstack ci fetch and release 0.1.22 --- BENCH_SDK_INTEGRATION.md | 4 +- BUILD.md | 2 +- CLAUDE.md | 6 +- Cargo.lock | 8 +- Cargo.toml | 2 +- README.md | 9 +- TESTING.md | 2 +- crates/mobench-macros/README.md | 6 +- crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench-sdk/README.md | 8 +- crates/mobench/Cargo.toml | 2 +- crates/mobench/README.md | 4 +- crates/mobench/src/browserstack.rs | 542 +++++++++++++++++++++++-- crates/mobench/src/lib.rs | 608 +++++++++++++++++------------ crates/mobench/src/summarize.rs | 345 ++++++++++++++-- 15 files changed, 1223 insertions(+), 327 deletions(-) diff --git a/BENCH_SDK_INTEGRATION.md b/BENCH_SDK_INTEGRATION.md index 8b5ffd4..0bfdb7a 100644 --- a/BENCH_SDK_INTEGRATION.md +++ b/BENCH_SDK_INTEGRATION.md @@ -14,7 +14,7 @@ Before diving into the full guide, ensure your project meets these requirements: ```toml [dependencies] -mobench-sdk = "0.1.21" +mobench-sdk = "0.1.22" inventory = "0.3" # Required for benchmark registration [lib] @@ -112,7 +112,7 @@ In your project's `Cargo.toml`: ```toml [dependencies] -mobench-sdk = "0.1.21" +mobench-sdk = "0.1.22" ``` ## 3) Annotate benchmark functions diff --git a/BUILD.md b/BUILD.md index e6afc26..6d4aae7 100644 --- a/BUILD.md +++ b/BUILD.md @@ -2,7 +2,7 @@ Complete build instructions for Android and iOS targets. -In `mobench 0.1.21`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. +In `mobench 0.1.22`, build commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to the legacy `bench-mobile/` layout. `build --progress` uses the same config-first resolution. > **For SDK Integrators**: Use the CLI commands: > - `cargo mobench check --target android` (validate prerequisites first) diff --git a/CLAUDE.md b/CLAUDE.md index 1a703e3..ad2c1a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.21):** +**Published on crates.io as the mobench ecosystem (v0.1.22):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation @@ -180,7 +180,7 @@ cargo mobench verify --target android --check-artifacts Use `cargo mobench build --target ` for local or CI builds. The CLI handles library builds, binding generation, and app packaging without extra scripts. -In `mobench 0.1.21`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. +In `mobench 0.1.22`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. **Important iOS Build Details:** @@ -594,7 +594,7 @@ The workflow supports manual dispatch with platform selection: ```toml [dependencies] -mobench-sdk = "0.1.21" +mobench-sdk = "0.1.22" inventory = "0.3" ``` diff --git a/Cargo.lock b/Cargo.lock index 1369c9b..1f85ac7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,7 +1017,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.21" +version = "0.1.22" dependencies = [ "anyhow", "clap", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.21" +version = "0.1.22" dependencies = [ "proc-macro2", "quote", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.21" +version = "0.1.22" dependencies = [ "anyhow", "include_dir", @@ -1522,7 +1522,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.21" +version = "0.1.22" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index a83fc6f..5d89629 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.21" +version = "0.1.22" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 2e8bb23..81ea4ed 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.21` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.22` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. CLI flags override config file values when provided. - In `cargo mobench run --config ` mode, `--device-matrix ` overrides `device_matrix` from the config file. @@ -206,6 +206,13 @@ fn db_query(db: &Database) { ## Release Notes +### v0.1.22 + +- Fixed BrowserStack result fetching so `cargo mobench ci run --fetch` falls back to downloaded session artifacts when live device logs do not expose benchmark JSON. +- Unified benchmark extraction across live logs, `bench-report.json`, iOS marker logs, and Android `BENCH_JSON` logs so per-function CI summaries are written with populated benchmark data. +- Fixed merged CI output generation to preserve every function under each target and emit a top-level `summary` for single-target runs. +- Fixed `cargo-mobench ci summarize` to read merged `{targets, ci}` outputs, recurse through nested target/function result directories, and fall back to raw `bench-report.json` when needed. + ### v0.1.21 - Added a shared config-first project resolver across `build`, `run`, packaging, `list`, and `verify`. diff --git a/TESTING.md b/TESTING.md index 810ae70..b64406c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ This document provides comprehensive testing instructions for mobile-bench-rs. > - See [BENCH_SDK_INTEGRATION.md](BENCH_SDK_INTEGRATION.md) for the integration guide > **Note**: For detailed build instructions, prerequisites, and step-by-step build processes, see **[BUILD.md](BUILD.md)**. This document focuses on testing scenarios and troubleshooting. -In `mobench 0.1.21`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. +In `mobench 0.1.22`, build/run/list/verify/package commands resolve the benchmark crate from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. `build --progress` uses that same config-first resolver. ## Table of Contents - [Prerequisites](#prerequisites) diff --git a/crates/mobench-macros/README.md b/crates/mobench-macros/README.md index eacb21c..777aa8d 100644 --- a/crates/mobench-macros/README.md +++ b/crates/mobench-macros/README.md @@ -4,7 +4,7 @@ Procedural macros for the [mobench](https://crates.io/crates/mobench) mobile ben This crate provides the `#[benchmark]` attribute macro that automatically registers functions for mobile benchmarking. It uses compile-time registration via the `inventory` crate to build a registry of benchmark functions. -In `mobench 0.1.21`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. +In `mobench 0.1.22`, benchmarks annotated with these macros are discovered through the CLI's config-first resolver, so non-legacy crate layouts work with `mobench.toml`, `--project-root`, and `--crate-path`. ## Features @@ -19,8 +19,8 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -mobench-macros = "0.1.21" -mobench-sdk = "0.1.21" # For the runtime +mobench-macros = "0.1.22" +mobench-sdk = "0.1.22" # For the runtime ``` ### Basic Example diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 0752b8e..e578635 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.21", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.22", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/README.md b/crates/mobench-sdk/README.md index ea78cb3..635493a 100644 --- a/crates/mobench-sdk/README.md +++ b/crates/mobench-sdk/README.md @@ -15,7 +15,7 @@ Transform your Rust project into a mobile benchmarking suite. This SDK provides - **BrowserStack integration**: Test on real devices in the cloud - **UniFFI bindings**: Automatic FFI generation for mobile platforms - **Configuration file support**: `mobench.toml` for project settings -- **Config-first CLI integration**: `mobench 0.1.21` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root +- **Config-first CLI integration**: `mobench 0.1.22` resolves project root, crate name, and library name from flags, `mobench.toml`, workspace metadata, or git root ## Quick Start @@ -23,7 +23,7 @@ Add mobench-sdk to your project: ```toml [dependencies] -mobench-sdk = "0.1.21" +mobench-sdk = "0.1.22" ``` Mark functions to benchmark: @@ -86,7 +86,7 @@ This creates: - `android/` or `ios/` - Mobile app projects - `bench-config.toml` - Configuration file -The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.21` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. +The generated `bench-mobile/` crate is still the default scaffold, but the `mobench` CLI in `0.1.22` can also target existing custom crate layouts through `mobench.toml`, `--project-root`, and `--crate-path`. ### 2. Add Benchmarks @@ -391,7 +391,7 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.21` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.22` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### `bench-config.toml` (Run Configuration) diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 41669b8..128f048 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.21", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.22", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/README.md b/crates/mobench/README.md index 74bf263..c4e6690 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -45,7 +45,7 @@ This creates: - `mobench.toml` - Project configuration file (when using `init`) - `benches/example.rs` - Example benchmarks (with `--examples`) -Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.21` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. +Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.22` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. ### 2. Write Benchmarks @@ -514,7 +514,7 @@ default_warmup = 10 ``` CLI flags always override config file values when provided. -Resolution precedence in `0.1.21` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence in `0.1.22` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### Run Config File Format (`bench-config.toml`) diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index d684cf3..65255e6 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -508,6 +508,44 @@ impl BrowserStackClient { Ok(text) } + fn get_session_json(&self, build_id: &str, session_id: &str, platform: &str) -> Result { + let path = match platform { + "espresso" => format!( + "app-automate/espresso/v2/builds/{}/sessions/{}", + build_id, session_id + ), + "xcuitest" => format!( + "app-automate/xcuitest/v2/builds/{}/sessions/{}", + build_id, session_id + ), + _ => return Err(anyhow!("unsupported platform: {}", platform)), + }; + + self.get_json(&path) + } + + fn download_text_url(&self, url: &str) -> Result { + let resp = self + .http + .get(url) + .basic_auth(&self.auth.username, Some(&self.auth.access_key)) + .send() + .with_context(|| format!("downloading BrowserStack asset {}", url))?; + let status = resp.status(); + let bytes = resp + .bytes() + .with_context(|| format!("reading BrowserStack asset body {}", url))?; + if !status.is_success() { + return Err(anyhow!( + "BrowserStack asset download failed (status {}): {}", + status, + String::from_utf8_lossy(&bytes) + )); + } + + Ok(String::from_utf8_lossy(&bytes).into_owned()) + } + /// Extract benchmark results from device logs /// Looks for JSON output matching BenchReport format /// Supports both Android (BENCH_JSON) and iOS (BENCH_REPORT_JSON_START/END) formats @@ -516,7 +554,7 @@ impl BrowserStackClient { // First, try iOS-style markers: BENCH_REPORT_JSON_START ... BENCH_REPORT_JSON_END if let Some(json) = Self::extract_ios_bench_json(logs) { - results.push(json); + Self::extend_unique_results(&mut results, Self::normalize_benchmark_values(json)); } // Also look for Android-style BENCH_JSON marker @@ -525,12 +563,10 @@ impl BrowserStackClient { if let Some(idx) = line.find(bench_json_marker) { let json_part = &line[idx + bench_json_marker.len()..]; if let Ok(json) = serde_json::from_str::(json_part) { - if json.get("function").is_some() - || json.get("samples").is_some() - || json.get("spec").is_some() - { - results.push(json); - } + Self::extend_unique_results( + &mut results, + Self::normalize_benchmark_values(json), + ); } } } @@ -543,15 +579,8 @@ impl BrowserStackClient { trimmed.contains("\"function\"") && trimmed.contains("\"samples\""); if (looks_like_json || looks_like_bench) && let Ok(json) = serde_json::from_str::(trimmed) - && (json.get("function").is_some() || json.get("samples").is_some()) { - // Avoid duplicates - if !results - .iter() - .any(|existing| existing.to_string() == json.to_string()) - { - results.push(json); - } + Self::extend_unique_results(&mut results, Self::normalize_benchmark_values(json)); } } @@ -562,6 +591,66 @@ impl BrowserStackClient { } } + pub(crate) fn extract_benchmark_results_from_artifact( + &self, + contents: &str, + ) -> Result> { + let trimmed = contents.trim(); + if !trimmed.is_empty() + && let Ok(json) = serde_json::from_str::(trimmed) + { + let results = Self::normalize_benchmark_values(json); + if !results.is_empty() { + return Ok(results); + } + } + + self.extract_benchmark_results(contents) + } + + pub(crate) fn extract_results_from_session_artifacts( + &self, + session_json: &Value, + mut fetch_text: F, + ) -> Result<(Vec, PerformanceMetrics)> + where + F: FnMut(&str) -> Result, + { + let artifact_urls = Self::collect_text_artifact_urls(session_json); + if artifact_urls.is_empty() { + return Err(anyhow!("No text artifact URLs found in session response")); + } + + let mut benchmark_results = Vec::new(); + let mut snapshots = Vec::new(); + + for (_, url) in artifact_urls { + let contents = match fetch_text(&url) { + Ok(contents) => contents, + Err(_) => continue, + }; + + if benchmark_results.is_empty() + && let Ok(results) = self.extract_benchmark_results_from_artifact(&contents) + { + benchmark_results = results; + } + + if let Ok(mut artifact_snapshots) = self.extract_performance_snapshots(&contents) { + snapshots.append(&mut artifact_snapshots); + } + } + + if benchmark_results.is_empty() { + Err(anyhow!("No benchmark results found in session artifacts")) + } else { + Ok(( + benchmark_results, + PerformanceMetrics::from_snapshots(snapshots), + )) + } + } + /// Extract benchmark JSON from iOS logs using START/END markers. /// iOS uses NSLog which may split the JSON across multiple log lines. fn extract_ios_bench_json(logs: &str) -> Option { @@ -677,6 +766,12 @@ impl BrowserStackClient { /// Extract performance metrics from device logs /// Looks for JSON objects with "type":"performance" or similar performance indicators pub fn extract_performance_metrics(&self, logs: &str) -> Result { + Ok(PerformanceMetrics::from_snapshots( + self.extract_performance_snapshots(logs)?, + )) + } + + fn extract_performance_snapshots(&self, logs: &str) -> Result> { let mut snapshots = Vec::new(); for line in logs.lines() { @@ -693,7 +788,7 @@ impl BrowserStackClient { } } - Ok(PerformanceMetrics::from_snapshots(snapshots)) + Ok(snapshots) } /// Wait for build completion and fetch all results including performance metrics @@ -741,16 +836,19 @@ impl BrowserStackClient { device.device, device.session_id ); + let mut device_benchmark_results: Option> = None; + let mut device_performance_metrics = PerformanceMetrics::default(); + match self.get_device_logs(build_id, &device.session_id, platform) { Ok(logs) => { // Extract benchmark results match self.extract_benchmark_results(&logs) { - Ok(bench_results) => { - println!(" Found {} benchmark result(s)", bench_results.len()); - benchmark_results.insert(device.device.clone(), bench_results); + Ok(results) => { + println!(" Found {} benchmark result(s)", results.len()); + device_benchmark_results = Some(results); } Err(e) => { - println!(" Warning: No benchmark results - {}", e); + println!(" No benchmark results in live logs: {}", e); } } @@ -761,10 +859,10 @@ impl BrowserStackClient { " Found {} performance metric snapshot(s)", perf_metrics.sample_count ); - performance_metrics.insert(device.device.clone(), perf_metrics); + device_performance_metrics = perf_metrics; } Ok(_) => { - println!(" No performance metrics found"); + println!(" No performance metrics found in live logs"); } Err(e) => { println!(" Warning: Failed to extract performance metrics - {}", e); @@ -772,9 +870,48 @@ impl BrowserStackClient { } } Err(e) => { - println!(" Failed to fetch logs: {}", e); + println!(" Failed to fetch live logs: {}", e); + } + } + + if device_benchmark_results.is_none() { + match self + .get_session_json(build_id, &device.session_id, platform) + .and_then(|session_json| { + self.extract_results_from_session_artifacts(&session_json, |url| { + self.download_text_url(url) + }) + }) { + Ok((results, perf_metrics)) => { + println!( + " Found {} benchmark result(s) from session artifacts", + results.len() + ); + if device_performance_metrics.sample_count == 0 + && perf_metrics.sample_count > 0 + { + println!( + " Found {} performance metric snapshot(s) from session artifacts", + perf_metrics.sample_count + ); + device_performance_metrics = perf_metrics; + } + device_benchmark_results = Some(results); + } + Err(e) => { + println!( + " Warning: Failed to fetch results from session artifacts: {e}" + ); + } } } + + if let Some(results) = device_benchmark_results { + benchmark_results.insert(device.device.clone(), results); + } + if device_performance_metrics.sample_count > 0 { + performance_metrics.insert(device.device.clone(), device_performance_metrics); + } } if benchmark_results.is_empty() { @@ -785,11 +922,7 @@ impl BrowserStackClient { } /// Fetch session details from BrowserStack API. - pub fn get_session_details( - &self, - build_id: &str, - session_id: &str, - ) -> Result { + pub fn get_session_details(&self, build_id: &str, session_id: &str) -> Result { let path = format!("/app-automate/builds/{build_id}/sessions/{session_id}"); let value = self.get_json(&path)?; @@ -813,9 +946,7 @@ impl BrowserStackClient { .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(), - duration: automation_session - .get("duration") - .and_then(|v| v.as_u64()), + duration: automation_session.get("duration").and_then(|v| v.as_u64()), }) } @@ -843,10 +974,7 @@ impl BrowserStackClient { .as_ref() .map(|d| d.device.clone()) .unwrap_or_else(|| device_session.device.clone()), - os: details - .as_ref() - .map(|d| d.os.clone()) - .unwrap_or_default(), + os: details.as_ref().map(|d| d.os.clone()).unwrap_or_default(), os_version: details .as_ref() .map(|d| d.os_version.clone()) @@ -862,6 +990,203 @@ impl BrowserStackClient { sessions, }) } + + fn normalize_benchmark_values(value: Value) -> Vec { + match value { + Value::Array(entries) => entries + .into_iter() + .filter_map(Self::normalize_benchmark_value) + .collect(), + value => Self::normalize_benchmark_value(value).into_iter().collect(), + } + } + + fn normalize_benchmark_value(mut value: Value) -> Option { + let samples = Self::extract_sample_durations(&value); + let stats = Self::compute_sample_stats(&samples); + let object = value.as_object_mut()?; + + if !object.contains_key("function") + && let Some(function) = object + .get("spec") + .and_then(|spec| spec.get("name")) + .and_then(|name| name.as_str()) + { + object.insert("function".to_string(), Value::String(function.to_string())); + } + + if !object.contains_key("samples") + && let Some(samples_ns) = object + .get("samples_ns") + .and_then(|samples| samples.as_array()) + { + object.insert("samples".to_string(), Value::Array(samples_ns.clone())); + } + + let has_function = object + .get("function") + .and_then(|value| value.as_str()) + .is_some(); + let has_samples = object + .get("samples") + .and_then(|value| value.as_array()) + .is_some(); + let has_stats = ["mean_ns", "median_ns", "p95_ns", "min_ns", "max_ns"] + .iter() + .any(|key| object.get(*key).is_some()); + + if !has_function || (!has_samples && !has_stats) { + return None; + } + + if let Some(stats) = stats { + if !object.contains_key("mean_ns") { + object.insert("mean_ns".to_string(), Value::from(stats.mean_ns)); + } + if !object.contains_key("median_ns") { + object.insert("median_ns".to_string(), Value::from(stats.median_ns)); + } + if !object.contains_key("p95_ns") { + object.insert("p95_ns".to_string(), Value::from(stats.p95_ns)); + } + if !object.contains_key("min_ns") { + object.insert("min_ns".to_string(), Value::from(stats.min_ns)); + } + if !object.contains_key("max_ns") { + object.insert("max_ns".to_string(), Value::from(stats.max_ns)); + } + } + + Some(value) + } + + fn extend_unique_results(results: &mut Vec, mut new_results: Vec) { + for result in new_results.drain(..) { + if !results.iter().any(|existing| existing == &result) { + results.push(result); + } + } + } + + fn collect_text_artifact_urls(value: &Value) -> Vec<(String, String)> { + let mut urls = Vec::new(); + Self::collect_text_artifact_urls_recursive(value, "", &mut urls); + urls.sort_by_key(|(key, url)| Self::artifact_url_priority(key, url)); + urls + } + + fn collect_text_artifact_urls_recursive( + value: &Value, + prefix: &str, + out: &mut Vec<(String, String)>, + ) { + match value { + Value::Object(map) => { + for (key, value) in map { + let next = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + if let Value::String(url) = value + && (url.starts_with("http") || url.starts_with("bs://")) + && Self::artifact_url_priority(&next, url) < 4 + { + out.push((next.clone(), url.clone())); + } + Self::collect_text_artifact_urls_recursive(value, &next, out); + } + } + Value::Array(items) => { + for (index, value) in items.iter().enumerate() { + let next = format!("{}[{}]", prefix, index); + Self::collect_text_artifact_urls_recursive(value, &next, out); + } + } + _ => {} + } + } + + fn artifact_url_priority(key: &str, url: &str) -> u8 { + let lower = format!("{} {}", key.to_ascii_lowercase(), url.to_ascii_lowercase()); + if lower.contains("bench-report") || lower.contains("bench_report") { + 0 + } else if lower.contains("device_log") + || lower.contains("devicelog") + || lower.contains("instrumentation_log") + || lower.contains("app_log") + { + 1 + } else if lower.ends_with(".json") || lower.ends_with(".log") || lower.ends_with(".txt") { + 2 + } else { + 4 + } + } + + fn extract_sample_durations(value: &Value) -> Vec { + let mut durations = Vec::new(); + + if let Some(samples) = value.get("samples").and_then(|samples| samples.as_array()) { + for sample in samples { + if let Some(duration_ns) = sample + .get("duration_ns") + .and_then(|duration| duration.as_u64()) + { + durations.push(duration_ns); + } else if let Some(duration_ns) = sample.as_u64() { + durations.push(duration_ns); + } + } + } + + if durations.is_empty() + && let Some(samples_ns) = value + .get("samples_ns") + .and_then(|samples| samples.as_array()) + { + durations.extend(samples_ns.iter().filter_map(|sample| sample.as_u64())); + } + + durations + } + + fn compute_sample_stats(samples: &[u64]) -> Option { + if samples.is_empty() { + return None; + } + + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + let len = sorted.len(); + let mean_ns = + (sorted.iter().map(|value| *value as u128).sum::() / len as u128) as u64; + let median_ns = if len % 2 == 1 { + sorted[len / 2] + } else { + let lower = sorted[(len / 2) - 1]; + let upper = sorted[len / 2]; + (lower + upper) / 2 + }; + let p95_ns = sorted[Self::percentile_index(len, 0.95)]; + + Some(NormalizedSampleStats { + mean_ns, + median_ns, + p95_ns, + min_ns: sorted[0], + max_ns: sorted[len - 1], + }) + } + + fn percentile_index(len: usize, percentile: f64) -> usize { + if len == 0 { + return 0; + } + let rank = (percentile * len as f64).ceil() as usize; + let index = rank.saturating_sub(1); + index.min(len - 1) + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -948,6 +1273,15 @@ pub struct AggregateCpuMetrics { pub min_percent: f64, } +#[derive(Debug, Clone, Copy)] +struct NormalizedSampleStats { + mean_ns: u64, + median_ns: u64, + p95_ns: u64, + min_ns: u64, + max_ns: u64, +} + impl PerformanceMetrics { pub fn from_snapshots(snapshots: Vec) -> Self { if snapshots.is_empty() { @@ -1424,6 +1758,8 @@ pub fn format_credentials_error(_missing_username: bool, _missing_access_key: bo #[cfg(test)] mod tests { use super::*; + use anyhow::anyhow; + use serde_json::json; #[test] fn rejects_missing_artifact() { @@ -2329,4 +2665,140 @@ BENCH_REPORT_JSON_END assert_eq!(devices[0].device, "Google Pixel 7"); assert_eq!(devices[1].device, "iPhone 14"); } + + #[test] + fn extract_results_from_session_artifacts_prefers_bench_report_json() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let session_json = json!({ + "bench_report_url": "https://example.com/bench-report.json", + "device_logs_url": "https://example.com/device.log", + }); + + let (results, metrics) = client + .extract_results_from_session_artifacts(&session_json, |url| match url { + "https://example.com/bench-report.json" => Ok(json!({ + "spec": { + "name": "bench_hash", + "iterations": 2, + "warmup": 1 + }, + "samples": [ + {"duration_ns": 1000}, + {"duration_ns": 2000} + ] + }) + .to_string()), + "https://example.com/device.log" => Ok("no benchmark markers here".to_string()), + other => Err(anyhow!("unexpected artifact url: {other}")), + }) + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!( + results[0].get("function").and_then(|v| v.as_str()), + Some("bench_hash") + ); + assert_eq!( + results[0] + .get("samples") + .and_then(|v| v.as_array()) + .map(std::vec::Vec::len), + Some(2) + ); + assert_eq!( + results[0].get("mean_ns").and_then(|v| v.as_u64()), + Some(1500) + ); + assert_eq!(metrics.sample_count, 0); + } + + #[test] + fn extract_results_from_session_artifacts_falls_back_to_ios_log_markers() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let session_json = json!({ + "device_logs_url": "https://example.com/device.log" + }); + + let (results, _) = client + .extract_results_from_session_artifacts(&session_json, |url| match url { + "https://example.com/device.log" => Ok( + r#" + 2026-01-20 12:34:57 BenchRunner[1:2] BENCH_REPORT_JSON_START + 2026-01-20 12:34:57 BenchRunner[1:2] {"spec":{"name":"bench_ios"},"samples_ns":[1000,2000,3000]} + 2026-01-20 12:34:57 BenchRunner[1:2] BENCH_REPORT_JSON_END + "# + .to_string(), + ), + other => Err(anyhow!("unexpected artifact url: {other}")), + }) + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!( + results[0].get("function").and_then(|v| v.as_str()), + Some("bench_ios") + ); + assert_eq!( + results[0].get("p95_ns").and_then(|v| v.as_u64()), + Some(3000) + ); + } + + #[test] + fn extract_results_from_session_artifacts_falls_back_to_android_bench_json_logs() { + let client = BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap(); + + let session_json = json!({ + "instrumentation_log_url": "https://example.com/instrumentation.log" + }); + + let (results, _) = client + .extract_results_from_session_artifacts(&session_json, |url| match url { + "https://example.com/instrumentation.log" => Ok( + r#" + 2026-01-20 12:34:57 I/BenchRunner: BENCH_JSON {"spec":{"name":"bench_android","iterations":2,"warmup":1},"samples_ns":[10,20]} + "# + .to_string(), + ), + other => Err(anyhow!("unexpected artifact url: {other}")), + }) + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!( + results[0].get("function").and_then(|v| v.as_str()), + Some("bench_android") + ); + assert_eq!(results[0].get("mean_ns").and_then(|v| v.as_u64()), Some(15)); + assert_eq!( + results[0] + .get("samples") + .and_then(|v| v.as_array()) + .map(std::vec::Vec::len), + Some(2) + ); + } } diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index f3f059b..e54514f 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -660,7 +660,10 @@ struct CiRunArgs { help = "Path to the benchmark crate directory containing Cargo.toml" )] crate_path: Option, - #[arg(long, help = "Fully-qualified Rust function to benchmark (single function)")] + #[arg( + long, + help = "Fully-qualified Rust function to benchmark (single function)" + )] function: Option, #[arg( long, @@ -2396,6 +2399,107 @@ fn resolve_ci_functions(args: &CiRunArgs) -> Result> { Ok(funcs) } +fn ci_function_slug(function: &str) -> String { + function.replace("::", "_").replace('/', "-") +} + +fn summary_report_from_value(value: &Value) -> Result { + let summary_value = value + .get("summary") + .cloned() + .unwrap_or_else(|| value.clone()); + serde_json::from_value(summary_value).context("parsing summary report") +} + +fn merge_summary_reports( + target: MobileTarget, + summaries: &[SummaryReport], +) -> Result { + let first = summaries + .first() + .ok_or_else(|| anyhow!("cannot merge empty summary list"))?; + + let latest = summaries + .iter() + .max_by_key(|summary| summary.generated_at_unix) + .unwrap_or(first); + + let mut devices = BTreeSet::new(); + let mut functions = BTreeSet::new(); + let mut device_benchmarks: BTreeMap> = BTreeMap::new(); + + for summary in summaries { + for device in &summary.devices { + devices.insert(device.clone()); + } + functions.insert(summary.function.clone()); + + for device_summary in &summary.device_summaries { + let benchmark_map = device_benchmarks + .entry(device_summary.device.clone()) + .or_default(); + for benchmark in &device_summary.benchmarks { + benchmark_map.insert(benchmark.function.clone(), benchmark.clone()); + } + } + } + + let function = if functions.len() == 1 { + functions + .into_iter() + .next() + .unwrap_or_else(|| "unknown".to_string()) + } else { + "multiple".to_string() + }; + + let device_summaries = device_benchmarks + .into_iter() + .map(|(device, benchmarks)| DeviceSummary { + device, + benchmarks: benchmarks.into_values().collect(), + }) + .collect(); + + Ok(SummaryReport { + generated_at: latest.generated_at.clone(), + generated_at_unix: latest.generated_at_unix, + target, + function, + iterations: first.iterations, + warmup: first.warmup, + devices: devices.into_iter().collect(), + device_summaries, + }) +} + +fn merge_ci_target_runs( + target: MobileTarget, + function_runs: &BTreeMap, +) -> Result { + let summaries = function_runs + .values() + .map(summary_report_from_value) + .collect::>>()?; + let merged_summary = merge_summary_reports(target, &summaries)?; + + Ok(json!({ + "summary": merged_summary, + "functions": function_runs + })) +} + +fn root_summary_from_merged_targets(targets: &BTreeMap) -> Option { + if targets.len() != 1 { + return None; + } + + targets + .values() + .next() + .and_then(|entry| entry.get("summary").cloned()) +} + fn cmd_ci_run(args: CiRunArgs) -> Result<()> { let all_functions = resolve_ci_functions(&args)?; @@ -2429,58 +2533,83 @@ fn cmd_ci_run(args: CiRunArgs) -> Result<()> { } let mut regression_detected = false; - let mut merged_summaries: BTreeMap = BTreeMap::new(); - let mut merged_markdown_sections = Vec::new(); - let mut merged_csv_rows = Vec::new(); - let mut merged_header: Option = None; - let mut target_outputs = BTreeMap::new(); + let mut target_runs: BTreeMap> = BTreeMap::new(); + let mut target_outputs: BTreeMap> = BTreeMap::new(); for target in targets { let target_value = *target; for func in &all_functions { - let slug = func.replace("::", "_").replace('/', "-"); - let target_dir = if all_functions.len() == 1 { - args.output_dir.join(target_value.as_str()) - } else { - args.output_dir - .join(target_value.as_str()) - .join(&slug) - }; - fs::create_dir_all(&target_dir) - .with_context(|| format!("creating target output dir {}", target_dir.display()))?; - let mut func_args = args.clone(); - func_args.function = Some(func.clone()); - let exit_code = cmd_ci_run_single(&func_args, target_value, &target_dir, &metadata)?; - if exit_code == EXIT_REGRESSION { - regression_detected = true; - } else if exit_code != 0 { - std::process::exit(exit_code); - } + let slug = ci_function_slug(func); + let target_dir = if all_functions.len() == 1 { + args.output_dir.join(target_value.as_str()) + } else { + args.output_dir.join(target_value.as_str()).join(&slug) + }; + fs::create_dir_all(&target_dir) + .with_context(|| format!("creating target output dir {}", target_dir.display()))?; + let mut func_args = args.clone(); + func_args.function = Some(func.clone()); + let exit_code = cmd_ci_run_single(&func_args, target_value, &target_dir, &metadata)?; + if exit_code == EXIT_REGRESSION { + regression_detected = true; + } else if exit_code != 0 { + std::process::exit(exit_code); + } - let summary_json = target_dir.join("summary.json"); - let summary_md = target_dir.join("summary.md"); - let results_csv = target_dir.join("results.csv"); - - let summary_text = fs::read_to_string(&summary_json) - .with_context(|| format!("reading {}", summary_json.display()))?; - let summary_value: Value = serde_json::from_str(&summary_text) - .with_context(|| format!("parsing {}", summary_json.display()))?; - merged_summaries.insert(target_value.as_str().to_string(), summary_value); - target_outputs.insert( - target_value.as_str().to_string(), - json!({ - "summary_json": summary_json.display().to_string(), - "summary_md": summary_md.display().to_string(), - "results_csv": results_csv.display().to_string(), - }), - ); + let summary_json = target_dir.join("summary.json"); + let summary_md = target_dir.join("summary.md"); + let results_csv = target_dir.join("results.csv"); + + let summary_text = fs::read_to_string(&summary_json) + .with_context(|| format!("reading {}", summary_json.display()))?; + let summary_value: Value = serde_json::from_str(&summary_text) + .with_context(|| format!("parsing {}", summary_json.display()))?; + target_runs + .entry(target_value.as_str().to_string()) + .or_default() + .insert(slug.clone(), summary_value); + target_outputs + .entry(target_value.as_str().to_string()) + .or_default() + .insert( + slug, + json!({ + "summary_json": summary_json.display().to_string(), + "summary_md": summary_md.display().to_string(), + "results_csv": results_csv.display().to_string(), + }), + ); + } // end for func + } // end for target + + let mut merged_targets = BTreeMap::new(); + for target in targets { + let target_value = *target; + let target_key = target_value.as_str().to_string(); + let runs = target_runs + .get(&target_key) + .ok_or_else(|| anyhow!("missing merged runs for target `{target_key}`"))?; + merged_targets.insert(target_key, merge_ci_target_runs(target_value, runs)?); + } + + let root_summary_json = args.output_dir.join("summary.json"); + let root_summary_md = args.output_dir.join("summary.md"); + let root_results_csv = args.output_dir.join("results.csv"); - let markdown = fs::read_to_string(&summary_md) - .with_context(|| format!("reading {}", summary_md.display()))?; - merged_markdown_sections.push(format!("## {}\n\n{}", target_value.as_str(), markdown)); + let mut merged_markdown_sections = Vec::new(); + let mut merged_csv_rows = Vec::new(); + let mut merged_header: Option = None; + + for (target_name, entry) in &merged_targets { + let summary = summary_report_from_value(entry)?; + let markdown = render_markdown_summary(&summary); + if merged_targets.len() == 1 { + merged_markdown_sections.push(markdown); + } else { + merged_markdown_sections.push(format!("## {}\n\n{}", target_name, markdown)); + } - let csv = fs::read_to_string(&results_csv) - .with_context(|| format!("reading {}", results_csv.display()))?; + let csv = render_csv_summary(&summary); let mut lines = csv.lines(); if let Some(header) = lines.next() && merged_header.is_none() @@ -2491,14 +2620,9 @@ fn cmd_ci_run(args: CiRunArgs) -> Result<()> { if line.trim().is_empty() { continue; } - merged_csv_rows.push(format!("{},{}", target_value.as_str(), line)); + merged_csv_rows.push(format!("{target_name},{line}")); } - } // end for func - } // end for target - - let root_summary_json = args.output_dir.join("summary.json"); - let root_summary_md = args.output_dir.join("summary.md"); - let root_results_csv = args.output_dir.join("results.csv"); + } let merged_markdown = merged_markdown_sections.join("\n\n"); write_file(&root_summary_md, merged_markdown.as_bytes())?; @@ -2522,11 +2646,28 @@ fn cmd_ci_run(args: CiRunArgs) -> Result<()> { "results_csv": root_results_csv.display().to_string(), }, "targets": target_outputs + .into_iter() + .map(|(target, functions)| (target, json!({ "functions": functions }))) + .collect::>() }); - let merged_summary = json!({ - "targets": merged_summaries, + let mut merged_summary = json!({ + "targets": merged_targets, "ci": root_ci_value }); + if let Some(summary) = merged_summary + .get("targets") + .and_then(|targets| targets.as_object()) + .map(|targets| { + targets + .iter() + .map(|(target, value)| (target.clone(), value.clone())) + .collect::>() + }) + .and_then(|targets| root_summary_from_merged_targets(&targets)) + && let Some(obj) = merged_summary.as_object_mut() + { + obj.insert("summary".to_string(), summary); + } write_file( &root_summary_json, serde_json::to_string_pretty(&merged_summary)?.as_bytes(), @@ -2920,7 +3061,7 @@ fn fetch_browserstack_artifacts( .with_context(|| format!("creating session dir {:?}", session_dir))?; write_json(session_dir.join("session.json"), &session_json)?; - let mut bench_report: Option = None; + let mut downloaded_texts = BTreeMap::new(); for (key, url) in extract_url_fields(&session_json) { let file_name = filename_for_url(&key, &url); let dest = session_dir.join(file_name); @@ -2928,17 +3069,24 @@ fn fetch_browserstack_artifacts( println!("Skipping download for {key}: {err}"); continue; } - if (key.contains("device_log") - || key.contains("instrumentation_log") - || key.contains("app_log")) - && let Ok(contents) = fs::read_to_string(&dest) - && let Some(parsed) = extract_bench_json(&contents) - { - bench_report = Some(parsed); + if let Ok(contents) = fs::read_to_string(&dest) { + downloaded_texts.insert(url, contents); } } - if let Some(report) = bench_report { + if let Ok((bench_results, _)) = + client.extract_results_from_session_artifacts(&session_json, |url| { + downloaded_texts + .get(url) + .cloned() + .ok_or_else(|| anyhow!("artifact {url} was not downloaded as text")) + }) + { + let report = if bench_results.len() == 1 { + bench_results.into_iter().next().unwrap_or(Value::Null) + } else { + Value::Array(bench_results) + }; write_json(session_dir.join("bench-report.json"), &report)?; } } @@ -3074,153 +3222,6 @@ fn filename_for_url(key: &str, url: &str) -> String { format!("{}.{}", safe, ext) } -fn extract_bench_json(contents: &str) -> Option { - // First, try iOS-style markers: BENCH_REPORT_JSON_START ... BENCH_REPORT_JSON_END - // This allows multi-line JSON and is more robust for iOS NSLog output - if let Some(json) = extract_bench_json_ios_markers(contents) { - return Some(json); - } - - // Fall back to Android-style single-line marker: BENCH_JSON {...} - let marker = "BENCH_JSON "; - for line in contents.lines().rev() { - if let Some(idx) = line.find(marker) { - let json_part = &line[idx + marker.len()..]; - if let Ok(value) = serde_json::from_str::(json_part) { - return Some(value); - } - } - } - None -} - -/// Extract benchmark JSON from iOS logs using START/END markers. -/// iOS uses NSLog which may split the JSON across multiple log lines, -/// so we need to capture everything between the markers. -fn extract_bench_json_ios_markers(contents: &str) -> Option { - let start_marker = "BENCH_REPORT_JSON_START"; - let end_marker = "BENCH_REPORT_JSON_END"; - - // Find the last occurrence of start marker (in case of multiple runs) - let start_pos = contents.rfind(start_marker)?; - let after_start = &contents[start_pos + start_marker.len()..]; - - // Find the end marker after the start - let end_pos = after_start.find(end_marker)?; - let json_section = &after_start[..end_pos]; - - // The JSON might be on the next line or have log prefixes, so we need to clean it up - // iOS NSLog format often looks like: "2026-01-20 12:34:56.789 BenchRunner[1234:5678] {"key": "value"}" - // or just the raw JSON on its own line - - // Try to find valid JSON in the section - let json_str = extract_json_from_log_section(json_section)?; - - serde_json::from_str::(&json_str).ok() -} - -/// Extract valid JSON from a log section that may contain log prefixes/timestamps. -/// Handles both raw JSON and JSON embedded in log lines. -fn extract_json_from_log_section(section: &str) -> Option { - // First, try the whole section as-is (trimmed) - let trimmed = section.trim(); - if trimmed.starts_with('{') && trimmed.ends_with('}') { - if serde_json::from_str::(trimmed).is_ok() { - return Some(trimmed.to_string()); - } - } - - // If that didn't work, look for JSON on individual lines - // This handles cases where NSLog adds timestamps/prefixes - for line in section.lines() { - let line = line.trim(); - - // Skip empty lines - if line.is_empty() { - continue; - } - - // Look for JSON starting with { - if let Some(json_start) = line.find('{') { - let potential_json = &line[json_start..]; - - // Try to find the matching closing brace - // This handles cases like: "timestamp prefix {"key": "value"} suffix" - if let Some(json) = extract_balanced_json(potential_json) { - if serde_json::from_str::(&json).is_ok() { - return Some(json); - } - } - } - } - - // Try concatenating all lines and looking for JSON (for multi-line JSON) - let all_content: String = section - .lines() - .map(|line| { - // Try to strip common log prefixes (timestamps, process info) - // iOS format: "2026-01-20 12:34:56.789 AppName[pid:tid] content" - if let Some(bracket_end) = line.find("] ") { - &line[bracket_end + 2..] - } else { - line.trim() - } - }) - .collect::>() - .join(""); - - if let Some(json_start) = all_content.find('{') { - let potential_json = &all_content[json_start..]; - if let Some(json) = extract_balanced_json(potential_json) { - if serde_json::from_str::(&json).is_ok() { - return Some(json); - } - } - } - - None -} - -/// Extract a balanced JSON object from a string starting with '{'. -/// Returns the JSON substring if balanced braces are found. -fn extract_balanced_json(s: &str) -> Option { - if !s.starts_with('{') { - return None; - } - - let mut depth = 0; - let mut in_string = false; - let mut escape_next = false; - - for (i, c) in s.char_indices() { - if escape_next { - escape_next = false; - continue; - } - - match c { - '\\' if in_string => { - escape_next = true; - } - '"' => { - in_string = !in_string; - } - '{' if !in_string => { - depth += 1; - } - '}' if !in_string => { - depth -= 1; - if depth == 0 { - return Some(s[..=i].to_string()); - } - } - _ => {} - } - } - - None -} - fn write_json(path: PathBuf, value: &Value) -> Result<()> { let contents = serde_json::to_string_pretty(value)?; write_file(&path, contents.as_bytes()) @@ -6397,39 +6398,37 @@ fn cmd_devices_resolve( .unwrap_or("default"); // Try loading matrix from file first; fall back to built-in profiles - let (resolved, source) = - match resolve_matrix_for_cli(config_path, device_matrix_path) { - Ok((matrix_path, config_tags)) => { - let matrix = load_device_matrix(&matrix_path).with_context(|| { - format!( - "config_error: failed to parse device matrix at {}", - matrix_path.display() - ) - })?; - let selected_tags = if profile.is_some() { - vec![profile_str.to_string()] - } else { - config_tags - .filter(|tags| !tags.is_empty()) - .unwrap_or_else(|| vec!["default".to_string()]) - }; - let devices = - resolve_devices_from_matrix(matrix.devices, platform, &selected_tags)?; - (devices, format!("matrix:{}", matrix_path.display())) - } - Err(_) => { - // No matrix file found — use built-in profiles - if let Some(device) = builtin_device_for_profile(platform, profile_str) { - (vec![device], "builtin".to_string()) - } else { - bail!( - "No device matrix found and '{}' is not a built-in profile. \ + let (resolved, source) = match resolve_matrix_for_cli(config_path, device_matrix_path) { + Ok((matrix_path, config_tags)) => { + let matrix = load_device_matrix(&matrix_path).with_context(|| { + format!( + "config_error: failed to parse device matrix at {}", + matrix_path.display() + ) + })?; + let selected_tags = if profile.is_some() { + vec![profile_str.to_string()] + } else { + config_tags + .filter(|tags| !tags.is_empty()) + .unwrap_or_else(|| vec!["default".to_string()]) + }; + let devices = resolve_devices_from_matrix(matrix.devices, platform, &selected_tags)?; + (devices, format!("matrix:{}", matrix_path.display())) + } + Err(_) => { + // No matrix file found — use built-in profiles + if let Some(device) = builtin_device_for_profile(platform, profile_str) { + (vec![device], "builtin".to_string()) + } else { + bail!( + "No device matrix found and '{}' is not a built-in profile. \ Built-in profiles: low-spec, mid-spec, high-spec", - profile_str - ); - } + profile_str + ); } - }; + } + }; match format { CheckOutputFormat::Text => { @@ -8484,6 +8483,131 @@ mod result_extraction_tests { } } +#[cfg(test)] +mod ci_merge_tests { + use super::*; + use serde_json::json; + + fn sample_run_summary( + target: MobileTarget, + function: &str, + device: &str, + mean_ns: u64, + ) -> Value { + json!({ + "summary": { + "generated_at": "2026-02-16T00:00:00Z", + "generated_at_unix": 1708041600, + "target": target.as_str(), + "function": function, + "iterations": 3, + "warmup": 1, + "devices": [device], + "device_summaries": [{ + "device": device, + "benchmarks": [{ + "function": function, + "samples": 3, + "mean_ns": mean_ns, + "median_ns": mean_ns, + "p95_ns": mean_ns, + "min_ns": mean_ns, + "max_ns": mean_ns + }] + }] + } + }) + } + + #[test] + fn merge_ci_target_runs_preserves_all_functions() { + let runs = BTreeMap::from([ + ( + "bench_a".to_string(), + sample_run_summary(MobileTarget::Ios, "bench_a", "iPhone 14-16.0", 100), + ), + ( + "bench_b".to_string(), + sample_run_summary(MobileTarget::Ios, "bench_b", "iPhone 14-16.0", 200), + ), + ]); + + let merged = merge_ci_target_runs(MobileTarget::Ios, &runs).unwrap(); + let functions = merged + .get("functions") + .and_then(|v| v.as_object()) + .expect("functions map"); + assert_eq!(functions.len(), 2); + + let benchmarks = merged["summary"]["device_summaries"][0]["benchmarks"] + .as_array() + .expect("benchmarks"); + assert_eq!(benchmarks.len(), 2); + assert_eq!(benchmarks[0]["function"], "bench_a"); + assert_eq!(benchmarks[1]["function"], "bench_b"); + } + + #[test] + fn root_summary_from_merged_targets_returns_summary_for_single_target() { + let merged_target = merge_ci_target_runs( + MobileTarget::Ios, + &BTreeMap::from([( + "bench_a".to_string(), + sample_run_summary(MobileTarget::Ios, "bench_a", "iPhone 14-16.0", 100), + )]), + ) + .unwrap(); + let targets = BTreeMap::from([("ios".to_string(), merged_target)]); + + let root_summary = root_summary_from_merged_targets(&targets).expect("single target"); + assert_eq!(root_summary["target"], "ios"); + assert_eq!( + root_summary["device_summaries"][0]["benchmarks"][0]["function"], + "bench_a" + ); + } + + #[test] + fn render_summary_markdown_from_output_renders_all_functions_from_merged_targets() { + let ios = merge_ci_target_runs( + MobileTarget::Ios, + &BTreeMap::from([ + ( + "bench_a".to_string(), + sample_run_summary(MobileTarget::Ios, "bench_a", "iPhone 14-16.0", 100), + ), + ( + "bench_b".to_string(), + sample_run_summary(MobileTarget::Ios, "bench_b", "iPhone 14-16.0", 200), + ), + ]), + ) + .unwrap(); + let android = merge_ci_target_runs( + MobileTarget::Android, + &BTreeMap::from([( + "bench_c".to_string(), + sample_run_summary(MobileTarget::Android, "bench_c", "Pixel 7-14.0", 300), + )]), + ) + .unwrap(); + + let markdown = render_summary_markdown_from_output(&json!({ + "targets": { + "ios": ios, + "android": android + } + })) + .unwrap(); + + assert!(markdown.contains("## ios")); + assert!(markdown.contains("## android")); + assert!(markdown.contains("bench_a")); + assert!(markdown.contains("bench_b")); + assert!(markdown.contains("bench_c")); + } +} + #[cfg(test)] mod init_sdk_tests { use super::*; diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index 27140f5..cd38724 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -1,9 +1,9 @@ //! Types and logic for the `ci summarize` command. use anyhow::{Context, Result}; -use comfy_table::{presets::UTF8_FULL, Attribute, Cell, ContentArrangement, Table}; +use comfy_table::{Attribute, Cell, ContentArrangement, Table, presets::UTF8_FULL}; use serde::{Deserialize, Serialize}; -use std::path::Path; +use std::path::{Path, PathBuf}; /// A fully-assembled summary ready for rendering. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -66,9 +66,29 @@ pub struct ResourceUsage { /// Parse a summary.json value into a [`SummarizeReport`]. pub fn parse_summary_value(value: &serde_json::Value) -> Result { - let summary = value - .get("summary") - .context("Missing 'summary' key in JSON")?; + if let Some(summary) = value.get("summary") { + return parse_summary_object(summary); + } + + if let Some(targets) = value.get("targets").and_then(|v| v.as_object()) { + let mut platforms = Vec::new(); + for entry in targets.values() { + if let Ok(report) = parse_summary_value(entry) { + platforms.extend(report.platforms); + } + } + if !platforms.is_empty() { + return Ok(SummarizeReport { platforms }); + } + } + + anyhow::bail!("Missing 'summary' or 'targets' key in JSON"); +} + +fn parse_summary_object(summary: &serde_json::Value) -> Result { + let summary = summary + .as_object() + .context("Summary payload must be a JSON object")?; let target = summary .get("target") @@ -81,10 +101,7 @@ pub fn parse_summary_value(value: &serde_json::Value) -> Result .and_then(|v| v.as_u64()) .unwrap_or(0) as u32; - let warmup = summary - .get("warmup") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32; + let warmup = summary.get("warmup").and_then(|v| v.as_u64()).unwrap_or(0) as u32; let device_summaries = summary .get("device_summaries") @@ -151,13 +168,8 @@ fn parse_benchmark_entry(value: &serde_json::Value) -> Result { let label = humanize_benchmark_name(&name); - let ns_to_ms = |key: &str| -> f64 { - value - .get(key) - .and_then(|v| v.as_f64()) - .unwrap_or(0.0) - / 1_000_000.0 - }; + let ns_to_ms = + |key: &str| -> f64 { value.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0) / 1_000_000.0 }; let timing = TimingStats { avg_ms: ns_to_ms("mean_ns"), @@ -193,18 +205,40 @@ fn humanize_benchmark_name(name: &str) -> String { /// Load all summary JSON files from a results directory. pub fn load_results_dir(dir: &Path) -> Result { + let root_summary_path = dir.join("summary.json"); + if root_summary_path.exists() { + let content = std::fs::read_to_string(&root_summary_path) + .with_context(|| format!("Failed to read {}", root_summary_path.display()))?; + let value: serde_json::Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", root_summary_path.display()))?; + if let Ok(report) = parse_summary_value(&value) { + return Ok(report); + } + } + + let mut json_paths = Vec::new(); + collect_json_files(dir, &mut json_paths)?; + json_paths.retain(|path| path != &root_summary_path); + let mut all_platforms = Vec::new(); + let mut raw_candidates = Vec::new(); - for entry in std::fs::read_dir(dir).context("Failed to read results directory")? { - let entry = entry?; - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "json") { - let content = std::fs::read_to_string(&path) - .with_context(|| format!("Failed to read {}", path.display()))?; - let value: serde_json::Value = serde_json::from_str(&content) - .with_context(|| format!("Failed to parse {}", path.display()))?; + for path in json_paths { + let content = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + let value: serde_json::Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", path.display()))?; + + if let Ok(report) = parse_summary_value(&value) { + all_platforms.extend(report.platforms); + } else { + raw_candidates.push((path, value)); + } + } - if let Ok(report) = parse_summary_value(&value) { + if all_platforms.is_empty() { + for (path, value) in raw_candidates { + if let Ok(report) = parse_raw_bench_report(&path, &value) { all_platforms.extend(report.platforms); } } @@ -219,6 +253,180 @@ pub fn load_results_dir(dir: &Path) -> Result { }) } +fn collect_json_files(dir: &Path, out: &mut Vec) -> Result<()> { + let mut entries = std::fs::read_dir(dir) + .with_context(|| format!("Failed to read results directory {}", dir.display()))? + .collect::, _>>() + .with_context(|| format!("Failed to iterate results directory {}", dir.display()))?; + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { + let path = entry.path(); + if path.is_dir() { + collect_json_files(&path, out)?; + } else if path.extension().is_some_and(|ext| ext == "json") { + out.push(path); + } + } + + Ok(()) +} + +fn parse_raw_bench_report(path: &Path, value: &serde_json::Value) -> Result { + let entries = match value { + serde_json::Value::Array(items) => items + .iter() + .filter_map(normalize_raw_benchmark_entry) + .collect::>(), + _ => normalize_raw_benchmark_entry(value).into_iter().collect(), + }; + + if entries.is_empty() { + anyhow::bail!("Not a raw bench report"); + } + + let first = entries + .first() + .ok_or_else(|| anyhow::anyhow!("missing bench report entries"))?; + let platform = infer_platform(path, first.get("device").and_then(|v| v.as_str())); + let device = first + .get("device") + .and_then(|v| v.as_str()) + .map(parse_device_string) + .unwrap_or_else(|| default_device_info(&platform)); + let iterations = first + .get("spec") + .and_then(|spec| spec.get("iterations")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + let warmup = first + .get("spec") + .and_then(|spec| spec.get("warmup")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + let benchmarks = entries + .iter() + .map(parse_benchmark_entry) + .collect::>>()?; + + Ok(SummarizeReport { + platforms: vec![PlatformReport { + platform, + device, + benchmarks, + iterations, + warmup, + }], + }) +} + +fn normalize_raw_benchmark_entry(value: &serde_json::Value) -> Option { + let mut value = value.clone(); + let samples = extract_raw_samples(&value); + let stats = crate::compute_sample_stats(&samples); + let object = value.as_object_mut()?; + + if !object.contains_key("function") + && let Some(function) = object + .get("spec") + .and_then(|spec| spec.get("name")) + .and_then(|name| name.as_str()) + { + object.insert( + "function".to_string(), + serde_json::Value::String(function.to_string()), + ); + } + + if !object.contains_key("samples") + && let Some(samples_ns) = object.get("samples_ns").and_then(|v| v.as_array()) + { + object.insert( + "samples".to_string(), + serde_json::Value::Array(samples_ns.clone()), + ); + } + + if let Some(stats) = stats { + if !object.contains_key("mean_ns") { + object.insert( + "mean_ns".to_string(), + serde_json::Value::from(stats.mean_ns), + ); + } + if !object.contains_key("median_ns") { + object.insert( + "median_ns".to_string(), + serde_json::Value::from(stats.median_ns), + ); + } + if !object.contains_key("min_ns") { + object.insert("min_ns".to_string(), serde_json::Value::from(stats.min_ns)); + } + if !object.contains_key("max_ns") { + object.insert("max_ns".to_string(), serde_json::Value::from(stats.max_ns)); + } + if !object.contains_key("p95_ns") { + object.insert("p95_ns".to_string(), serde_json::Value::from(stats.p95_ns)); + } + } + + let has_function = object.get("function").and_then(|v| v.as_str()).is_some(); + let has_samples = object.get("samples").and_then(|v| v.as_array()).is_some(); + if has_function && has_samples { + Some(value) + } else { + None + } +} + +fn extract_raw_samples(value: &serde_json::Value) -> Vec { + let mut samples = crate::extract_samples(value); + if samples.is_empty() + && let Some(samples_ns) = value.get("samples_ns").and_then(|v| v.as_array()) + { + samples.extend(samples_ns.iter().filter_map(|sample| sample.as_u64())); + } + samples +} + +fn infer_platform(path: &Path, device: Option<&str>) -> String { + if let Some(device) = device { + let lower = device.to_ascii_lowercase(); + if lower.contains("iphone") || lower.contains("ipad") || lower.contains("ios") { + return "ios".to_string(); + } + if lower.contains("pixel") || lower.contains("android") { + return "android".to_string(); + } + } + + let lower_path = path.to_string_lossy().to_ascii_lowercase(); + if lower_path.contains("/ios/") || lower_path.contains("\\ios\\") { + "ios".to_string() + } else if lower_path.contains("/android/") || lower_path.contains("\\android\\") { + "android".to_string() + } else { + "unknown".to_string() + } +} + +fn default_device_info(platform: &str) -> DeviceInfo { + let os = match platform { + "ios" => "iOS", + "android" => "Android", + _ => "unknown", + }; + + DeviceInfo { + name: "unknown".to_string(), + os: os.to_string(), + os_version: "unknown".to_string(), + chipset: None, + ram_gb: None, + } +} + /// Render the full report as terminal tables. pub fn render_table(report: &SummarizeReport) -> String { let mut output = String::new(); @@ -447,9 +655,10 @@ pub fn render_json(report: &SummarizeReport) -> Result { #[cfg(test)] mod tests { use super::*; + use serde_json::json; fn sample_summary_json() -> serde_json::Value { - serde_json::json!({ + json!({ "summary": { "generated_at": "2026-02-26T12:00:00Z", "target": "ios", @@ -473,6 +682,21 @@ mod tests { }) } + fn sample_bench_report_json() -> serde_json::Value { + json!({ + "spec": { + "name": "bench_nullifier_proving_only", + "iterations": 3, + "warmup": 1 + }, + "samples": [ + { "duration_ns": 1000_u64 }, + { "duration_ns": 2000_u64 }, + { "duration_ns": 3000_u64 } + ] + }) + } + #[test] fn test_parse_summary_json() { let json = sample_summary_json(); @@ -545,6 +769,75 @@ mod tests { assert!(load_results_dir(dir.path()).is_err()); } + #[test] + fn test_parse_summary_value_handles_merged_ci_root() { + let report = parse_summary_value(&json!({ + "targets": { + "ios": { + "summary": sample_summary_json()["summary"].clone(), + "functions": { + "bench_nullifier_proving_only": sample_summary_json() + } + } + }, + "ci": { + "metadata": { + "requested_by": "codex", + "request_command": "cargo mobench ci run", + "mobench_version": "0.1.0" + }, + "outputs": { + "summary_json": "summary.json", + "summary_md": "summary.md", + "results_csv": "results.csv" + } + } + })) + .unwrap(); + + assert_eq!(report.platforms.len(), 1); + assert_eq!(report.platforms[0].platform, "ios"); + assert_eq!(report.platforms[0].benchmarks.len(), 1); + } + + #[test] + fn test_load_results_dir_recurses_into_target_function_dirs() { + let dir = tempfile::tempdir().unwrap(); + let ios_dir = dir.path().join("ios").join("bench_nullifier_proving_only"); + std::fs::create_dir_all(&ios_dir).unwrap(); + std::fs::write( + ios_dir.join("summary.json"), + serde_json::to_string(&sample_summary_json()).unwrap(), + ) + .unwrap(); + + let report = load_results_dir(dir.path()).unwrap(); + assert_eq!(report.platforms.len(), 1); + assert_eq!(report.platforms[0].platform, "ios"); + assert_eq!( + report.platforms[0].benchmarks[0].name, + "bench_nullifier_proving_only" + ); + } + + #[test] + fn test_load_results_dir_falls_back_to_raw_bench_report() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("bench-report.json"), + serde_json::to_string(&sample_bench_report_json()).unwrap(), + ) + .unwrap(); + + let report = load_results_dir(dir.path()).unwrap(); + assert_eq!(report.platforms.len(), 1); + assert_eq!(report.platforms[0].benchmarks.len(), 1); + assert_eq!( + report.platforms[0].benchmarks[0].name, + "bench_nullifier_proving_only" + ); + } + #[test] fn test_render_table_output() { let report = SummarizeReport { From de42d3c9f67ea0386592e086d9eefd020855a1ff Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 19:49:23 -0700 Subject: [PATCH 096/196] docs: add sina-style device plot design --- .../2026-03-25-sina-device-plots-design.md | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/plans/2026-03-25-sina-device-plots-design.md diff --git a/docs/plans/2026-03-25-sina-device-plots-design.md b/docs/plans/2026-03-25-sina-device-plots-design.md new file mode 100644 index 0000000..7c40035 --- /dev/null +++ b/docs/plans/2026-03-25-sina-device-plots-design.md @@ -0,0 +1,250 @@ +# Sina-Style Device Comparison Plots + +## Summary + +Add per-function device comparison plots to the final Mobench summary flow. +The Rust CLI remains the user-facing entry point, but delegates plot rendering +to a separate Python tool. Each plot compares devices for a single benchmark +function by showing one dot per iteration, with runtime on the y-axis and +device on the x-axis. + +The result should increase information density without weakening the existing +CI output contract. `summary.json` remains stable. Plots are additive report +artifacts embedded into `summary.md`. + +## Goals + +- Keep the primary workflow inside the existing CLI/report path. +- Render one plot per benchmark function in the final summary section. +- Compare devices on a single chart with one point per raw iteration. +- Use deterministic point placement instead of random jitter or KDE sampling. +- Produce sleek, publication-friendly SVG output. +- Preserve the current `summary.json` contract. + +## Non-Goals + +- Replacing the current table and markdown summaries. +- Introducing a hard dependency on marimo notebooks. +- Changing the CI v1 required output contract. +- Requiring plots to exist when only aggregate statistics are available. + +## User Experience + +The common flow stays the same: + +```bash +cargo mobench ci run ... +cargo mobench report summarize --summary target/mobench/ci/summary.json +``` + +During the final summarize/report step, Mobench attempts to generate plots for +each benchmark function that has raw per-iteration data across devices. The +report embeds those plots in the same section as the summary table. + +Plot generation mode should be configurable: + +- `auto`: generate plots when the Python renderer and raw samples are available +- `off`: skip plotting entirely +- `require`: fail the summarize/report step if plots cannot be generated + +`auto` should be the default. + +## Architecture + +### Rust CLI + +Rust remains responsible for: + +- locating and loading result artifacts +- understanding Mobench summary/result layouts +- normalizing plot input per function and device +- invoking the Python renderer +- embedding generated plot paths into the markdown report + +Rust should not implement the visual rendering logic directly. + +### Python Renderer + +The Python renderer is a standalone script checked into the repository and +called by the CLI. It should: + +- read a normalized plot payload from JSON +- render one SVG figure for one benchmark function +- implement deterministic horizontal point packing +- apply a custom Matplotlib style tuned for clean static output + +This keeps the Rust side focused on artifact orchestration and keeps the +plotting side easy to iterate on visually. + +## Artifacts + +Required contract outputs remain unchanged: + +- `summary.json` +- `summary.md` +- `results.csv` + +Additive plot artifacts should live under: + +```text +target/mobench/ci/plots/ +``` + +Example outputs: + +```text +target/mobench/ci/plots/nullifier-proof-generation.svg +target/mobench/ci/plots/query-proof-generation.svg +target/mobench/ci/plots/manifest.json +``` + +`manifest.json` is optional but recommended as an implementation detail. Rust +can emit a slim plotting payload to avoid duplicating Mobench artifact parsing +logic in Python. + +## Data Flow + +1. `ci run` produces the usual result artifacts. +2. The final summarize/report step loads the summary and richer raw sample + sources already produced by earlier steps. +3. Rust groups raw samples by function and device. +4. Rust writes one normalized plot payload per function, or one shared manifest + with one entry per function. +5. Rust invokes the Python renderer once per function. +6. The renderer writes SVGs into `plots/`. +7. The markdown summary embeds the SVGs in the corresponding function section. + +The plotting feature depends on richer `benchmark_results` data when available. +If only aggregate stats exist, the function remains table-only. + +## Plot Layout + +Each figure represents a single benchmark function. + +- x-axis: device +- y-axis: runtime in milliseconds +- one dot: one iteration sample + +Each device gets a vertical strip of samples centered on the device position. +To avoid overlap, dots are packed horizontally around the device centerline. + +The report should render separate plots for multiple functions, all inside the +same summary section. + +## Point Placement Algorithm + +Use a deterministic local packing algorithm rather than KDE-based Sina jitter. + +For each device: + +1. Sort samples by y value. +2. Convert each sample to plot coordinates. +3. For the next dot, search horizontal offsets in symmetric order around zero. +4. Choose the smallest `delta_x` that keeps the new dot center at least + `epsilon` away from all previously placed dots in the same device strip. +5. Clamp the maximum allowed width so one dense device does not dominate the + figure layout. + +Where: + +- `epsilon` = dot diameter + visual gap +- search order is symmetric, for example `0, +d, -d, +2d, -2d, ...` + +This approach is: + +- deterministic +- simpler than KDE-based placement +- faithful to the raw samples +- visually centered and compact + +The implementation is best described as a Sina/beeswarm-style plot with +deterministic collision packing. + +## Visual Style + +The styling should aim for a clean, polished static look without requiring +marimo itself. + +Important note: marimo does not appear to use a special Matplotlib beautifier. +Its Matplotlib integration mainly provides a rendering backend and applies +Matplotlib's built-in `dark_background` style in dark mode. We should not add a +marimo dependency for this feature. + +Instead, ship a custom Matplotlib style layer for the renderer: + +- SVG output by default +- light theme by default +- restrained typography and spacing +- thin horizontal gridlines +- compact margins +- muted per-device colors or a single neutral dot color +- subtle median marker per device +- humanized benchmark titles +- consistent figure sizing across plots + +The style should live in a small dedicated module or `.mplstyle` file so it is +usable outside a notebook environment. + +## Markdown Embedding + +The markdown summary should keep the existing table, then add the plot directly +below it when present. + +Preferred embedding uses relative links so artifacts survive CI upload and +download together: + +```md +![nullifier-proof-generation](plots/nullifier-proof-generation.svg) +``` + +If a function has no plot, no placeholder is needed. + +## Failure Handling + +- If raw samples are missing for a function, skip that plot. +- If some devices for a function have raw samples and others do not, render the + plot with the complete devices only and emit a warning. +- In `auto` mode, missing Python or renderer failures should not fail the + overall report. +- In `require` mode, plot generation failures should fail the step. +- Output should be deterministic for stable CI diffs. + +## Testing Strategy + +### Rust + +- unit tests for extracting raw per-device per-function samples from current + result layouts +- tests for manifest generation +- tests for markdown embedding and relative path handling +- fixture-based tests covering mixed availability of raw and aggregate data + +### Python + +- unit tests for the collision-packing algorithm +- invariant tests that verify minimum distance constraints +- determinism tests using fixed input data +- smoke tests that generate SVG output for representative inputs + +### End-to-End + +- one fixture-driven summarize/report test that confirms plots are emitted and + linked into markdown when raw sample data is available + +## Rollout Notes + +- Make plots additive and optional first. +- Keep the current text/table report fully useful without plots. +- Prefer a minimal renderer dependency surface, ideally `matplotlib` and + standard library only unless a narrow extra package materially improves the + result. + +## Accepted Decisions + +- Rust CLI entry point, Python renderer underneath +- one plot per benchmark function +- separate plots embedded in the final summary section +- compare devices on one graph +- point-per-iteration visualization +- deterministic collision-based horizontal packing +- custom Matplotlib styling rather than marimo notebook dependency From dc649a6090e4f8ab10823d41f6d5ef1c399c1666 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 19:58:54 -0700 Subject: [PATCH 097/196] feat: extract plot payloads from benchmark results --- crates/mobench/src/lib.rs | 1 + crates/mobench/src/plots.rs | 340 ++++++++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 crates/mobench/src/plots.rs diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index e54514f..a08ac9a 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -139,6 +139,7 @@ use browserstack::{BrowserStackAuth, BrowserStackClient}; mod browserstack; pub mod config; mod github; +mod plots; pub(crate) mod summarize; /// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. diff --git a/crates/mobench/src/plots.rs b/crates/mobench/src/plots.rs new file mode 100644 index 0000000..62688d0 --- /dev/null +++ b/crates/mobench/src/plots.rs @@ -0,0 +1,340 @@ +use anyhow::{Context, Result}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlotFunctionInput { + pub function_name: String, + pub function_label: String, + pub target: String, + pub iterations: u32, + pub warmup: u32, + pub devices: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlotDeviceSamples { + pub device_name: String, + pub os_version: String, + pub samples_ns: Vec, +} + +#[test] +fn extract_function_plot_inputs_reads_fixture_samples() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/ci-artifact-root"); + + let plots = extract_function_plot_inputs_from_results_dir(&root) + .expect("extract plot inputs"); + + let alpha = plots + .iter() + .find(|plot| plot.function_name == "bench_alpha") + .expect("bench_alpha plot"); + + assert_eq!(alpha.devices.len(), 1); + assert_eq!(alpha.devices[0].device_name, "Google Pixel 8"); + assert_eq!( + alpha.devices[0].samples_ns, + vec![95_000_000, 98_000_000, 100_000_000, 120_000_000, 123_000_000] + ); +} + +pub fn extract_function_plot_inputs_from_results_dir(dir: &Path) -> Result> { + let root_summary_path = dir.join("summary.json"); + let mut builders = BTreeMap::new(); + + if root_summary_path.exists() { + let root_value = read_json(&root_summary_path)?; + if root_value + .get("benchmark_results") + .and_then(|value| value.as_object()) + .is_some() + { + collect_from_value(&root_value, &root_summary_path, &mut builders)?; + } else { + collect_from_results_dir(dir, &mut builders)?; + } + } else { + collect_from_results_dir(dir, &mut builders)?; + } + + let mut plots = builders + .into_values() + .map(PlotFunctionInputBuilder::finish) + .collect::>(); + plots.sort_by(|left, right| { + left.function_name + .cmp(&right.function_name) + .then(left.target.cmp(&right.target)) + }); + Ok(plots) +} + +#[derive(Debug, Default)] +struct PlotFunctionInputBuilder { + function_name: String, + function_label: String, + target: String, + iterations: u32, + warmup: u32, + devices: BTreeMap<(String, String), PlotDeviceSamplesBuilder>, +} + +#[derive(Debug, Default)] +struct PlotDeviceSamplesBuilder { + device_name: String, + os_version: String, + samples_ns: Vec, +} + +impl PlotFunctionInputBuilder { + fn new(function_name: String, target: String) -> Self { + Self { + function_label: humanize_benchmark_name(&function_name), + function_name, + target, + iterations: 0, + warmup: 0, + devices: BTreeMap::new(), + } + } + + fn set_run_metadata(&mut self, iterations: u32, warmup: u32) { + if self.iterations == 0 { + self.iterations = iterations; + } + if self.warmup == 0 { + self.warmup = warmup; + } + } + + fn add_device_samples( + &mut self, + device_name: String, + os_version: String, + mut samples_ns: Vec, + ) { + if samples_ns.is_empty() { + return; + } + + samples_ns.sort_unstable(); + + let key = (device_name.clone(), os_version.clone()); + let device = self.devices.entry(key).or_insert_with(|| PlotDeviceSamplesBuilder { + device_name, + os_version, + samples_ns: Vec::new(), + }); + device.samples_ns.extend(samples_ns); + device.samples_ns.sort_unstable(); + } + + fn finish(self) -> PlotFunctionInput { + PlotFunctionInput { + function_name: self.function_name, + function_label: self.function_label, + target: self.target, + iterations: self.iterations, + warmup: self.warmup, + devices: self + .devices + .into_values() + .map(|device| PlotDeviceSamples { + device_name: device.device_name, + os_version: device.os_version, + samples_ns: device.samples_ns, + }) + .collect(), + } + } +} + +fn collect_from_results_dir( + dir: &Path, + builders: &mut BTreeMap<(String, String), PlotFunctionInputBuilder>, +) -> Result<()> { + let mut json_paths = Vec::new(); + collect_json_files(dir, &mut json_paths)?; + json_paths.sort(); + + for path in json_paths { + let value = read_json(&path)?; + collect_from_value(&value, &path, builders)?; + } + + Ok(()) +} + +fn collect_from_value( + value: &Value, + path: &Path, + builders: &mut BTreeMap<(String, String), PlotFunctionInputBuilder>, +) -> Result<()> { + if let Some(benchmark_results) = value.get("benchmark_results").and_then(|value| value.as_object()) { + let (target, iterations, warmup) = extract_run_metadata(value, path); + + for (device_label, entries) in benchmark_results { + let Some(entries) = entries.as_array() else { + continue; + }; + let (device_name, os_version) = parse_device_string(device_label); + + for entry in entries { + let Some(function_name) = extract_function_name(entry) else { + continue; + }; + let samples_ns = extract_samples_for_plot(entry); + if samples_ns.is_empty() { + continue; + } + + let key = (target.clone(), function_name.clone()); + let builder = builders + .entry(key) + .or_insert_with(|| PlotFunctionInputBuilder::new(function_name, target.clone())); + builder.set_run_metadata(iterations, warmup); + builder.add_device_samples(device_name.clone(), os_version.clone(), samples_ns); + } + } + + return Ok(()); + } + + if value.get("spec").is_some() { + let (target, iterations, warmup) = extract_run_metadata(value, path); + let Some(function_name) = extract_function_name(value) else { + return Ok(()); + }; + let samples_ns = extract_samples_for_plot(value); + if samples_ns.is_empty() { + return Ok(()); + } + let (device_name, os_version) = value + .get("device") + .and_then(|value| value.as_str()) + .map(parse_device_string) + .unwrap_or_else(|| infer_device_from_path(path)); + + let key = (target.clone(), function_name.clone()); + let builder = builders + .entry(key) + .or_insert_with(|| PlotFunctionInputBuilder::new(function_name, target)); + builder.set_run_metadata(iterations, warmup); + builder.add_device_samples(device_name, os_version, samples_ns); + } + + Ok(()) +} + +fn extract_run_metadata(value: &Value, path: &Path) -> (String, u32, u32) { + let summary = value.get("summary").unwrap_or(value); + + let target = summary + .get("target") + .or_else(|| value.get("target")) + .and_then(|value| value.as_str()) + .map(str::to_string) + .unwrap_or_else(|| infer_target_from_path(path)); + + let iterations = summary + .get("iterations") + .and_then(|value| value.as_u64()) + .unwrap_or(0) as u32; + let warmup = summary + .get("warmup") + .and_then(|value| value.as_u64()) + .unwrap_or(0) as u32; + + (target, iterations, warmup) +} + +fn extract_function_name(value: &Value) -> Option { + value + .get("function") + .and_then(|value| value.as_str()) + .or_else(|| { + value + .get("spec") + .and_then(|spec| spec.get("name")) + .and_then(|value| value.as_str()) + }) + .map(str::to_string) +} + +fn extract_samples_for_plot(value: &Value) -> Vec { + let mut samples = crate::extract_samples(value); + if samples.is_empty() + && let Some(samples_ns) = value.get("samples_ns").and_then(|value| value.as_array()) + { + samples.extend(samples_ns.iter().filter_map(|value| value.as_u64())); + } + samples.sort_unstable(); + samples +} + +fn parse_device_string(s: &str) -> (String, String) { + match s.rsplit_once('-') { + Some((name, version)) if !name.is_empty() && !version.is_empty() => { + (name.to_string(), version.to_string()) + } + _ => (s.to_string(), "unknown".to_string()), + } +} + +fn infer_device_from_path(path: &Path) -> (String, String) { + let lower_path = path.to_string_lossy().to_ascii_lowercase(); + if lower_path.contains("/ios/") || lower_path.contains("\\ios\\") { + ("unknown".to_string(), "unknown".to_string()) + } else if lower_path.contains("/android/") || lower_path.contains("\\android\\") { + ("unknown".to_string(), "unknown".to_string()) + } else { + ("unknown".to_string(), "unknown".to_string()) + } +} + +fn infer_target_from_path(path: &Path) -> String { + let lower_path = path.to_string_lossy().to_ascii_lowercase(); + if lower_path.contains("/ios/") || lower_path.contains("\\ios\\") { + "ios".to_string() + } else if lower_path.contains("/android/") || lower_path.contains("\\android\\") { + "android".to_string() + } else { + "unknown".to_string() + } +} + +fn humanize_benchmark_name(name: &str) -> String { + let leaf = name.rsplit("::").next().unwrap_or(name); + let s = leaf.strip_prefix("bench_").unwrap_or(leaf); + s.replace('_', "-") +} + +fn collect_json_files(dir: &Path, out: &mut Vec) -> Result<()> { + let mut entries = fs::read_dir(dir) + .with_context(|| format!("Failed to read results directory {}", dir.display()))? + .collect::, _>>() + .with_context(|| format!("Failed to iterate results directory {}", dir.display()))?; + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { + let path = entry.path(); + if path.is_dir() { + collect_json_files(&path, out)?; + } else if path.extension().is_some_and(|ext| ext == "json") { + out.push(path); + } + } + + Ok(()) +} + +fn read_json(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", path.display())) +} From f64a0071169814217a59a84a81f1a61c3fe48542 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 20:01:30 -0700 Subject: [PATCH 098/196] fix: walk nested benchmark result files for plots --- crates/mobench/src/plots.rs | 128 +++++++++++++++++++++++++++++------- 1 file changed, 105 insertions(+), 23 deletions(-) diff --git a/crates/mobench/src/plots.rs b/crates/mobench/src/plots.rs index 62688d0..d2092bf 100644 --- a/crates/mobench/src/plots.rs +++ b/crates/mobench/src/plots.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use serde_json::Value; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -42,24 +42,104 @@ fn extract_function_plot_inputs_reads_fixture_samples() { ); } +#[test] +fn extract_function_plot_inputs_walks_nested_files_without_duplicates() { + let root = tempfile::tempdir().expect("tempdir"); + let root_summary = root.path().join("summary.json"); + let nested_dir = root.path().join("android").join("bench_beta"); + fs::create_dir_all(&nested_dir).expect("create nested dir"); + + write_json( + &root_summary, + serde_json::json!({ + "summary": { + "generated_at": "2026-03-25T00:00:00Z", + "generated_at_unix": 1_000_000_000_u64, + "target": "android", + "function": "bench_alpha", + "iterations": 3, + "warmup": 1, + "devices": ["Google Pixel 8-14.0"], + "device_summaries": [{ + "device": "Google Pixel 8-14.0", + "benchmarks": [{ + "function": "bench_alpha", + "samples": 3, + "mean_ns": 100_u64, + "median_ns": 100_u64, + "p95_ns": 100_u64, + "min_ns": 100_u64, + "max_ns": 100_u64 + }] + }] + }, + "benchmark_results": { + "Google Pixel 8-14.0": [{ + "function": "bench_alpha", + "samples": [100_u64, 200_u64, 300_u64] + }] + } + }), + ); + + write_json( + &nested_dir.join("summary.json"), + serde_json::json!({ + "summary": { + "generated_at": "2026-03-25T00:00:00Z", + "generated_at_unix": 1_000_000_001_u64, + "target": "android", + "function": "bench_beta", + "iterations": 3, + "warmup": 1, + "devices": ["Google Pixel 8-14.0"], + "device_summaries": [{ + "device": "Google Pixel 8-14.0", + "benchmarks": [{ + "function": "bench_beta", + "samples": 3, + "mean_ns": 400_u64, + "median_ns": 400_u64, + "p95_ns": 400_u64, + "min_ns": 400_u64, + "max_ns": 400_u64 + }] + }] + }, + "benchmark_results": { + "Google Pixel 8-14.0": [{ + "function": "bench_alpha", + "samples": [100_u64, 200_u64, 300_u64] + }, { + "function": "bench_beta", + "samples": [400_u64, 500_u64, 600_u64] + }] + } + }), + ); + + let plots = extract_function_plot_inputs_from_results_dir(root.path()) + .expect("extract plot inputs"); + + let alpha = plots + .iter() + .find(|plot| plot.function_name == "bench_alpha") + .expect("bench_alpha plot"); + let beta = plots + .iter() + .find(|plot| plot.function_name == "bench_beta") + .expect("bench_beta plot"); + + assert_eq!(alpha.devices.len(), 1); + assert_eq!(alpha.devices[0].samples_ns, vec![100, 200, 300]); + assert_eq!(beta.devices.len(), 1); + assert_eq!(beta.devices[0].samples_ns, vec![400, 500, 600]); +} + pub fn extract_function_plot_inputs_from_results_dir(dir: &Path) -> Result> { - let root_summary_path = dir.join("summary.json"); let mut builders = BTreeMap::new(); - if root_summary_path.exists() { - let root_value = read_json(&root_summary_path)?; - if root_value - .get("benchmark_results") - .and_then(|value| value.as_object()) - .is_some() - { - collect_from_value(&root_value, &root_summary_path, &mut builders)?; - } else { - collect_from_results_dir(dir, &mut builders)?; - } - } else { - collect_from_results_dir(dir, &mut builders)?; - } + collect_from_results_dir(dir, &mut builders)?; let mut plots = builders .into_values() @@ -87,7 +167,7 @@ struct PlotFunctionInputBuilder { struct PlotDeviceSamplesBuilder { device_name: String, os_version: String, - samples_ns: Vec, + samples_ns: BTreeSet, } impl PlotFunctionInputBuilder { @@ -115,22 +195,19 @@ impl PlotFunctionInputBuilder { &mut self, device_name: String, os_version: String, - mut samples_ns: Vec, + samples_ns: Vec, ) { if samples_ns.is_empty() { return; } - samples_ns.sort_unstable(); - let key = (device_name.clone(), os_version.clone()); let device = self.devices.entry(key).or_insert_with(|| PlotDeviceSamplesBuilder { device_name, os_version, - samples_ns: Vec::new(), + samples_ns: BTreeSet::new(), }); device.samples_ns.extend(samples_ns); - device.samples_ns.sort_unstable(); } fn finish(self) -> PlotFunctionInput { @@ -146,7 +223,7 @@ impl PlotFunctionInputBuilder { .map(|device| PlotDeviceSamples { device_name: device.device_name, os_version: device.os_version, - samples_ns: device.samples_ns, + samples_ns: device.samples_ns.into_iter().collect(), }) .collect(), } @@ -338,3 +415,8 @@ fn read_json(path: &Path) -> Result { serde_json::from_str(&content) .with_context(|| format!("Failed to parse {}", path.display())) } + +fn write_json(path: &Path, value: Value) { + fs::write(path, serde_json::to_vec_pretty(&value).expect("serialize json")) + .expect("write json"); +} From 81bd1e580accbaa69779e82a4a15a7a32502ed6a Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 20:03:41 -0700 Subject: [PATCH 099/196] fix: preserve duplicate plot samples --- crates/mobench/src/plots.rs | 123 +++++++++++++++++++++++++++++++++--- 1 file changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/mobench/src/plots.rs b/crates/mobench/src/plots.rs index d2092bf..febc4fe 100644 --- a/crates/mobench/src/plots.rs +++ b/crates/mobench/src/plots.rs @@ -136,6 +136,93 @@ fn extract_function_plot_inputs_walks_nested_files_without_duplicates() { assert_eq!(beta.devices[0].samples_ns, vec![400, 500, 600]); } +#[test] +fn extract_function_plot_inputs_preserves_duplicate_samples_from_a_single_payload() { + let root = tempfile::tempdir().expect("tempdir"); + let root_summary = root.path().join("summary.json"); + let nested_dir = root.path().join("android").join("bench_alpha"); + fs::create_dir_all(&nested_dir).expect("create nested dir"); + + let payload = serde_json::json!({ + "function": "bench_alpha", + "samples": [100_u64, 100_u64, 200_u64] + }); + + write_json( + &root_summary, + serde_json::json!({ + "summary": { + "generated_at": "2026-03-25T00:00:00Z", + "generated_at_unix": 1_000_000_002_u64, + "target": "android", + "function": "bench_alpha", + "iterations": 3, + "warmup": 1, + "devices": ["Google Pixel 8-14.0"], + "device_summaries": [{ + "device": "Google Pixel 8-14.0", + "benchmarks": [{ + "function": "bench_alpha", + "samples": 3, + "mean_ns": 133_u64, + "median_ns": 100_u64, + "p95_ns": 200_u64, + "min_ns": 100_u64, + "max_ns": 200_u64 + }] + }] + }, + "benchmark_results": { + "Google Pixel 8-14.0": [payload] + } + }), + ); + + write_json( + &nested_dir.join("summary.json"), + serde_json::json!({ + "summary": { + "generated_at": "2026-03-25T00:00:00Z", + "generated_at_unix": 1_000_000_003_u64, + "target": "android", + "function": "bench_alpha", + "iterations": 3, + "warmup": 1, + "devices": ["Google Pixel 8-14.0"], + "device_summaries": [{ + "device": "Google Pixel 8-14.0", + "benchmarks": [{ + "function": "bench_alpha", + "samples": 3, + "mean_ns": 133_u64, + "median_ns": 100_u64, + "p95_ns": 200_u64, + "min_ns": 100_u64, + "max_ns": 200_u64 + }] + }] + }, + "benchmark_results": { + "Google Pixel 8-14.0": [payload] + } + }), + ); + + let plots = extract_function_plot_inputs_from_results_dir(root.path()) + .expect("extract plot inputs"); + + let alpha = plots + .iter() + .find(|plot| plot.function_name == "bench_alpha") + .expect("bench_alpha plot"); + + assert_eq!(alpha.devices.len(), 1); + assert_eq!( + alpha.devices[0].samples_ns, + vec![100, 100, 200] + ); +} + pub fn extract_function_plot_inputs_from_results_dir(dir: &Path) -> Result> { let mut builders = BTreeMap::new(); @@ -167,7 +254,8 @@ struct PlotFunctionInputBuilder { struct PlotDeviceSamplesBuilder { device_name: String, os_version: String, - samples_ns: BTreeSet, + samples_ns: Vec, + seen_payloads: BTreeSet, } impl PlotFunctionInputBuilder { @@ -195,6 +283,7 @@ impl PlotFunctionInputBuilder { &mut self, device_name: String, os_version: String, + source_signature: String, samples_ns: Vec, ) { if samples_ns.is_empty() { @@ -205,8 +294,12 @@ impl PlotFunctionInputBuilder { let device = self.devices.entry(key).or_insert_with(|| PlotDeviceSamplesBuilder { device_name, os_version, - samples_ns: BTreeSet::new(), + samples_ns: Vec::new(), + seen_payloads: BTreeSet::new(), }); + if !device.seen_payloads.insert(source_signature) { + return; + } device.samples_ns.extend(samples_ns); } @@ -220,10 +313,13 @@ impl PlotFunctionInputBuilder { devices: self .devices .into_values() - .map(|device| PlotDeviceSamples { - device_name: device.device_name, - os_version: device.os_version, - samples_ns: device.samples_ns.into_iter().collect(), + .map(|mut device| { + device.samples_ns.sort_unstable(); + PlotDeviceSamples { + device_name: device.device_name, + os_version: device.os_version, + samples_ns: device.samples_ns, + } }) .collect(), } @@ -268,13 +364,19 @@ fn collect_from_value( if samples_ns.is_empty() { continue; } + let source_signature = source_payload_signature(entry); let key = (target.clone(), function_name.clone()); let builder = builders .entry(key) .or_insert_with(|| PlotFunctionInputBuilder::new(function_name, target.clone())); builder.set_run_metadata(iterations, warmup); - builder.add_device_samples(device_name.clone(), os_version.clone(), samples_ns); + builder.add_device_samples( + device_name.clone(), + os_version.clone(), + source_signature, + samples_ns, + ); } } @@ -290,6 +392,7 @@ fn collect_from_value( if samples_ns.is_empty() { return Ok(()); } + let source_signature = source_payload_signature(value); let (device_name, os_version) = value .get("device") .and_then(|value| value.as_str()) @@ -301,7 +404,7 @@ fn collect_from_value( .entry(key) .or_insert_with(|| PlotFunctionInputBuilder::new(function_name, target)); builder.set_run_metadata(iterations, warmup); - builder.add_device_samples(device_name, os_version, samples_ns); + builder.add_device_samples(device_name, os_version, source_signature, samples_ns); } Ok(()) @@ -420,3 +523,7 @@ fn write_json(path: &Path, value: Value) { fs::write(path, serde_json::to_vec_pretty(&value).expect("serialize json")) .expect("write json"); } + +fn source_payload_signature(value: &Value) -> String { + serde_json::to_string(value).expect("serialize source payload") +} From 6c72283d2dd11b5247fd377ddab0e1232265732d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 20:09:34 -0700 Subject: [PATCH 100/196] fix: preserve non-root duplicate plot runs --- crates/mobench/src/plots.rs | 151 +++++++++++++++++++++++++++--------- 1 file changed, 115 insertions(+), 36 deletions(-) diff --git a/crates/mobench/src/plots.rs b/crates/mobench/src/plots.rs index febc4fe..34f0f6e 100644 --- a/crates/mobench/src/plots.rs +++ b/crates/mobench/src/plots.rs @@ -137,17 +137,12 @@ fn extract_function_plot_inputs_walks_nested_files_without_duplicates() { } #[test] -fn extract_function_plot_inputs_preserves_duplicate_samples_from_a_single_payload() { +fn extract_function_plot_inputs_preserves_duplicate_runs_from_non_root_files() { let root = tempfile::tempdir().expect("tempdir"); let root_summary = root.path().join("summary.json"); let nested_dir = root.path().join("android").join("bench_alpha"); fs::create_dir_all(&nested_dir).expect("create nested dir"); - let payload = serde_json::json!({ - "function": "bench_alpha", - "samples": [100_u64, 100_u64, 200_u64] - }); - write_json( &root_summary, serde_json::json!({ @@ -155,25 +150,28 @@ fn extract_function_plot_inputs_preserves_duplicate_samples_from_a_single_payloa "generated_at": "2026-03-25T00:00:00Z", "generated_at_unix": 1_000_000_002_u64, "target": "android", - "function": "bench_alpha", + "function": "bench_beta", "iterations": 3, "warmup": 1, "devices": ["Google Pixel 8-14.0"], "device_summaries": [{ "device": "Google Pixel 8-14.0", "benchmarks": [{ - "function": "bench_alpha", + "function": "bench_beta", "samples": 3, - "mean_ns": 133_u64, - "median_ns": 100_u64, - "p95_ns": 200_u64, - "min_ns": 100_u64, - "max_ns": 200_u64 + "mean_ns": 333_u64, + "median_ns": 300_u64, + "p95_ns": 400_u64, + "min_ns": 300_u64, + "max_ns": 400_u64 }] }] }, "benchmark_results": { - "Google Pixel 8-14.0": [payload] + "Google Pixel 8-14.0": [{ + "function": "bench_beta", + "samples": [300_u64, 300_u64, 400_u64] + }] } }), ); @@ -203,7 +201,16 @@ fn extract_function_plot_inputs_preserves_duplicate_samples_from_a_single_payloa }] }, "benchmark_results": { - "Google Pixel 8-14.0": [payload] + "Google Pixel 8-14.0": [ + { + "function": "bench_alpha", + "samples": [100_u64, 100_u64, 200_u64] + }, + { + "function": "bench_alpha", + "samples": [100_u64, 100_u64, 200_u64] + } + ] } }), ); @@ -215,18 +222,24 @@ fn extract_function_plot_inputs_preserves_duplicate_samples_from_a_single_payloa .iter() .find(|plot| plot.function_name == "bench_alpha") .expect("bench_alpha plot"); + let beta = plots + .iter() + .find(|plot| plot.function_name == "bench_beta") + .expect("bench_beta plot"); assert_eq!(alpha.devices.len(), 1); assert_eq!( alpha.devices[0].samples_ns, - vec![100, 100, 200] + vec![100, 100, 100, 100, 200, 200] ); + assert_eq!(beta.devices[0].samples_ns, vec![300, 300, 400]); } pub fn extract_function_plot_inputs_from_results_dir(dir: &Path) -> Result> { let mut builders = BTreeMap::new(); + let mut nested_source_keys = BTreeSet::new(); - collect_from_results_dir(dir, &mut builders)?; + collect_from_results_dir(dir, &mut builders, &mut nested_source_keys)?; let mut plots = builders .into_values() @@ -255,7 +268,6 @@ struct PlotDeviceSamplesBuilder { device_name: String, os_version: String, samples_ns: Vec, - seen_payloads: BTreeSet, } impl PlotFunctionInputBuilder { @@ -283,7 +295,6 @@ impl PlotFunctionInputBuilder { &mut self, device_name: String, os_version: String, - source_signature: String, samples_ns: Vec, ) { if samples_ns.is_empty() { @@ -295,11 +306,7 @@ impl PlotFunctionInputBuilder { device_name, os_version, samples_ns: Vec::new(), - seen_payloads: BTreeSet::new(), }); - if !device.seen_payloads.insert(source_signature) { - return; - } device.samples_ns.extend(samples_ns); } @@ -329,14 +336,31 @@ impl PlotFunctionInputBuilder { fn collect_from_results_dir( dir: &Path, builders: &mut BTreeMap<(String, String), PlotFunctionInputBuilder>, + nested_source_keys: &mut BTreeSet, ) -> Result<()> { let mut json_paths = Vec::new(); collect_json_files(dir, &mut json_paths)?; json_paths.sort(); + let root_summary_path = dir.join("summary.json"); + let mut nested_paths = Vec::new(); + let mut root_paths = Vec::new(); for path in json_paths { + if path == root_summary_path { + root_paths.push(path); + } else { + nested_paths.push(path); + } + } + + for path in nested_paths { let value = read_json(&path)?; - collect_from_value(&value, &path, builders)?; + collect_from_value(&value, &path, false, builders, nested_source_keys)?; + } + + for path in root_paths { + let value = read_json(&path)?; + collect_from_value(&value, &path, true, builders, nested_source_keys)?; } Ok(()) @@ -345,7 +369,9 @@ fn collect_from_results_dir( fn collect_from_value( value: &Value, path: &Path, + is_root_summary: bool, builders: &mut BTreeMap<(String, String), PlotFunctionInputBuilder>, + nested_source_keys: &mut BTreeSet, ) -> Result<()> { if let Some(benchmark_results) = value.get("benchmark_results").and_then(|value| value.as_object()) { let (target, iterations, warmup) = extract_run_metadata(value, path); @@ -354,7 +380,6 @@ fn collect_from_value( let Some(entries) = entries.as_array() else { continue; }; - let (device_name, os_version) = parse_device_string(device_label); for entry in entries { let Some(function_name) = extract_function_name(entry) else { @@ -364,19 +389,29 @@ fn collect_from_value( if samples_ns.is_empty() { continue; } - let source_signature = source_payload_signature(entry); + let (device_name, os_version) = parse_device_string(device_label); + let entry_key = PlotEntryKey::new( + &target, + &function_name, + &device_name, + &os_version, + iterations, + warmup, + &samples_ns, + ); + if is_root_summary && nested_source_keys.contains(&entry_key) { + continue; + } + if !is_root_summary { + nested_source_keys.insert(entry_key); + } let key = (target.clone(), function_name.clone()); let builder = builders .entry(key) .or_insert_with(|| PlotFunctionInputBuilder::new(function_name, target.clone())); builder.set_run_metadata(iterations, warmup); - builder.add_device_samples( - device_name.clone(), - os_version.clone(), - source_signature, - samples_ns, - ); + builder.add_device_samples(device_name, os_version, samples_ns); } } @@ -392,19 +427,33 @@ fn collect_from_value( if samples_ns.is_empty() { return Ok(()); } - let source_signature = source_payload_signature(value); let (device_name, os_version) = value .get("device") .and_then(|value| value.as_str()) .map(parse_device_string) .unwrap_or_else(|| infer_device_from_path(path)); + let entry_key = PlotEntryKey::new( + &target, + &function_name, + &device_name, + &os_version, + iterations, + warmup, + &samples_ns, + ); + if is_root_summary && nested_source_keys.contains(&entry_key) { + return Ok(()); + } + if !is_root_summary { + nested_source_keys.insert(entry_key); + } let key = (target.clone(), function_name.clone()); let builder = builders .entry(key) .or_insert_with(|| PlotFunctionInputBuilder::new(function_name, target)); builder.set_run_metadata(iterations, warmup); - builder.add_device_samples(device_name, os_version, source_signature, samples_ns); + builder.add_device_samples(device_name, os_version, samples_ns); } Ok(()) @@ -519,11 +568,41 @@ fn read_json(path: &Path) -> Result { .with_context(|| format!("Failed to parse {}", path.display())) } +#[cfg(test)] fn write_json(path: &Path, value: Value) { fs::write(path, serde_json::to_vec_pretty(&value).expect("serialize json")) .expect("write json"); } -fn source_payload_signature(value: &Value) -> String { - serde_json::to_string(value).expect("serialize source payload") +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct PlotEntryKey { + target: String, + function_name: String, + device_name: String, + os_version: String, + iterations: u32, + warmup: u32, + samples_ns: Vec, +} + +impl PlotEntryKey { + fn new( + target: &str, + function_name: &str, + device_name: &str, + os_version: &str, + iterations: u32, + warmup: u32, + samples_ns: &[u64], + ) -> Self { + Self { + target: target.to_string(), + function_name: function_name.to_string(), + device_name: device_name.to_string(), + os_version: os_version.to_string(), + iterations, + warmup, + samples_ns: samples_ns.to_vec(), + } + } } From ecddfc3f926bde54d61c8e1493de6b226c0a9027 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 20:12:50 -0700 Subject: [PATCH 101/196] feat: add python renderer for sina-style plots --- crates/mobench/python/mobench_light.mplstyle | 26 +++ crates/mobench/python/render_sina_plot.py | 202 ++++++++++++++++++ .../python/tests/test_render_sina_plot.py | 33 +++ 3 files changed, 261 insertions(+) create mode 100644 crates/mobench/python/mobench_light.mplstyle create mode 100644 crates/mobench/python/render_sina_plot.py create mode 100644 crates/mobench/python/tests/test_render_sina_plot.py diff --git a/crates/mobench/python/mobench_light.mplstyle b/crates/mobench/python/mobench_light.mplstyle new file mode 100644 index 0000000..4ef5c0b --- /dev/null +++ b/crates/mobench/python/mobench_light.mplstyle @@ -0,0 +1,26 @@ +figure.facecolor: white +axes.facecolor: white +axes.edgecolor: #2a2f3a +axes.labelcolor: #2a2f3a +axes.titlecolor: #111827 +axes.grid: True +axes.axisbelow: True +axes.spines.top: False +axes.spines.right: False +grid.color: #d6d9e0 +grid.linewidth: 0.7 +grid.linestyle: - +text.color: #111827 +xtick.color: #374151 +ytick.color: #374151 +font.family: DejaVu Sans +font.size: 11 +axes.titlesize: 14 +axes.labelsize: 11 +legend.frameon: False +lines.linewidth: 1.4 +savefig.format: svg +savefig.bbox: tight +savefig.transparent: false +figure.dpi: 144 +path.simplify: true diff --git a/crates/mobench/python/render_sina_plot.py b/crates/mobench/python/render_sina_plot.py new file mode 100644 index 0000000..d9b6fc5 --- /dev/null +++ b/crates/mobench/python/render_sina_plot.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +from typing import Iterable, Sequence + + +def pack_strip( + ys: Sequence[float], + epsilon: float, + step: float, + max_width: float, +) -> list[float]: + """Pack a vertical strip of points with deterministic horizontal offsets. + + The input order is preserved. The offsets are centered at the end so the + strip stays visually balanced around x = 0. + """ + + placed: list[tuple[float, float]] = [] + + for y in ys: + offset = _find_offset_for_point(y, placed, epsilon, step, max_width) + placed.append((offset, y)) + + if not placed: + return [] + + mean_x = sum(x for x, _ in placed) / len(placed) + return [x - mean_x for x, _ in placed] + + +def _find_offset_for_point( + y: float, + placed: Sequence[tuple[float, float]], + epsilon: float, + step: float, + max_width: float, +) -> float: + max_ring = max(1, math.ceil(max_width / step)) + best_dx = 0.0 + best_score = float("-inf") + + ring = 0 + while ring <= max_ring: + candidates = [0.0] if ring == 0 else [ring * step, -ring * step] + for dx in candidates: + if abs(dx) > max_width: + continue + if not placed: + return dx + + score = min((dx - ox) ** 2 + (y - oy) ** 2 for ox, oy in placed) + if score >= epsilon**2: + return dx + if score > best_score: + best_score = score + best_dx = dx + ring += 1 + + return best_dx + + +def render_plot(spec: dict[str, object], output_path: Path) -> None: + matplotlib = _import_matplotlib() + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + style_path = Path(__file__).with_name("mobench_light.mplstyle") + plt.style.use(str(style_path)) + + plot = _normalize_plot_spec(spec) + devices = plot["devices"] + if not devices: + raise ValueError("plot spec must contain at least one device") + + all_samples_ms = [ + sample / 1_000_000.0 + for device in devices + for sample in device["samples_ns"] + ] + if not all_samples_ms: + raise ValueError("plot spec must contain at least one sample") + + y_min = min(all_samples_ms) + y_max = max(all_samples_ms) + y_span = max(y_max - y_min, 1e-9) + + fig_width = max(6.0, 1.2 * len(devices) + 1.6) + fig, ax = plt.subplots(figsize=(fig_width, 4.8)) + colors = plt.get_cmap("tab10") + + for idx, device in enumerate(devices): + samples_ms = [sample / 1_000_000.0 for sample in device["samples_ns"]] + normalized = [(sample - y_min) / y_span for sample in samples_ms] + offsets = pack_strip( + normalized, + epsilon=0.08, + step=0.02, + max_width=0.28, + ) + x_positions = [idx + dx for dx in offsets] + color = colors(idx % 10) + + ax.scatter( + x_positions, + samples_ms, + s=18, + color=color, + alpha=0.8, + edgecolors="none", + rasterized=False, + ) + + median_ms = _median(samples_ms) + ax.hlines( + median_ms, + idx - 0.22, + idx + 0.22, + color=color, + linewidth=1.4, + alpha=0.85, + ) + + title = plot["function_label"] + target = plot["target"] + if target: + title = f"{title} on {target}" + ax.set_title(title) + ax.set_xlabel("Device") + ax.set_ylabel("Runtime (ms)") + ax.set_xticks(range(len(devices))) + ax.set_xticklabels( + [ + _format_device_label(device["device_name"], device["os_version"]) + for device in devices + ], + rotation=20, + ha="right", + ) + ax.set_xlim(-0.6, len(devices) - 0.4) + ax.margins(x=0.02, y=0.08) + + # Keep the y-axis tight to the data while leaving room for the points. + pad = max((y_max - y_min) * 0.08, 0.03) + ax.set_ylim(y_min - pad, y_max + pad) + + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output_path, format="svg", bbox_inches="tight") + plt.close(fig) + + +def _normalize_plot_spec(spec: dict[str, object]) -> dict[str, object]: + if "function_name" in spec: + return spec + if "plot" in spec and isinstance(spec["plot"], dict): + return spec["plot"] + raise ValueError("expected a single plot specification") + + +def _format_device_label(device_name: str, os_version: str) -> str: + return device_name if not os_version else f"{device_name} {os_version}" + + +def _median(values: Sequence[float]) -> float: + ordered = sorted(values) + n = len(ordered) + mid = n // 2 + if n % 2 == 1: + return ordered[mid] + return (ordered[mid - 1] + ordered[mid]) / 2.0 + + +def _import_matplotlib(): + import matplotlib + + return matplotlib + + +def _load_json(path: Path) -> dict[str, object]: + with path.open("r", encoding="utf-8") as f: + payload = json.load(f) + if not isinstance(payload, dict): + raise ValueError("plot input must be a JSON object") + return payload + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Render a sine-style device comparison plot") + parser.add_argument("--input", required=True, help="Path to normalized plot JSON") + parser.add_argument("--output", required=True, help="Path to write the SVG plot") + args = parser.parse_args(list(argv) if argv is not None else None) + + spec = _load_json(Path(args.input)) + render_plot(spec, Path(args.output)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/crates/mobench/python/tests/test_render_sina_plot.py b/crates/mobench/python/tests/test_render_sina_plot.py new file mode 100644 index 0000000..b646346 --- /dev/null +++ b/crates/mobench/python/tests/test_render_sina_plot.py @@ -0,0 +1,33 @@ +import importlib.util +import math +import pathlib +import unittest + + +def load_module(): + path = pathlib.Path(__file__).resolve().parents[1] / "render_sina_plot.py" + spec = importlib.util.spec_from_file_location("render_sina_plot", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class PackStripTests(unittest.TestCase): + def test_pack_strip_is_deterministic_and_centered(self): + mod = load_module() + xs = mod.pack_strip([10.0, 10.0, 10.0, 10.2], epsilon=0.12, step=0.02, max_width=0.4) + self.assertEqual( + xs, + mod.pack_strip([10.0, 10.0, 10.0, 10.2], epsilon=0.12, step=0.02, max_width=0.4), + ) + self.assertAlmostEqual(sum(xs), 0.0, places=6) + + def test_pack_strip_respects_minimum_distance(self): + mod = load_module() + ys = [1.0, 1.0, 1.05, 1.07, 1.10] + xs = mod.pack_strip(ys, epsilon=0.10, step=0.01, max_width=0.4) + for i, (x1, y1) in enumerate(zip(xs, ys)): + for x2, y2 in zip(xs[i + 1 :], ys[i + 1 :]): + self.assertGreaterEqual(math.hypot(x2 - x1, y2 - y1), 0.10 - 1e-9) + From 8941b771912bb08740507c4cf5f7b9ba97bd6417 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 20:20:11 -0700 Subject: [PATCH 102/196] fix: harden sina plot packing validation --- crates/mobench/python/render_sina_plot.py | 35 ++++++---- .../python/tests/test_render_sina_plot.py | 69 +++++++++++++++++++ 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/crates/mobench/python/render_sina_plot.py b/crates/mobench/python/render_sina_plot.py index d9b6fc5..e16564e 100644 --- a/crates/mobench/python/render_sina_plot.py +++ b/crates/mobench/python/render_sina_plot.py @@ -39,9 +39,12 @@ def _find_offset_for_point( step: float, max_width: float, ) -> float: + if step <= 0: + raise ValueError("step must be positive") + if max_width < 0: + raise ValueError("max_width must be non-negative") + max_ring = max(1, math.ceil(max_width / step)) - best_dx = 0.0 - best_score = float("-inf") ring = 0 while ring <= max_ring: @@ -52,18 +55,17 @@ def _find_offset_for_point( if not placed: return dx - score = min((dx - ox) ** 2 + (y - oy) ** 2 for ox, oy in placed) - if score >= epsilon**2: + if all((dx - ox) ** 2 + (y - oy) ** 2 >= epsilon**2 for ox, oy in placed): return dx - if score > best_score: - best_score = score - best_dx = dx ring += 1 - return best_dx + raise ValueError("unable to place point within max_width while preserving epsilon") def render_plot(spec: dict[str, object], output_path: Path) -> None: + plot = _normalize_plot_spec(spec) + _validate_plot_spec(plot) + matplotlib = _import_matplotlib() matplotlib.use("Agg") import matplotlib.pyplot as plt @@ -71,11 +73,7 @@ def render_plot(spec: dict[str, object], output_path: Path) -> None: style_path = Path(__file__).with_name("mobench_light.mplstyle") plt.style.use(str(style_path)) - plot = _normalize_plot_spec(spec) devices = plot["devices"] - if not devices: - raise ValueError("plot spec must contain at least one device") - all_samples_ms = [ sample / 1_000_000.0 for device in devices @@ -160,6 +158,17 @@ def _normalize_plot_spec(spec: dict[str, object]) -> dict[str, object]: raise ValueError("expected a single plot specification") +def _validate_plot_spec(plot: dict[str, object]) -> None: + devices = plot.get("devices") + if not isinstance(devices, list) or not devices: + raise ValueError("plot spec must contain at least one device") + + for device in devices: + samples_ns = device.get("samples_ns") if isinstance(device, dict) else None + if not isinstance(samples_ns, list) or not samples_ns: + raise ValueError("each device must contain at least one sample") + + def _format_device_label(device_name: str, os_version: str) -> str: return device_name if not os_version else f"{device_name} {os_version}" @@ -188,7 +197,7 @@ def _load_json(path: Path) -> dict[str, object]: def main(argv: Iterable[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Render a sine-style device comparison plot") + parser = argparse.ArgumentParser(description="Render a sina-style device comparison plot") parser.add_argument("--input", required=True, help="Path to normalized plot JSON") parser.add_argument("--output", required=True, help="Path to write the SVG plot") args = parser.parse_args(list(argv) if argv is not None else None) diff --git a/crates/mobench/python/tests/test_render_sina_plot.py b/crates/mobench/python/tests/test_render_sina_plot.py index b646346..cd7c0bf 100644 --- a/crates/mobench/python/tests/test_render_sina_plot.py +++ b/crates/mobench/python/tests/test_render_sina_plot.py @@ -1,7 +1,10 @@ import importlib.util +import json import math import pathlib +import tempfile import unittest +from unittest import mock def load_module(): @@ -31,3 +34,69 @@ def test_pack_strip_respects_minimum_distance(self): for x2, y2 in zip(xs[i + 1 :], ys[i + 1 :]): self.assertGreaterEqual(math.hypot(x2 - x1, y2 - y1), 0.10 - 1e-9) + def test_pack_strip_raises_when_strip_is_saturated(self): + mod = load_module() + with self.assertRaises(ValueError): + mod.pack_strip([0.0, 0.0], epsilon=0.20, step=0.05, max_width=0.10) + + +class CliTests(unittest.TestCase): + def test_main_dispatches_input_json_to_renderer(self): + mod = load_module() + payload = { + "function_name": "nullifier-proof-generation", + "function_label": "Nullifier proof generation", + "target": "benchmark-1", + "devices": [ + { + "device_name": "iPhone 15", + "os_version": "iOS 17.4", + "samples_ns": [10_000_000, 11_000_000], + } + ], + } + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = pathlib.Path(tmpdir) + input_path = tmpdir_path / "input.json" + output_path = tmpdir_path / "output.svg" + input_path.write_text(json.dumps(payload), encoding="utf-8") + + with mock.patch.object(mod, "render_plot") as render_plot: + exit_code = mod.main([ + "--input", + str(input_path), + "--output", + str(output_path), + ]) + + self.assertEqual(exit_code, 0) + render_plot.assert_called_once() + called_spec, called_output = render_plot.call_args.args + self.assertEqual(called_spec, payload) + self.assertEqual(called_output, output_path) + + +class ValidationTests(unittest.TestCase): + def test_render_plot_rejects_device_with_empty_samples(self): + mod = load_module() + spec = { + "function_name": "nullifier-proof-generation", + "function_label": "Nullifier proof generation", + "target": "benchmark-1", + "devices": [ + { + "device_name": "iPhone 15", + "os_version": "iOS 17.4", + "samples_ns": [10_000_000, 11_000_000], + }, + { + "device_name": "Pixel 8", + "os_version": "Android 15", + "samples_ns": [], + }, + ], + } + + with self.assertRaises(ValueError): + mod.render_plot(spec, pathlib.Path("/tmp/out.svg")) From 2581b2333d37b13422acedf066570de00ea943cd Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 20:25:27 -0700 Subject: [PATCH 103/196] fix: adapt sina plot layout for dense columns --- crates/mobench/python/mobench_light.mplstyle | 14 +- crates/mobench/python/render_sina_plot.py | 79 +++++++-- .../python/tests/test_render_sina_plot.py | 151 ++++++++++++++++++ 3 files changed, 224 insertions(+), 20 deletions(-) diff --git a/crates/mobench/python/mobench_light.mplstyle b/crates/mobench/python/mobench_light.mplstyle index 4ef5c0b..b9b0263 100644 --- a/crates/mobench/python/mobench_light.mplstyle +++ b/crates/mobench/python/mobench_light.mplstyle @@ -1,18 +1,18 @@ figure.facecolor: white axes.facecolor: white -axes.edgecolor: #2a2f3a -axes.labelcolor: #2a2f3a -axes.titlecolor: #111827 +axes.edgecolor: 0.1647, 0.1843, 0.2275 +axes.labelcolor: 0.1647, 0.1843, 0.2275 +axes.titlecolor: 0.0667, 0.0941, 0.1529 axes.grid: True axes.axisbelow: True axes.spines.top: False axes.spines.right: False -grid.color: #d6d9e0 +grid.color: 0.8392, 0.8510, 0.8784 grid.linewidth: 0.7 grid.linestyle: - -text.color: #111827 -xtick.color: #374151 -ytick.color: #374151 +text.color: 0.0667, 0.0941, 0.1529 +xtick.color: 0.2157, 0.2549, 0.3176 +ytick.color: 0.2157, 0.2549, 0.3176 font.family: DejaVu Sans font.size: 11 axes.titlesize: 14 diff --git a/crates/mobench/python/render_sina_plot.py b/crates/mobench/python/render_sina_plot.py index e16564e..0472223 100644 --- a/crates/mobench/python/render_sina_plot.py +++ b/crates/mobench/python/render_sina_plot.py @@ -6,6 +6,11 @@ from pathlib import Path from typing import Iterable, Sequence +_PACK_EPSILON = 0.08 +_PACK_STEP = 0.02 +_BASE_HALF_WIDTH = 0.28 +_DEVICE_GAP = 0.45 + def pack_strip( ys: Sequence[float], @@ -86,20 +91,36 @@ def render_plot(spec: dict[str, object], output_path: Path) -> None: y_max = max(all_samples_ms) y_span = max(y_max - y_min, 1e-9) - fig_width = max(6.0, 1.2 * len(devices) + 1.6) - fig, ax = plt.subplots(figsize=(fig_width, 4.8)) - colors = plt.get_cmap("tab10") - - for idx, device in enumerate(devices): + packed_devices = [] + for device in devices: samples_ms = [sample / 1_000_000.0 for sample in device["samples_ns"]] normalized = [(sample - y_min) / y_span for sample in samples_ms] - offsets = pack_strip( - normalized, - epsilon=0.08, - step=0.02, - max_width=0.28, + offsets = _pack_device_offsets(normalized) + half_width = max((abs(dx) for dx in offsets), default=0.0) + packed_devices.append( + { + "device": device, + "samples_ms": samples_ms, + "offsets": offsets, + "half_width": half_width, + } ) - x_positions = [idx + dx for dx in offsets] + + half_widths = [entry["half_width"] for entry in packed_devices] + centers = _compute_device_centers(half_widths) + leftmost = min(center - half_width for center, half_width in zip(centers, half_widths)) + rightmost = max(center + half_width for center, half_width in zip(centers, half_widths)) + total_span = max(rightmost - leftmost, 1.0) + + fig_width = max(6.0, total_span * 1.8) + fig, ax = plt.subplots(figsize=(fig_width, 4.8)) + colors = plt.get_cmap("tab10") + + for idx, (center, packed) in enumerate(zip(centers, packed_devices)): + device = packed["device"] + samples_ms = packed["samples_ms"] + offsets = packed["offsets"] + x_positions = [center + dx for dx in offsets] color = colors(idx % 10) ax.scatter( @@ -129,7 +150,7 @@ def render_plot(spec: dict[str, object], output_path: Path) -> None: ax.set_title(title) ax.set_xlabel("Device") ax.set_ylabel("Runtime (ms)") - ax.set_xticks(range(len(devices))) + ax.set_xticks(centers) ax.set_xticklabels( [ _format_device_label(device["device_name"], device["os_version"]) @@ -138,7 +159,8 @@ def render_plot(spec: dict[str, object], output_path: Path) -> None: rotation=20, ha="right", ) - ax.set_xlim(-0.6, len(devices) - 0.4) + span_margin = max(total_span * 0.03, 0.18) + ax.set_xlim(leftmost - span_margin, rightmost + span_margin) ax.margins(x=0.02, y=0.08) # Keep the y-axis tight to the data while leaving room for the points. @@ -169,6 +191,37 @@ def _validate_plot_spec(plot: dict[str, object]) -> None: raise ValueError("each device must contain at least one sample") +def _pack_device_offsets(normalized_samples: Sequence[float]) -> list[float]: + max_width = _BASE_HALF_WIDTH + while True: + try: + return pack_strip( + normalized_samples, + epsilon=_PACK_EPSILON, + step=_PACK_STEP, + max_width=max_width, + ) + except ValueError: + max_width *= 2 + + +def _compute_device_centers(half_widths: Sequence[float]) -> list[float]: + if not half_widths: + return [] + + centers: list[float] = [] + cursor = 0.0 + for half_width in half_widths: + if centers: + cursor += _DEVICE_GAP + center = cursor + half_width + centers.append(center) + cursor = center + half_width + + shift = (centers[0] - half_widths[0] + centers[-1] + half_widths[-1]) / 2.0 + return [center - shift for center in centers] + + def _format_device_label(device_name: str, os_version: str) -> str: return device_name if not os_version else f"{device_name} {os_version}" diff --git a/crates/mobench/python/tests/test_render_sina_plot.py b/crates/mobench/python/tests/test_render_sina_plot.py index cd7c0bf..701a62f 100644 --- a/crates/mobench/python/tests/test_render_sina_plot.py +++ b/crates/mobench/python/tests/test_render_sina_plot.py @@ -2,7 +2,9 @@ import json import math import pathlib +import sys import tempfile +import types import unittest from unittest import mock @@ -77,6 +79,89 @@ def test_main_dispatches_input_json_to_renderer(self): self.assertEqual(called_output, output_path) +class FakeAxes: + def __init__(self): + self.scatter_calls = [] + self.hline_calls = [] + self.title = None + self.xlabel = None + self.ylabel = None + self.xticks = None + self.xticklabels = None + self.xlim = None + self.margins_args = None + self.ylim = None + + def scatter(self, x, y, **kwargs): + self.scatter_calls.append((list(x), list(y), kwargs)) + + def hlines(self, y, xmin, xmax, **kwargs): + self.hline_calls.append((y, xmin, xmax, kwargs)) + + def set_title(self, value): + self.title = value + + def set_xlabel(self, value): + self.xlabel = value + + def set_ylabel(self, value): + self.ylabel = value + + def set_xticks(self, values): + self.xticks = list(values) + + def set_xticklabels(self, values, **kwargs): + self.xticklabels = list(values) + + def set_xlim(self, left, right): + self.xlim = (left, right) + + def margins(self, **kwargs): + self.margins_args = kwargs + + def set_ylim(self, bottom, top): + self.ylim = (bottom, top) + + +class FakeFigure: + def __init__(self): + self.saved = None + + def savefig(self, path, **kwargs): + self.saved = (path, kwargs) + + +class FakeStyle: + def __init__(self): + self.paths = [] + + def use(self, path): + self.paths.append(path) + + +class FakePyplot(types.SimpleNamespace): + def __init__(self): + super().__init__() + self.style = FakeStyle() + self.figure = FakeFigure() + self.axes = FakeAxes() + + def subplots(self, figsize=None): + self.figsize = figsize + return self.figure, self.axes + + def get_cmap(self, name): + self.cmap_name = name + + def color(idx): + return f"color-{idx}" + + return color + + def close(self, fig): + self.closed = fig + + class ValidationTests(unittest.TestCase): def test_render_plot_rejects_device_with_empty_samples(self): mod = load_module() @@ -100,3 +185,69 @@ def test_render_plot_rejects_device_with_empty_samples(self): with self.assertRaises(ValueError): mod.render_plot(spec, pathlib.Path("/tmp/out.svg")) + + +class LayoutTests(unittest.TestCase): + def test_render_plot_expands_dense_columns_without_overlap(self): + mod = load_module() + dense_samples = [10_000_000] * 30 + spec = { + "function_name": "nullifier-proof-generation", + "function_label": "Nullifier proof generation", + "target": "benchmark-1", + "devices": [ + { + "device_name": "iPhone 15", + "os_version": "iOS 17.4", + "samples_ns": dense_samples, + }, + { + "device_name": "Pixel 8", + "os_version": "Android 15", + "samples_ns": dense_samples, + }, + ], + } + + fake_pyplot = FakePyplot() + fake_matplotlib = types.ModuleType("matplotlib") + fake_matplotlib.__path__ = [] + fake_matplotlib.use = lambda backend: None + with mock.patch.dict( + sys.modules, + { + "matplotlib": fake_matplotlib, + "matplotlib.pyplot": fake_pyplot, + }, + ): + mod.render_plot(spec, pathlib.Path("/tmp/out.svg")) + + self.assertEqual(len(fake_pyplot.axes.scatter_calls), 2) + first_xs = fake_pyplot.axes.scatter_calls[0][0] + second_xs = fake_pyplot.axes.scatter_calls[1][0] + self.assertLess(max(first_xs), min(second_xs)) + + +@unittest.skipIf(importlib.util.find_spec("matplotlib") is None, "matplotlib not installed") +class MatplotlibSmokeTests(unittest.TestCase): + def test_render_plot_writes_svg(self): + mod = load_module() + spec = { + "function_name": "nullifier-proof-generation", + "function_label": "Nullifier proof generation", + "target": "benchmark-1", + "devices": [ + { + "device_name": "iPhone 15", + "os_version": "iOS 17.4", + "samples_ns": [10_000_000, 10_100_000, 10_200_000], + } + ], + } + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = pathlib.Path(tmpdir) / "plot.svg" + mod.render_plot(spec, output_path) + self.assertTrue(output_path.exists()) + svg = output_path.read_text(encoding="utf-8") + self.assertIn(" Date: Wed, 25 Mar 2026 20:30:02 -0700 Subject: [PATCH 104/196] fix: align sina median markers with packed columns --- crates/mobench/python/render_sina_plot.py | 4 +- .../python/tests/test_render_sina_plot.py | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/mobench/python/render_sina_plot.py b/crates/mobench/python/render_sina_plot.py index 0472223..ffa6e5d 100644 --- a/crates/mobench/python/render_sina_plot.py +++ b/crates/mobench/python/render_sina_plot.py @@ -136,8 +136,8 @@ def render_plot(spec: dict[str, object], output_path: Path) -> None: median_ms = _median(samples_ms) ax.hlines( median_ms, - idx - 0.22, - idx + 0.22, + center - 0.22, + center + 0.22, color=color, linewidth=1.4, alpha=0.85, diff --git a/crates/mobench/python/tests/test_render_sina_plot.py b/crates/mobench/python/tests/test_render_sina_plot.py index 701a62f..e8883bc 100644 --- a/crates/mobench/python/tests/test_render_sina_plot.py +++ b/crates/mobench/python/tests/test_render_sina_plot.py @@ -227,6 +227,50 @@ def test_render_plot_expands_dense_columns_without_overlap(self): second_xs = fake_pyplot.axes.scatter_calls[1][0] self.assertLess(max(first_xs), min(second_xs)) + def test_render_plot_centers_median_marker_on_adaptive_device_center(self): + mod = load_module() + dense_samples = [10_000_000] * 30 + spec = { + "function_name": "nullifier-proof-generation", + "function_label": "Nullifier proof generation", + "target": "benchmark-1", + "devices": [ + { + "device_name": "iPhone 15", + "os_version": "iOS 17.4", + "samples_ns": dense_samples, + }, + { + "device_name": "Pixel 8", + "os_version": "Android 15", + "samples_ns": dense_samples, + }, + ], + } + + fake_pyplot = FakePyplot() + fake_matplotlib = types.ModuleType("matplotlib") + fake_matplotlib.__path__ = [] + fake_matplotlib.use = lambda backend: None + with mock.patch.dict( + sys.modules, + { + "matplotlib": fake_matplotlib, + "matplotlib.pyplot": fake_pyplot, + }, + ): + mod.render_plot(spec, pathlib.Path("/tmp/out.svg")) + + self.assertEqual(len(fake_pyplot.axes.scatter_calls), 2) + self.assertEqual(len(fake_pyplot.axes.hline_calls), 2) + for scatter_call, hline_call in zip( + fake_pyplot.axes.scatter_calls, fake_pyplot.axes.hline_calls + ): + xs, _, _ = scatter_call + _, xmin, xmax, _ = hline_call + expected_center = sum(xs) / len(xs) + self.assertAlmostEqual((xmin + xmax) / 2.0, expected_center, places=6) + @unittest.skipIf(importlib.util.find_spec("matplotlib") is None, "matplotlib not installed") class MatplotlibSmokeTests(unittest.TestCase): From f5a75829ec636327d1d49ba31acc77fc91160917 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 20:33:39 -0700 Subject: [PATCH 105/196] feat: add rust plot runner for embedded python renderer --- crates/mobench/src/plots.rs | 379 +++++++++++++++++++++++++++++++++--- 1 file changed, 353 insertions(+), 26 deletions(-) diff --git a/crates/mobench/src/plots.rs b/crates/mobench/src/plots.rs index 34f0f6e..5a2d90a 100644 --- a/crates/mobench/src/plots.rs +++ b/crates/mobench/src/plots.rs @@ -1,10 +1,15 @@ use anyhow::{Context, Result}; +use clap::ValueEnum; +use serde::Serialize; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; use std::fs; +use std::io; use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PlotFunctionInput { pub function_name: String, pub function_label: String, @@ -14,7 +19,7 @@ pub struct PlotFunctionInput { pub devices: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PlotDeviceSamples { pub device_name: String, pub os_version: String, @@ -23,11 +28,9 @@ pub struct PlotDeviceSamples { #[test] fn extract_function_plot_inputs_reads_fixture_samples() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/ci-artifact-root"); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ci-artifact-root"); - let plots = extract_function_plot_inputs_from_results_dir(&root) - .expect("extract plot inputs"); + let plots = extract_function_plot_inputs_from_results_dir(&root).expect("extract plot inputs"); let alpha = plots .iter() @@ -38,7 +41,13 @@ fn extract_function_plot_inputs_reads_fixture_samples() { assert_eq!(alpha.devices[0].device_name, "Google Pixel 8"); assert_eq!( alpha.devices[0].samples_ns, - vec![95_000_000, 98_000_000, 100_000_000, 120_000_000, 123_000_000] + vec![ + 95_000_000, + 98_000_000, + 100_000_000, + 120_000_000, + 123_000_000 + ] ); } @@ -118,8 +127,8 @@ fn extract_function_plot_inputs_walks_nested_files_without_duplicates() { }), ); - let plots = extract_function_plot_inputs_from_results_dir(root.path()) - .expect("extract plot inputs"); + let plots = + extract_function_plot_inputs_from_results_dir(root.path()).expect("extract plot inputs"); let alpha = plots .iter() @@ -215,8 +224,8 @@ fn extract_function_plot_inputs_preserves_duplicate_runs_from_non_root_files() { }), ); - let plots = extract_function_plot_inputs_from_results_dir(root.path()) - .expect("extract plot inputs"); + let plots = + extract_function_plot_inputs_from_results_dir(root.path()).expect("extract plot inputs"); let alpha = plots .iter() @@ -235,6 +244,45 @@ fn extract_function_plot_inputs_preserves_duplicate_runs_from_non_root_files() { assert_eq!(beta.devices[0].samples_ns, vec![300, 300, 400]); } +#[test] +fn render_plots_auto_mode_skips_missing_python() { + let out = tempfile::tempdir().expect("tempdir"); + let inputs = vec![sample_plot_input()]; + + let rendered = render_plot_artifacts( + &inputs, + out.path(), + PlotMode::Auto, + Some(Path::new("/definitely/missing/python")), + ) + .expect("auto mode should not fail"); + + assert!(rendered.is_empty()); +} + +#[test] +fn materialize_renderer_writes_script_and_style() { + let out = tempfile::tempdir().expect("tempdir"); + let bundle = materialize_renderer_assets(out.path()).expect("materialize renderer"); + + assert!(bundle.script_path.exists()); + assert!(bundle.style_path.exists()); +} + +#[test] +fn plot_output_file_name_uses_target_to_avoid_collisions() { + let mut ios = sample_plot_input(); + ios.target = "ios".to_string(); + let mut android = sample_plot_input(); + android.target = "android".to_string(); + + assert_eq!(plot_output_file_name(&ios), "ios-nullifier-proof-generation.svg"); + assert_eq!( + plot_output_file_name(&android), + "android-nullifier-proof-generation.svg" + ); +} + pub fn extract_function_plot_inputs_from_results_dir(dir: &Path) -> Result> { let mut builders = BTreeMap::new(); let mut nested_source_keys = BTreeSet::new(); @@ -253,6 +301,277 @@ pub fn extract_function_plot_inputs_from_results_dir(dir: &Path) -> Result Result { + fs::create_dir_all(parent) + .with_context(|| format!("creating directory {}", parent.display()))?; + + for _ in 0..1024 { + let seq = ASSET_DIR_SEQ.fetch_add(1, Ordering::Relaxed); + let candidate = parent.join(format!(".mobench-plot-assets-{seq}")); + match fs::create_dir(&candidate) { + Ok(()) => return Ok(Self { path: candidate }), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(err).with_context(|| { + format!("creating temporary directory {}", candidate.display()) + }); + } + } + } + + anyhow::bail!("failed to allocate a temporary plot asset directory") + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for ManagedTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +pub fn materialize_renderer_assets(output_dir: &Path) -> Result { + let tempdir = ManagedTempDir::new_in(output_dir)?; + let script_path = tempdir.path().join(PLOT_SCRIPT_NAME); + let style_path = tempdir.path().join(PLOT_STYLE_NAME); + + fs::write(&script_path, PLOT_SCRIPT) + .with_context(|| format!("writing renderer script {}", script_path.display()))?; + fs::write(&style_path, PLOT_STYLE) + .with_context(|| format!("writing renderer style {}", style_path.display()))?; + + Ok(RendererAssets { + _tempdir: tempdir, + script_path, + style_path, + }) +} + +pub fn render_plot_artifacts( + inputs: &[PlotFunctionInput], + output_dir: &Path, + mode: PlotMode, + python_override: Option<&Path>, +) -> Result> { + match mode { + PlotMode::Off => Ok(Vec::new()), + PlotMode::Auto | PlotMode::Require => { + if inputs.is_empty() { + return Ok(Vec::new()); + } + + let assets = materialize_renderer_assets(output_dir)?; + let plots_dir = output_dir.join("plots"); + fs::create_dir_all(&plots_dir) + .with_context(|| format!("creating directory {}", plots_dir.display()))?; + + let mut rendered = Vec::new(); + for input in inputs { + match render_single_plot(input, &plots_dir, &assets, python_override) { + Ok(plot) => rendered.push(plot), + Err(err) if mode == PlotMode::Auto => { + eprintln!("Skipping plot {}: {err}", input.function_name); + } + Err(err) => return Err(err), + } + } + + Ok(rendered) + } + } +} + +fn render_single_plot( + input: &PlotFunctionInput, + plots_dir: &Path, + assets: &RendererAssets, + python_override: Option<&Path>, +) -> Result { + let output_path = plots_dir.join(plot_output_file_name(input)); + let input_path = assets + .script_path + .parent() + .context("renderer assets missing parent directory")? + .join(format!( + "{}.json", + slugify_for_filename(&input.function_name) + )); + let payload = serde_json::to_vec_pretty(input).context("serializing plot input")?; + fs::write(&input_path, payload) + .with_context(|| format!("writing plot input {}", input_path.display()))?; + + let mut last_not_found = None; + for candidate in python_candidates(python_override) { + match try_render_with_python(&candidate, &assets.script_path, &input_path, &output_path) { + Ok(()) => { + return Ok(RenderedPlot { + function_name: input.function_name.clone(), + function_label: input.function_label.clone(), + output_path, + }); + } + Err(RenderAttemptError::NotFound(err)) => last_not_found = Some(err), + Err(RenderAttemptError::Failed(err)) => return Err(err), + } + } + + Err(last_not_found + .map(anyhow::Error::from) + .unwrap_or_else(|| anyhow::anyhow!("no usable python interpreter found"))) +} + +enum RenderAttemptError { + NotFound(io::Error), + Failed(anyhow::Error), +} + +fn try_render_with_python( + python: &Path, + script_path: &Path, + input_path: &Path, + output_path: &Path, +) -> std::result::Result<(), RenderAttemptError> { + let output = Command::new(python) + .arg(script_path) + .arg("--input") + .arg(input_path) + .arg("--output") + .arg(output_path) + .output(); + + let output = match output { + Ok(output) => output, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + return Err(RenderAttemptError::NotFound(err)); + } + Err(err) => return Err(RenderAttemptError::Failed(err.into())), + }; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let details = match (stdout.is_empty(), stderr.is_empty()) { + (true, true) => "renderer exited unsuccessfully".to_string(), + (false, true) => format!("renderer stdout: {stdout}"), + (true, false) => format!("renderer stderr: {stderr}"), + (false, false) => format!("renderer stdout: {stdout}; stderr: {stderr}"), + }; + + Err(RenderAttemptError::Failed(anyhow::anyhow!( + "{} failed for {}", + python.display(), + details + ))) +} + +fn python_candidates(python_override: Option<&Path>) -> Vec { + if let Some(path) = python_override { + return vec![path.to_path_buf()]; + } + + let mut candidates = Vec::new(); + if let Ok(value) = std::env::var("MOBENCH_PLOT_PYTHON") { + let trimmed = value.trim(); + if !trimmed.is_empty() { + candidates.push(PathBuf::from(trimmed)); + } + } + candidates.push(PathBuf::from("python3")); + candidates.push(PathBuf::from("python")); + candidates +} + +fn plot_output_file_name(input: &PlotFunctionInput) -> String { + format!( + "{}.svg", + slugify_for_filename(&format!("{}-{}", input.target, input.function_name)) + ) +} + +fn slugify_for_filename(value: &str) -> String { + let mut slug = String::new(); + let mut previous_dash = false; + + for ch in value.to_ascii_lowercase().chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + previous_dash = false; + } else if !previous_dash { + slug.push('-'); + previous_dash = true; + } + } + + while slug.starts_with('-') { + slug.remove(0); + } + while slug.ends_with('-') { + slug.pop(); + } + + if slug.is_empty() { + "plot".to_string() + } else { + slug + } +} + +#[cfg(test)] +fn sample_plot_input() -> PlotFunctionInput { + PlotFunctionInput { + function_name: "nullifier-proof-generation".to_string(), + function_label: "Nullifier proof generation".to_string(), + target: "benchmark-1".to_string(), + iterations: 20, + warmup: 5, + devices: vec![PlotDeviceSamples { + device_name: "iPhone 15".to_string(), + os_version: "iOS 17.4".to_string(), + samples_ns: vec![10_000_000, 11_000_000], + }], + } +} + #[derive(Debug, Default)] struct PlotFunctionInputBuilder { function_name: String, @@ -302,11 +621,14 @@ impl PlotFunctionInputBuilder { } let key = (device_name.clone(), os_version.clone()); - let device = self.devices.entry(key).or_insert_with(|| PlotDeviceSamplesBuilder { - device_name, - os_version, - samples_ns: Vec::new(), - }); + let device = self + .devices + .entry(key) + .or_insert_with(|| PlotDeviceSamplesBuilder { + device_name, + os_version, + samples_ns: Vec::new(), + }); device.samples_ns.extend(samples_ns); } @@ -373,7 +695,10 @@ fn collect_from_value( builders: &mut BTreeMap<(String, String), PlotFunctionInputBuilder>, nested_source_keys: &mut BTreeSet, ) -> Result<()> { - if let Some(benchmark_results) = value.get("benchmark_results").and_then(|value| value.as_object()) { + if let Some(benchmark_results) = value + .get("benchmark_results") + .and_then(|value| value.as_object()) + { let (target, iterations, warmup) = extract_run_metadata(value, path); for (device_label, entries) in benchmark_results { @@ -407,9 +732,9 @@ fn collect_from_value( } let key = (target.clone(), function_name.clone()); - let builder = builders - .entry(key) - .or_insert_with(|| PlotFunctionInputBuilder::new(function_name, target.clone())); + let builder = builders.entry(key).or_insert_with(|| { + PlotFunctionInputBuilder::new(function_name, target.clone()) + }); builder.set_run_metadata(iterations, warmup); builder.add_device_samples(device_name, os_version, samples_ns); } @@ -562,16 +887,18 @@ fn collect_json_files(dir: &Path, out: &mut Vec) -> Result<()> { } fn read_json(path: &Path) -> Result { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))?; - serde_json::from_str(&content) - .with_context(|| format!("Failed to parse {}", path.display())) + let content = + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", path.display())) } #[cfg(test)] fn write_json(path: &Path, value: Value) { - fs::write(path, serde_json::to_vec_pretty(&value).expect("serialize json")) - .expect("write json"); + fs::write( + path, + serde_json::to_vec_pretty(&value).expect("serialize json"), + ) + .expect("write json"); } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] From db053338a8cb688cab6181f54edf5393f75f6100 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 20:36:21 -0700 Subject: [PATCH 106/196] feat: add rust plot runner for embedded python renderer --- crates/mobench/src/plots.rs | 133 ++++++++++++++++++++++++++++++------ 1 file changed, 114 insertions(+), 19 deletions(-) diff --git a/crates/mobench/src/plots.rs b/crates/mobench/src/plots.rs index 5a2d90a..c0e0f4e 100644 --- a/crates/mobench/src/plots.rs +++ b/crates/mobench/src/plots.rs @@ -267,19 +267,82 @@ fn materialize_renderer_writes_script_and_style() { assert!(bundle.script_path.exists()); assert!(bundle.style_path.exists()); + assert_eq!( + fs::read_to_string(&bundle.script_path).expect("read script"), + PLOT_SCRIPT + ); + assert_eq!( + fs::read_to_string(&bundle.style_path).expect("read style"), + PLOT_STYLE + ); } #[test] -fn plot_output_file_name_uses_target_to_avoid_collisions() { - let mut ios = sample_plot_input(); - ios.target = "ios".to_string(); - let mut android = sample_plot_input(); - android.target = "android".to_string(); +fn allocate_plot_file_names_deduplicates_function_labels() { + let first = sample_plot_input(); + let mut second = sample_plot_input(); + second.target = "ios".to_string(); - assert_eq!(plot_output_file_name(&ios), "ios-nullifier-proof-generation.svg"); assert_eq!( - plot_output_file_name(&android), - "android-nullifier-proof-generation.svg" + allocate_plot_file_names(&[first, second]), + vec![ + "nullifier-proof-generation.svg".to_string(), + "nullifier-proof-generation-ios.svg".to_string() + ] + ); +} + +#[cfg(unix)] +#[test] +fn render_plot_artifacts_invokes_renderer_with_fake_python() { + use std::os::unix::fs::PermissionsExt; + + let out = tempfile::tempdir().expect("tempdir"); + let fake_python = out.path().join("fake-python"); + fs::write( + &fake_python, + r#"#!/bin/sh +if [ "$1" = "--version" ]; then + exit 0 +fi + +output="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--output" ]; then + shift + output="$1" + fi + shift +done + +mkdir -p "$(dirname "$output")" +printf 'ok' > "$output" +"#, + ) + .expect("write fake python"); + + let mut permissions = fs::metadata(&fake_python) + .expect("fake python metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&fake_python, permissions).expect("set fake python perms"); + + let rendered = render_plot_artifacts( + &[sample_plot_input()], + out.path(), + PlotMode::Require, + Some(&fake_python), + ) + .expect("render plots"); + + assert_eq!(rendered.len(), 1); + assert_eq!( + rendered[0].relative_path, + PathBuf::from("plots/nullifier-proof-generation.svg") + ); + assert_eq!( + fs::read_to_string(&rendered[0].output_path).expect("read svg"), + "ok" ); } @@ -320,6 +383,7 @@ pub struct RenderedPlot { pub function_name: String, pub function_label: String, pub output_path: PathBuf, + pub relative_path: PathBuf, } const PLOT_SCRIPT_NAME: &str = "render_sina_plot.py"; @@ -401,10 +465,11 @@ pub fn render_plot_artifacts( let plots_dir = output_dir.join("plots"); fs::create_dir_all(&plots_dir) .with_context(|| format!("creating directory {}", plots_dir.display()))?; + let file_names = allocate_plot_file_names(inputs); let mut rendered = Vec::new(); - for input in inputs { - match render_single_plot(input, &plots_dir, &assets, python_override) { + for (input, file_name) in inputs.iter().zip(file_names.iter()) { + match render_single_plot(input, &plots_dir, &assets, python_override, file_name) { Ok(plot) => rendered.push(plot), Err(err) if mode == PlotMode::Auto => { eprintln!("Skipping plot {}: {err}", input.function_name); @@ -423,8 +488,10 @@ fn render_single_plot( plots_dir: &Path, assets: &RendererAssets, python_override: Option<&Path>, + file_name: &str, ) -> Result { - let output_path = plots_dir.join(plot_output_file_name(input)); + let output_path = plots_dir.join(file_name); + let relative_path = PathBuf::from("plots").join(file_name); let input_path = assets .script_path .parent() @@ -445,6 +512,7 @@ fn render_single_plot( function_name: input.function_name.clone(), function_label: input.function_label.clone(), output_path, + relative_path, }); } Err(RenderAttemptError::NotFound(err)) => last_not_found = Some(err), @@ -521,11 +589,38 @@ fn python_candidates(python_override: Option<&Path>) -> Vec { candidates } -fn plot_output_file_name(input: &PlotFunctionInput) -> String { - format!( - "{}.svg", - slugify_for_filename(&format!("{}-{}", input.target, input.function_name)) - ) +fn allocate_plot_file_names(inputs: &[PlotFunctionInput]) -> Vec { + let mut used = BTreeSet::new(); + inputs + .iter() + .map(|input| { + let base = slugify_for_filename(&input.function_label); + let target = slugify_for_filename(&input.target); + let function = slugify_for_filename(&input.function_name); + + for candidate in [ + Some(base.clone()), + (!target.is_empty()).then(|| format!("{base}-{target}")), + (function != base && !function.is_empty()).then(|| format!("{base}-{function}")), + ] + .into_iter() + .flatten() + { + if used.insert(candidate.clone()) { + return format!("{candidate}.svg"); + } + } + + let mut index = 2usize; + loop { + let candidate = format!("{base}-{index}"); + if used.insert(candidate.clone()) { + return format!("{candidate}.svg"); + } + index += 1; + } + }) + .collect() } fn slugify_for_filename(value: &str) -> String { @@ -559,9 +654,9 @@ fn slugify_for_filename(value: &str) -> String { #[cfg(test)] fn sample_plot_input() -> PlotFunctionInput { PlotFunctionInput { - function_name: "nullifier-proof-generation".to_string(), - function_label: "Nullifier proof generation".to_string(), - target: "benchmark-1".to_string(), + function_name: "bench_nullifier_proof_generation".to_string(), + function_label: "nullifier-proof-generation".to_string(), + target: "android".to_string(), iterations: 20, warmup: 5, devices: vec![PlotDeviceSamples { From ce2c09c6bb94311e6c652a8072cb26f7bcd9f5d5 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 20:45:38 -0700 Subject: [PATCH 107/196] feat: embed device comparison plots in local summary markdown --- crates/mobench/src/lib.rs | 934 ++++++++++++++++++++++++++++++++++-- crates/mobench/src/plots.rs | 73 ++- 2 files changed, 944 insertions(+), 63 deletions(-) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index a08ac9a..c003179 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -633,6 +633,8 @@ enum ReportCommand { summary: PathBuf, #[arg(long, help = "Write markdown output to file")] output: Option, + #[arg(long, value_enum, default_value_t = plots::PlotMode::Auto)] + plots: plots::PlotMode, }, /// Generate/publish sticky GitHub PR comment from summary output. Github { @@ -736,6 +738,8 @@ struct CiRunArgs { request_command: Option, #[arg(long, help = "Metadata: git ref/sha for this mobench invocation")] mobench_ref: Option, + #[arg(long, value_enum, default_value_t = plots::PlotMode::Auto)] + plots: plots::PlotMode, } #[derive(Args, Debug, Clone)] @@ -1053,6 +1057,24 @@ struct BenchmarkStats { p95_ns: Option, min_ns: Option, max_ns: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resource_usage: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +struct BenchmarkResourceUsage { + #[serde(skip_serializing_if = "Option::is_none")] + cpu_total_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + peak_memory_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + total_pss_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + private_dirty_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + native_heap_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + java_heap_kb: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -1317,7 +1339,7 @@ pub fn run() -> Result<()> { if !progress { println!("\u{2713} Built iOS xcframework at {:?}", xcframework); } - let ios_xcuitest = spec.ios_xcuitest.clone(); + let mut ios_xcuitest = spec.ios_xcuitest.clone(); if spec.devices.is_empty() { if !progress { @@ -1328,10 +1350,21 @@ pub fn run() -> Result<()> { println!("[dry-run] Skipping BrowserStack upload/run for iOS"); } } else { + if ios_xcuitest.as_ref().is_some_and(|artifacts| { + uses_managed_ios_xcuitest_artifacts(&layout, artifacts) + }) { + println!( + "📦 Packaging iOS BrowserStack artifacts with current bench_spec..." + ); + let packaged = package_ios_xcuitest_artifacts(&layout, release)?; + println!(" ✓ IPA: {}", packaged.app.display()); + println!(" ✓ XCUITest: {}", packaged.test_suite.display()); + ios_xcuitest = Some(packaged); + } if progress { println!("[3/4] Uploading to BrowserStack..."); } - let xcui = spec.ios_xcuitest.as_ref().context( + let xcui = ios_xcuitest.as_ref().context( "iOS XCUITest artifacts required when targeting BrowserStack devices; provide --ios-app and --ios-test-suite or set ios_xcuitest in the config", )?; let run = trigger_browserstack_xcuitest(&spec, xcui)?; @@ -1494,7 +1527,12 @@ pub fn run() -> Result<()> { } run_summary.summary = build_summary(&run_summary)?; - write_summary(&run_summary, &summary_paths, summary_csv)?; + write_summary( + &run_summary, + &summary_paths, + summary_csv, + plots::PlotMode::Off, + )?; let mut compare_report = None; let mut regression_findings: Vec = Vec::new(); @@ -1795,8 +1833,12 @@ pub fn run() -> Result<()> { } }, Command::Report { command } => match command { - ReportCommand::Summarize { summary, output } => { - cmd_report_summarize(&summary, output.as_deref())?; + ReportCommand::Summarize { + summary, + output, + plots, + } => { + cmd_report_summarize(&summary, output.as_deref(), plots)?; } ReportCommand::Github { pr, @@ -2213,6 +2255,8 @@ pub struct RunRequest { pub progress: bool, /// Output directory for CI contract files. pub output_dir: PathBuf, + /// Plot rendering mode for local markdown summaries. + pub plots: plots::PlotMode, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -2401,7 +2445,49 @@ fn resolve_ci_functions(args: &CiRunArgs) -> Result> { } fn ci_function_slug(function: &str) -> String { - function.replace("::", "_").replace('/', "-") + let mut slug = String::new(); + let mut chars = function.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + ':' if matches!(chars.peek(), Some(':')) => { + chars.next(); + slug.push('_'); + } + '_' => slug.push_str("__"), + '/' => slug.push_str("_slash_"), + '-' => slug.push('-'), + ch if ch.is_ascii_alphanumeric() => slug.push(ch), + ch => slug.push_str(&format!("_x{:02x}", ch as u32)), + } + } + + slug +} + +fn find_baseline_benchmark<'a>( + baseline_report: &'a summarize::SummarizeReport, + platform_name: &str, + device_name: &str, + device_os_version: &str, + benchmark_name: &str, +) -> Option<&'a summarize::BenchmarkResult> { + baseline_report + .platforms + .iter() + .find(|platform| { + platform.platform == platform_name + && summarize::device_names_match(&platform.device.name, device_name) + && (device_os_version == "unknown" + || platform.device.os_version == "unknown" + || platform.device.os_version == device_os_version) + }) + .and_then(|platform| { + platform + .benchmarks + .iter() + .find(|benchmark| benchmark.name == benchmark_name) + }) } fn summary_report_from_value(value: &Value) -> Result { @@ -2597,19 +2683,11 @@ fn cmd_ci_run(args: CiRunArgs) -> Result<()> { let root_summary_md = args.output_dir.join("summary.md"); let root_results_csv = args.output_dir.join("results.csv"); - let mut merged_markdown_sections = Vec::new(); let mut merged_csv_rows = Vec::new(); let mut merged_header: Option = None; for (target_name, entry) in &merged_targets { let summary = summary_report_from_value(entry)?; - let markdown = render_markdown_summary(&summary); - if merged_targets.len() == 1 { - merged_markdown_sections.push(markdown); - } else { - merged_markdown_sections.push(format!("## {}\n\n{}", target_name, markdown)); - } - let csv = render_csv_summary(&summary); let mut lines = csv.lines(); if let Some(header) = lines.next() @@ -2625,9 +2703,6 @@ fn cmd_ci_run(args: CiRunArgs) -> Result<()> { } } - let merged_markdown = merged_markdown_sections.join("\n\n"); - write_file(&root_summary_md, merged_markdown.as_bytes())?; - let mut merged_csv = String::new(); if let Some(header) = merged_header { merged_csv.push_str(&header); @@ -2673,6 +2748,12 @@ fn cmd_ci_run(args: CiRunArgs) -> Result<()> { &root_summary_json, serde_json::to_string_pretty(&merged_summary)?.as_bytes(), )?; + let merged_markdown = render_summary_markdown_from_output_with_plots( + &merged_summary, + &args.output_dir, + args.plots, + )?; + write_file(&root_summary_md, merged_markdown.as_bytes())?; println!("CI outputs ready:"); println!(" - {}", root_summary_json.display()); @@ -2818,12 +2899,13 @@ fn cmd_ci_check_run(args: CiCheckRunArgs) -> Result<()> { for platform in &report.platforms { for bench in &platform.benchmarks { - let baseline_bench = baseline_report - .platforms - .iter() - .filter(|p| p.platform == platform.platform) - .flat_map(|p| &p.benchmarks) - .find(|b| b.name == bench.name); + let baseline_bench = find_baseline_benchmark( + &baseline_report, + &platform.platform, + &platform.device.name, + &platform.device.os_version, + &bench.name, + ); if let Some(base) = baseline_bench { if base.timing.avg_ms > 0.0 { @@ -2922,6 +3004,7 @@ fn cmd_ci_run_single( fetch_timeout_secs: args.fetch_timeout_secs, progress: args.progress, output_dir: output_dir.to_path_buf(), + plots: args.plots, })?; let summary_json = result.report.summary_json; @@ -3249,7 +3332,7 @@ fn resolve_run_spec( ios_app: Option, ios_test_suite: Option, local_only: bool, - release: bool, + _release: bool, dry_run: bool, ) -> Result { if let Some(cfg_path) = config { @@ -3315,20 +3398,10 @@ fn resolve_run_spec( && !resolved_devices.is_empty() && ios_xcuitest.is_none() { - let artifacts = if dry_run { + if dry_run { println!("📦 [dry-run] Would auto-package iOS artifacts for BrowserStack..."); - IosXcuitestArtifacts { - app: layout.output_dir.join("ios/BenchRunner.ipa"), - test_suite: layout.output_dir.join("ios/BenchRunnerUITests.zip"), - } - } else { - println!("📦 Auto-packaging iOS artifacts for BrowserStack..."); - let artifacts = package_ios_xcuitest_artifacts(layout, release)?; - println!(" ✓ IPA: {}", artifacts.app.display()); - println!(" ✓ XCUITest: {}", artifacts.test_suite.display()); - artifacts - }; - Some(artifacts) + } + Some(default_ios_xcuitest_artifacts(layout)) } else { ios_xcuitest }; @@ -3464,6 +3537,45 @@ fn package_ios_xcuitest_artifacts( Ok(IosXcuitestArtifacts { app, test_suite }) } +fn default_ios_xcuitest_artifacts(layout: &ResolvedProjectLayout) -> IosXcuitestArtifacts { + IosXcuitestArtifacts { + app: layout.output_dir.join("ios/BenchRunner.ipa"), + test_suite: layout.output_dir.join("ios/BenchRunnerUITests.zip"), + } +} + +fn legacy_ios_xcuitest_artifacts(layout: &ResolvedProjectLayout) -> IosXcuitestArtifacts { + IosXcuitestArtifacts { + app: layout.project_root.join("target/ios/BenchRunner.ipa"), + test_suite: layout + .project_root + .join("target/ios/BenchRunnerUITests.zip"), + } +} + +fn resolve_project_relative_path(project_root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + project_root.join(path) + } +} + +fn uses_managed_ios_xcuitest_artifacts( + layout: &ResolvedProjectLayout, + artifacts: &IosXcuitestArtifacts, +) -> bool { + let app = resolve_project_relative_path(&layout.project_root, &artifacts.app); + let test_suite = resolve_project_relative_path(&layout.project_root, &artifacts.test_suite); + + [ + default_ios_xcuitest_artifacts(layout), + legacy_ios_xcuitest_artifacts(layout), + ] + .into_iter() + .any(|managed| app == managed.app && test_suite == managed.test_suite) +} + #[derive(Debug, Clone)] struct ResolvedBrowserStack { username: String, @@ -4001,6 +4113,10 @@ fn build_summary(run_summary: &RunSummary) -> Result { if let Some(results) = &run_summary.benchmark_results { for (device, entries) in results { let mut benchmarks = Vec::new(); + let perf_metrics = run_summary + .performance_metrics + .as_ref() + .and_then(|metrics| metrics.get(device)); for entry in entries { let function = entry .get("function") @@ -4022,6 +4138,7 @@ fn build_summary(run_summary: &RunSummary) -> Result { p95_ns: stats.as_ref().map(|s| s.p95_ns), min_ns: stats.as_ref().map(|s| s.min_ns), max_ns: stats.as_ref().map(|s| s.max_ns), + resource_usage: extract_benchmark_resource_usage(entry, perf_metrics), }); } @@ -4051,13 +4168,21 @@ fn build_summary(run_summary: &RunSummary) -> Result { }) } -fn write_summary(summary: &RunSummary, paths: &SummaryPaths, summary_csv: bool) -> Result<()> { +fn write_summary( + summary: &RunSummary, + paths: &SummaryPaths, + summary_csv: bool, + plot_mode: plots::PlotMode, +) -> Result<()> { let json = serde_json::to_string_pretty(summary)?; ensure_parent_dir(&paths.json)?; write_file(&paths.json, json.as_bytes())?; println!("Wrote run summary to {:?}", paths.json); - let markdown = render_markdown_summary(&summary.summary); + let summary_value = serde_json::to_value(summary).context("serializing run summary")?; + let markdown_dir = paths.markdown.parent().unwrap_or_else(|| Path::new(".")); + let markdown = + render_summary_markdown_from_output_with_plots(&summary_value, markdown_dir, plot_mode)?; ensure_parent_dir(&paths.markdown)?; write_file(&paths.markdown, markdown.as_bytes())?; println!("Wrote markdown summary to {:?}", paths.markdown); @@ -4646,12 +4771,20 @@ struct GitHubIssueComment { body: String, } -fn cmd_report_summarize(summary_path: &Path, output: Option<&Path>) -> Result { +fn cmd_report_summarize( + summary_path: &Path, + output: Option<&Path>, + plot_mode: plots::PlotMode, +) -> Result { let contents = fs::read_to_string(summary_path) .with_context(|| format!("reading summary file {}", summary_path.display()))?; let value: Value = serde_json::from_str(&contents) .with_context(|| format!("parsing summary file {}", summary_path.display()))?; - let markdown = render_summary_markdown_from_output(&value)?; + let markdown = render_summary_markdown_from_output_with_plots( + &value, + &summary_markdown_output_dir(summary_path, output), + plot_mode, + )?; if let Some(path) = output { ensure_parent_dir(path)?; @@ -4734,6 +4867,116 @@ fn render_summary_markdown_from_output(value: &Value) -> Result { Ok(render_markdown_summary(&parsed)) } +fn render_summary_markdown_from_output_with_plots( + value: &Value, + output_dir: &Path, + plot_mode: plots::PlotMode, +) -> Result { + render_summary_markdown_from_output_with_plots_using_python(value, output_dir, plot_mode, None) +} + +fn render_summary_markdown_from_output_with_plots_using_python( + value: &Value, + output_dir: &Path, + plot_mode: plots::PlotMode, + python_override: Option<&Path>, +) -> Result { + let plot_inputs = plots::extract_function_plot_inputs_from_output_value(value)?; + let rendered_plots = + plots::render_plot_artifacts(&plot_inputs, output_dir, plot_mode, python_override)?; + + if let Some(summary) = value.get("summary") { + let parsed: SummaryReport = + serde_json::from_value(summary.clone()).context("parsing summary report")?; + let rendered_refs = rendered_plots.iter().collect::>(); + return Ok(append_plot_links_to_markdown( + render_markdown_summary(&parsed), + &rendered_refs, + )); + } + + if let Some(targets) = value.get("targets").and_then(|v| v.as_object()) { + let mut target_names: Vec = targets.keys().cloned().collect(); + target_names.sort(); + + let mut sections = Vec::new(); + for name in target_names { + let Some(entry) = targets.get(&name) else { + continue; + }; + let summary_value = entry + .get("summary") + .cloned() + .unwrap_or_else(|| entry.clone()); + let parsed: SummaryReport = + serde_json::from_value(summary_value).with_context(|| { + format!("parsing summary report for target `{name}` in merged output") + })?; + let rendered_refs = rendered_plots + .iter() + .filter(|plot| plot.target == name) + .collect::>(); + sections.push(format!( + "## {name}\n\n{}", + append_plot_links_to_markdown(render_markdown_summary(&parsed), &rendered_refs) + )); + } + if !sections.is_empty() { + return Ok(sections.join("\n\n")); + } + } + + let parsed: SummaryReport = + serde_json::from_value(value.clone()).context("parsing summary report")?; + let rendered_refs = rendered_plots.iter().collect::>(); + Ok(append_plot_links_to_markdown( + render_markdown_summary(&parsed), + &rendered_refs, + )) +} + +fn append_plot_links_to_markdown( + mut markdown: String, + rendered_plots: &[&plots::RenderedPlot], +) -> String { + if rendered_plots.is_empty() { + return markdown; + } + + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + markdown.push('\n'); + markdown.push_str("## Device Comparison Plots\n\n"); + + for plot in rendered_plots { + let _ = writeln!(markdown, "### {}", plot.function_label); + let _ = writeln!( + markdown, + "![{}]({})", + plot.function_label, + plot.relative_path.display() + ); + let _ = writeln!(markdown); + } + + markdown +} + +fn summary_markdown_output_dir(summary_path: &Path, output: Option<&Path>) -> PathBuf { + output + .and_then(|path| path.parent()) + .filter(|path| !path.as_os_str().is_empty()) + .map(Path::to_path_buf) + .or_else(|| { + summary_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .map(Path::to_path_buf) + }) + .unwrap_or_else(|| PathBuf::from(".")) +} + fn upsert_github_pr_comment(pr_number: &str, marker: &str, body: &str) -> Result<()> { let token = env::var("GITHUB_TOKEN").context("provider_error: GITHUB_TOKEN is required for publish")?; @@ -4820,10 +5063,90 @@ fn summarize_local_report(run_summary: &RunSummary) -> Option { p95_ns: Some(stats.p95_ns), min_ns: Some(stats.min_ns), max_ns: Some(stats.max_ns), + resource_usage: None, }], }) } +impl BenchmarkResourceUsage { + fn is_empty(&self) -> bool { + self.cpu_total_ms.is_none() + && self.peak_memory_kb.is_none() + && self.total_pss_kb.is_none() + && self.private_dirty_kb.is_none() + && self.native_heap_kb.is_none() + && self.java_heap_kb.is_none() + } +} + +fn json_value_to_u64(value: &Value) -> Option { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|v| u64::try_from(v).ok())) + .or_else(|| { + value + .as_f64() + .filter(|v| v.is_finite() && *v >= 0.0) + .map(|v| v.round() as u64) + }) +} + +fn raw_peak_memory_kb( + total_pss_kb: Option, + private_dirty_kb: Option, + native_heap_kb: Option, + java_heap_kb: Option, +) -> Option { + total_pss_kb + .or(private_dirty_kb) + .or_else(|| match (native_heap_kb, java_heap_kb) { + (Some(native), Some(java)) => Some(native + java), + (Some(native), None) => Some(native), + (None, Some(java)) => Some(java), + (None, None) => None, + }) +} + +fn extract_benchmark_resource_usage( + entry: &Value, + perf_metrics: Option<&browserstack::PerformanceMetrics>, +) -> Option { + let resources = entry.get("resources"); + let cpu_total_ms = resources + .and_then(|res| res.get("elapsed_cpu_ms")) + .and_then(json_value_to_u64); + let total_pss_kb = resources + .and_then(|res| res.get("total_pss_kb")) + .and_then(json_value_to_u64); + let private_dirty_kb = resources + .and_then(|res| res.get("private_dirty_kb")) + .and_then(json_value_to_u64); + let native_heap_kb = resources + .and_then(|res| res.get("native_heap_kb")) + .and_then(json_value_to_u64); + let java_heap_kb = resources + .and_then(|res| res.get("java_heap_kb")) + .and_then(json_value_to_u64); + + let peak_memory_kb = perf_metrics + .and_then(|metrics| metrics.memory.as_ref()) + .map(|memory| (memory.peak_mb * 1024.0).round() as u64) + .or_else(|| { + raw_peak_memory_kb(total_pss_kb, private_dirty_kb, native_heap_kb, java_heap_kb) + }); + + let resource_usage = BenchmarkResourceUsage { + cpu_total_ms, + peak_memory_kb, + total_pss_kb, + private_dirty_kb, + native_heap_kb, + java_heap_kb, + }; + + (!resource_usage.is_empty()).then_some(resource_usage) +} + #[derive(Clone, Debug)] struct SampleStats { mean_ns: u64, @@ -7535,8 +7858,44 @@ fn check_xcodegen() -> PrereqCheck { mod tests { use super::*; use jsonschema::JSONSchema; + use std::path::Path; use tempfile::TempDir; + #[cfg(unix)] + pub(crate) fn write_fake_plot_python(dir: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let path = dir.join("fake-python"); + std::fs::write( + &path, + r#"#!/bin/sh +if [ "$1" = "--version" ]; then + exit 0 +fi + +output="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--output" ]; then + shift + output="$1" + fi + shift +done + +mkdir -p "$(dirname "$output")" +printf 'ok' > "$output" +"#, + ) + .expect("write fake python"); + + let mut permissions = std::fs::metadata(&path) + .expect("fake python metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("set fake python perms"); + path + } + fn write_custom_layout_project(temp_dir: &TempDir) -> (PathBuf, PathBuf) { let project_root = temp_dir.path().to_path_buf(); let crate_dir = project_root.join("crates/zk-mobile-bench"); @@ -7969,20 +8328,22 @@ project = "proj" } #[test] - fn ios_requires_artifacts_for_browserstack() { + fn ios_defers_packaging_browserstack_artifacts_until_run_time() { + let temp_dir = TempDir::new().expect("temp dir"); + let (project_root, _) = write_custom_layout_project(&temp_dir); let layout = resolve_project_layout(ProjectLayoutOptions { - start_dir: None, + start_dir: Some(project_root.as_path()), project_root: None, crate_path: None, config_path: None, }) - .unwrap(); + .expect("resolve layout"); let spec = resolve_run_spec( MobileTarget::Ios, - "sample_fns::fibonacci".into(), + "zk_mobile_bench::bench_query_proof_generation".into(), 1, 0, - vec!["iphone".into()], + vec!["iPhone 15".into()], &layout, None, None, @@ -7993,14 +8354,49 @@ project = "proj" false, // release false, ) - .expect("should auto-package iOS artifacts when missing"); + .expect("should prepare iOS BrowserStack artifact paths"); let ios_artifacts = spec .ios_xcuitest - .expect("iOS artifacts should be populated"); - assert!(ios_artifacts.app.exists(), "iOS app artifact missing"); + .expect("iOS artifact paths should be populated"); + assert_eq!( + ios_artifacts.app, + layout.output_dir.join("ios/BenchRunner.ipa") + ); + assert!( + ios_artifacts + .test_suite + .ends_with(Path::new("target/mobench/ios/BenchRunnerUITests.zip")) + ); + assert!( + !ios_artifacts.app.exists(), + "iOS app artifact should not be packaged before the current bench_spec is persisted" + ); + assert!( + !ios_artifacts.test_suite.exists(), + "iOS test suite should not be packaged before the current bench_spec is persisted" + ); + } + + #[test] + fn ios_managed_artifact_detection_accepts_config_template_paths() { + let temp_dir = TempDir::new().expect("temp dir"); + let (project_root, _) = write_custom_layout_project(&temp_dir); + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: Some(project_root.as_path()), + project_root: None, + crate_path: None, + config_path: None, + }) + .expect("resolve layout"); + + let config_template_artifacts = IosXcuitestArtifacts { + app: PathBuf::from("target/ios/BenchRunner.ipa"), + test_suite: PathBuf::from("target/ios/BenchRunnerUITests.zip"), + }; + assert!( - ios_artifacts.test_suite.exists(), - "iOS test suite artifact missing" + uses_managed_ios_xcuitest_artifacts(&layout, &config_template_artifacts), + "legacy config template paths should still be treated as mobench-managed artifacts" ); } @@ -8421,6 +8817,80 @@ project = "proj" panic!("ci schema validation failed: {}", messages.join(" | ")); } } + + #[test] + fn ci_function_slug_distinguishes_ambiguous_paths() { + assert_ne!(ci_function_slug("a::b_c"), ci_function_slug("a_b::c")); + } + + #[test] + fn baseline_lookup_matches_device_row() { + let baseline_report = summarize::SummarizeReport { + platforms: vec![ + summarize::PlatformReport { + platform: "android".to_string(), + device: summarize::DeviceInfo { + name: "Google Pixel 6".to_string(), + os: "Android".to_string(), + os_version: "14".to_string(), + chipset: None, + ram_gb: None, + }, + benchmarks: vec![summarize::BenchmarkResult { + name: "bench_alpha".to_string(), + label: "alpha".to_string(), + timing: summarize::TimingStats { + avg_ms: 100.0, + median_ms: 100.0, + best_ms: 100.0, + worst_ms: 100.0, + p95_ms: 100.0, + std_dev_ms: None, + }, + resource_usage: None, + }], + iterations: 5, + warmup: 1, + }, + summarize::PlatformReport { + platform: "android".to_string(), + device: summarize::DeviceInfo { + name: "Samsung Galaxy S24".to_string(), + os: "Android".to_string(), + os_version: "14".to_string(), + chipset: None, + ram_gb: None, + }, + benchmarks: vec![summarize::BenchmarkResult { + name: "bench_alpha".to_string(), + label: "alpha".to_string(), + timing: summarize::TimingStats { + avg_ms: 200.0, + median_ms: 200.0, + best_ms: 200.0, + worst_ms: 200.0, + p95_ms: 200.0, + std_dev_ms: None, + }, + resource_usage: None, + }], + iterations: 5, + warmup: 1, + }, + ], + }; + + let baseline = find_baseline_benchmark( + &baseline_report, + "android", + "Samsung Galaxy S24", + "14", + "bench_alpha", + ) + .expect("matching baseline benchmark"); + + assert_eq!(baseline.timing.avg_ms, 200.0); + } } #[cfg(test)] @@ -8568,6 +9038,59 @@ mod ci_merge_tests { ); } + #[test] + fn merge_ci_target_runs_preserves_resource_usage() { + let runs = BTreeMap::from([ + ( + "bench_a".to_string(), + json!({ + "summary": { + "generated_at": "2026-02-16T00:00:00Z", + "generated_at_unix": 1708041600, + "target": "android", + "function": "bench_a", + "iterations": 3, + "warmup": 1, + "devices": ["Pixel 8-14.0"], + "device_summaries": [{ + "device": "Pixel 8-14.0", + "benchmarks": [{ + "function": "bench_a", + "samples": 3, + "mean_ns": 100, + "median_ns": 100, + "p95_ns": 100, + "min_ns": 100, + "max_ns": 100, + "resource_usage": { + "cpu_total_ms": 482, + "peak_memory_kb": 654321, + "total_pss_kb": 654321 + } + }] + }] + } + }), + ), + ( + "bench_b".to_string(), + sample_run_summary(MobileTarget::Android, "bench_b", "Pixel 8-14.0", 200), + ), + ]); + + let merged = merge_ci_target_runs(MobileTarget::Android, &runs).expect("merge targets"); + let benchmarks = merged["summary"]["device_summaries"][0]["benchmarks"] + .as_array() + .expect("benchmarks"); + let bench_a = benchmarks + .iter() + .find(|benchmark| benchmark["function"] == "bench_a") + .expect("bench_a"); + + assert_eq!(bench_a["resource_usage"]["cpu_total_ms"], 482); + assert_eq!(bench_a["resource_usage"]["peak_memory_kb"], 654321); + } + #[test] fn render_summary_markdown_from_output_renders_all_functions_from_merged_targets() { let ios = merge_ci_target_runs( @@ -8607,6 +9130,315 @@ mod ci_merge_tests { assert!(markdown.contains("bench_b")); assert!(markdown.contains("bench_c")); } + + #[cfg(unix)] + #[test] + fn render_summary_markdown_from_output_with_plots_embeds_image_links() { + let output = json!({ + "summary": { + "generated_at": "2026-03-25T00:00:00Z", + "generated_at_unix": 1_742_862_400_u64, + "target": "android", + "function": "bench_alpha", + "iterations": 3, + "warmup": 1, + "devices": ["Google Pixel 8-14.0", "iPhone 15-17.4"], + "device_summaries": [ + { + "device": "Google Pixel 8-14.0", + "benchmarks": [{ + "function": "bench_alpha", + "samples": 3, + "mean_ns": 97_u64, + "median_ns": 98_u64, + "p95_ns": 100_u64, + "min_ns": 95_u64, + "max_ns": 100_u64 + }] + }, + { + "device": "iPhone 15-17.4", + "benchmarks": [{ + "function": "bench_alpha", + "samples": 3, + "mean_ns": 82_u64, + "median_ns": 82_u64, + "p95_ns": 84_u64, + "min_ns": 80_u64, + "max_ns": 84_u64 + }] + } + ] + }, + "benchmark_results": { + "Google Pixel 8-14.0": [{ + "function": "bench_alpha", + "samples": [95_u64, 98_u64, 100_u64] + }], + "iPhone 15-17.4": [{ + "function": "bench_alpha", + "samples": [80_u64, 82_u64, 84_u64] + }] + } + }); + let dir = tempfile::tempdir().expect("tempdir"); + let fake_python = crate::tests::write_fake_plot_python(dir.path()); + + let markdown = render_summary_markdown_from_output_with_plots_using_python( + &output, + dir.path(), + plots::PlotMode::Require, + Some(&fake_python), + ) + .expect("render markdown with plots"); + + assert!(markdown.contains("## Device Comparison Plots")); + assert!(markdown.contains("![alpha](plots/alpha.svg)")); + assert!(dir.path().join("plots/alpha.svg").exists()); + } + + #[cfg(unix)] + #[test] + fn render_summary_markdown_from_output_with_plots_deduplicates_across_targets() { + let merged = json!({ + "targets": { + "android": { + "summary": { + "generated_at": "2026-03-25T00:00:00Z", + "generated_at_unix": 1_742_862_400_u64, + "target": "android", + "function": "bench_alpha", + "iterations": 3, + "warmup": 1, + "devices": ["Google Pixel 8-14.0"], + "device_summaries": [{ + "device": "Google Pixel 8-14.0", + "benchmarks": [{ + "function": "bench_alpha", + "samples": 3, + "mean_ns": 97_u64, + "median_ns": 98_u64, + "p95_ns": 100_u64, + "min_ns": 95_u64, + "max_ns": 100_u64 + }] + }] + }, + "functions": { + "bench_alpha": { + "summary": { + "generated_at": "2026-03-25T00:00:00Z", + "generated_at_unix": 1_742_862_400_u64, + "target": "android", + "function": "bench_alpha", + "iterations": 3, + "warmup": 1, + "devices": ["Google Pixel 8-14.0"], + "device_summaries": [{ + "device": "Google Pixel 8-14.0", + "benchmarks": [{ + "function": "bench_alpha", + "samples": 3, + "mean_ns": 97_u64, + "median_ns": 98_u64, + "p95_ns": 100_u64, + "min_ns": 95_u64, + "max_ns": 100_u64 + }] + }] + }, + "benchmark_results": { + "Google Pixel 8-14.0": [{ + "function": "bench_alpha", + "samples": [95_u64, 98_u64, 100_u64] + }] + } + } + } + }, + "ios": { + "summary": { + "generated_at": "2026-03-25T00:00:00Z", + "generated_at_unix": 1_742_862_400_u64, + "target": "ios", + "function": "bench_alpha", + "iterations": 3, + "warmup": 1, + "devices": ["iPhone 15-17.4"], + "device_summaries": [{ + "device": "iPhone 15-17.4", + "benchmarks": [{ + "function": "bench_alpha", + "samples": 3, + "mean_ns": 82_u64, + "median_ns": 82_u64, + "p95_ns": 84_u64, + "min_ns": 80_u64, + "max_ns": 84_u64 + }] + }] + }, + "functions": { + "bench_alpha": { + "summary": { + "generated_at": "2026-03-25T00:00:00Z", + "generated_at_unix": 1_742_862_400_u64, + "target": "ios", + "function": "bench_alpha", + "iterations": 3, + "warmup": 1, + "devices": ["iPhone 15-17.4"], + "device_summaries": [{ + "device": "iPhone 15-17.4", + "benchmarks": [{ + "function": "bench_alpha", + "samples": 3, + "mean_ns": 82_u64, + "median_ns": 82_u64, + "p95_ns": 84_u64, + "min_ns": 80_u64, + "max_ns": 84_u64 + }] + }] + }, + "benchmark_results": { + "iPhone 15-17.4": [{ + "function": "bench_alpha", + "samples": [80_u64, 82_u64, 84_u64] + }] + } + } + } + } + } + }); + let dir = tempfile::tempdir().expect("tempdir"); + let fake_python = crate::tests::write_fake_plot_python(dir.path()); + + let markdown = render_summary_markdown_from_output_with_plots_using_python( + &merged, + dir.path(), + plots::PlotMode::Require, + Some(&fake_python), + ) + .expect("render merged markdown with plots"); + + assert!(markdown.contains("## android")); + assert!(markdown.contains("## ios")); + assert!(markdown.contains("![alpha](plots/alpha.svg)")); + assert!(markdown.contains("![alpha](plots/alpha-ios.svg)")); + assert!(dir.path().join("plots/alpha.svg").exists()); + assert!(dir.path().join("plots/alpha-ios.svg").exists()); + } + + #[test] + fn build_summary_preserves_resource_usage_from_benchmark_results() { + let spec = RunSpec { + target: MobileTarget::Android, + function: "bench_nullifier_proving_only".into(), + iterations: 3, + warmup: 1, + devices: vec!["Google Pixel 8-14.0".into()], + browserstack: None, + ios_xcuitest: None, + }; + let local_report = json!({}); + let run_summary = RunSummary { + spec: spec.clone(), + artifacts: None, + local_report, + remote_run: None, + summary: empty_summary(&spec), + benchmark_results: Some(BTreeMap::from([( + "Google Pixel 8-14.0".to_string(), + vec![json!({ + "function": "bench_nullifier_proving_only", + "mean_ns": 125000000_u64, + "samples": [ + { "duration_ns": 120000000_u64 }, + { "duration_ns": 130000000_u64 } + ], + "resources": { + "elapsed_cpu_ms": 482, + "total_pss_kb": 654321, + "private_dirty_kb": 321000, + "native_heap_kb": 120000, + "java_heap_kb": 45000 + } + })], + )])), + performance_metrics: None, + }; + + let summary = build_summary(&run_summary).expect("build summary"); + let value = serde_json::to_value(summary).expect("serialize summary"); + let resource_usage = &value["device_summaries"][0]["benchmarks"][0]["resource_usage"]; + + assert_eq!(resource_usage["cpu_total_ms"], 482); + assert_eq!(resource_usage["peak_memory_kb"], 654321); + assert_eq!(resource_usage["total_pss_kb"], 654321); + assert_eq!(resource_usage["private_dirty_kb"], 321000); + assert_eq!(resource_usage["native_heap_kb"], 120000); + assert_eq!(resource_usage["java_heap_kb"], 45000); + } + + #[test] + fn build_summary_prefers_browserstack_peak_memory_for_ci_summary() { + let spec = RunSpec { + target: MobileTarget::Ios, + function: "bench_nullifier_proving_only".into(), + iterations: 3, + warmup: 1, + devices: vec!["iPhone 15-17.0".into()], + browserstack: None, + ios_xcuitest: None, + }; + let run_summary = RunSummary { + spec: spec.clone(), + artifacts: None, + local_report: json!({}), + remote_run: None, + summary: empty_summary(&spec), + benchmark_results: Some(BTreeMap::from([( + "iPhone 15-17.0".to_string(), + vec![json!({ + "function": "bench_nullifier_proving_only", + "mean_ns": 125000000_u64, + "samples": [ + { "duration_ns": 120000000_u64 }, + { "duration_ns": 130000000_u64 } + ], + "resources": { + "platform": "ios" + } + })], + )])), + performance_metrics: Some(BTreeMap::from([( + "iPhone 15-17.0".to_string(), + browserstack::PerformanceMetrics { + sample_count: 1, + memory: Some(browserstack::AggregateMemoryMetrics { + peak_mb: 243.57, + average_mb: 169.45, + min_mb: 169.45, + }), + cpu: Some(browserstack::AggregateCpuMetrics { + peak_percent: 12.52, + average_percent: 5.06, + min_percent: 5.06, + }), + snapshots: Vec::new(), + }, + )])), + }; + + let summary = build_summary(&run_summary).expect("build summary"); + let value = serde_json::to_value(summary).expect("serialize summary"); + let resource_usage = &value["device_summaries"][0]["benchmarks"][0]["resource_usage"]; + + assert_eq!(resource_usage["peak_memory_kb"], 249416); + assert_eq!(resource_usage["cpu_total_ms"], Value::Null); + } } #[cfg(test)] diff --git a/crates/mobench/src/plots.rs b/crates/mobench/src/plots.rs index c0e0f4e..a7d08d1 100644 --- a/crates/mobench/src/plots.rs +++ b/crates/mobench/src/plots.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use clap::ValueEnum; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -352,19 +352,25 @@ pub fn extract_function_plot_inputs_from_results_dir(dir: &Path) -> Result>(); - plots.sort_by(|left, right| { - left.function_name - .cmp(&right.function_name) - .then(left.target.cmp(&right.target)) - }); - Ok(plots) + Ok(finish_plot_inputs(builders)) +} + +pub fn extract_function_plot_inputs_from_output_value( + value: &Value, +) -> Result> { + let mut builders = BTreeMap::new(); + let mut nested_source_keys = BTreeSet::new(); + collect_from_output_value( + value, + Path::new("summary.json"), + &mut builders, + &mut nested_source_keys, + )?; + Ok(finish_plot_inputs(builders)) } -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum PlotMode { Auto, Off, @@ -382,6 +388,7 @@ pub struct RendererAssets { pub struct RenderedPlot { pub function_name: String, pub function_label: String, + pub target: String, pub output_path: PathBuf, pub relative_path: PathBuf, } @@ -511,6 +518,7 @@ fn render_single_plot( return Ok(RenderedPlot { function_name: input.function_name.clone(), function_label: input.function_label.clone(), + target: input.target.clone(), output_path, relative_path, }); @@ -783,6 +791,47 @@ fn collect_from_results_dir( Ok(()) } +fn collect_from_output_value( + value: &Value, + path: &Path, + builders: &mut BTreeMap<(String, String), PlotFunctionInputBuilder>, + nested_source_keys: &mut BTreeSet, +) -> Result<()> { + collect_from_value(value, path, false, builders, nested_source_keys)?; + + if let Some(functions) = value + .get("functions") + .and_then(|functions| functions.as_object()) + { + for entry in functions.values() { + collect_from_output_value(entry, path, builders, nested_source_keys)?; + } + } + + if let Some(targets) = value.get("targets").and_then(|targets| targets.as_object()) { + for entry in targets.values() { + collect_from_output_value(entry, path, builders, nested_source_keys)?; + } + } + + Ok(()) +} + +fn finish_plot_inputs( + builders: BTreeMap<(String, String), PlotFunctionInputBuilder>, +) -> Vec { + let mut plots = builders + .into_values() + .map(PlotFunctionInputBuilder::finish) + .collect::>(); + plots.sort_by(|left, right| { + left.target + .cmp(&right.target) + .then(left.function_name.cmp(&right.function_name)) + }); + plots +} + fn collect_from_value( value: &Value, path: &Path, From 5c5352d9fcb1ce5ca4493bea275bd454f13e0d10 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 20:46:31 -0700 Subject: [PATCH 108/196] chore: silence unused plot helper warnings --- crates/mobench/src/plots.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/mobench/src/plots.rs b/crates/mobench/src/plots.rs index a7d08d1..18573c7 100644 --- a/crates/mobench/src/plots.rs +++ b/crates/mobench/src/plots.rs @@ -346,6 +346,7 @@ printf 'ok' > "$output" ); } +#[cfg_attr(not(test), allow(dead_code))] pub fn extract_function_plot_inputs_from_results_dir(dir: &Path) -> Result> { let mut builders = BTreeMap::new(); let mut nested_source_keys = BTreeSet::new(); @@ -381,6 +382,7 @@ pub enum PlotMode { pub struct RendererAssets { _tempdir: ManagedTempDir, pub script_path: PathBuf, + #[allow(dead_code)] pub style_path: PathBuf, } @@ -758,6 +760,7 @@ impl PlotFunctionInputBuilder { } } +#[cfg_attr(not(test), allow(dead_code))] fn collect_from_results_dir( dir: &Path, builders: &mut BTreeMap<(String, String), PlotFunctionInputBuilder>, @@ -1011,6 +1014,7 @@ fn humanize_benchmark_name(name: &str) -> String { s.replace('_', "-") } +#[cfg_attr(not(test), allow(dead_code))] fn collect_json_files(dir: &Path, out: &mut Vec) -> Result<()> { let mut entries = fs::read_dir(dir) .with_context(|| format!("Failed to read results directory {}", dir.display()))? @@ -1030,6 +1034,7 @@ fn collect_json_files(dir: &Path, out: &mut Vec) -> Result<()> { Ok(()) } +#[cfg_attr(not(test), allow(dead_code))] fn read_json(path: &Path) -> Result { let content = fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; From 7c7a7d6d5fde62411a5dec5820d2e1b6ac4ae070 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 21:09:15 -0700 Subject: [PATCH 109/196] release: prepare v0.1.23 --- .github/workflows/reusable-bench.yml | 54 +- Cargo.lock | 8 +- Cargo.toml | 2 +- README.md | 10 + crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 4 +- crates/mobench/src/browserstack.rs | 209 +++++- crates/mobench/src/summarize.rs | 696 ++++++++++++++++-- .../android/bench_alpha/summary.json | 47 ++ .../android/bench_beta/summary.json | 47 ++ .../ci-artifact-root/android/summary.json | 38 + docs/schemas/summary-v1.schema.json | 13 +- 12 files changed, 1068 insertions(+), 62 deletions(-) create mode 100644 crates/mobench/tests/fixtures/ci-artifact-root/android/bench_alpha/summary.json create mode 100644 crates/mobench/tests/fixtures/ci-artifact-root/android/bench_beta/summary.json create mode 100644 crates/mobench/tests/fixtures/ci-artifact-root/android/summary.json diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 69ad007..e85220f 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -104,6 +104,7 @@ on: required: false permissions: + actions: read contents: read pull-requests: write @@ -270,8 +271,7 @@ jobs: --crate-path "$CRATE_PATH" \ $RELEASE_FLAG \ --fetch \ - --output-dir target/mobench/ci/ios \ - || echo "::warning::iOS benchmark run failed" + --output-dir target/mobench/ci/ios - name: Upload iOS results if: always() @@ -457,8 +457,7 @@ jobs: --crate-path "$CRATE_PATH" \ $RELEASE_FLAG \ --fetch \ - --output-dir target/mobench/ci/android \ - || echo "::warning::Android benchmark run failed" + --output-dir target/mobench/ci/android - name: Upload Android results if: always() @@ -625,6 +624,47 @@ jobs: app-id: ${{ secrets.MOBENCH_APP_ID }} private-key: ${{ secrets.MOBENCH_APP_PRIVATE_KEY }} + - name: Download baseline artifacts + if: steps.app-token.outcome == 'success' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + default_branch="${DEFAULT_BRANCH:-main}" + mkdir -p baseline + + workflow_id=$(gh api "repos/${GH_REPO}/actions/runs/${GITHUB_RUN_ID}" --jq '.workflow_id') + baseline_run_id=$(gh api "repos/${GH_REPO}/actions/workflows/${workflow_id}/runs?branch=${default_branch}&status=completed&per_page=20" \ + --jq ".workflow_runs[] | select(.id != ${GITHUB_RUN_ID} and .conclusion == \"success\") | .id" | head -1) + + if [ -z "${baseline_run_id}" ]; then + echo "::notice::No successful baseline run found on ${default_branch}; check runs will be descriptive only." + exit 0 + fi + + echo "Using baseline run ${baseline_run_id} from ${default_branch}" + for platform in ios android; do + artifact_name="mobench-results-${platform}" + destination="baseline/${platform}" + mkdir -p "${destination}" + + if gh run download "${baseline_run_id}" \ + --repo "${GH_REPO}" \ + --name "${artifact_name}" \ + --dir "${destination}" >/dev/null 2>&1; then + if [ -f "${destination}/summary.json" ]; then + echo "Downloaded baseline ${artifact_name}" + else + echo "::warning::Downloaded ${artifact_name} from run ${baseline_run_id}, but summary.json was missing." + fi + else + echo "::notice::Baseline artifact ${artifact_name} not found in run ${baseline_run_id}." + fi + done + - name: Create Check Run if: steps.app-token.outcome == 'success' shell: bash @@ -640,6 +680,11 @@ jobs: for dir in results/ios results/android; do if [ -d "$dir" ]; then PLATFORM=$(basename "$dir") + baseline_args=() + baseline_path="baseline/${PLATFORM}/summary.json" + if [ -f "$baseline_path" ]; then + baseline_args=(--baseline "$baseline_path") + fi cargo-mobench ci check-run \ --results-dir "$dir" \ --repo "$GH_REPO" \ @@ -647,6 +692,7 @@ jobs: --name "$CHECK_RUN_NAME — ${PLATFORM}" \ --annotation-path "${CRATE_PATH}/src/lib.rs" \ --regression-threshold-pct "$REGRESSION_THRESHOLD" \ + "${baseline_args[@]}" \ || true fi done diff --git a/Cargo.lock b/Cargo.lock index 1f85ac7..e7e7ac0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,7 +1017,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.22" +version = "0.1.23" dependencies = [ "anyhow", "clap", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.22" +version = "0.1.23" dependencies = [ "proc-macro2", "quote", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.22" +version = "0.1.23" dependencies = [ "anyhow", "include_dir", @@ -1522,7 +1522,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.22" +version = "0.1.23" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index 5d89629..a6cb4b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.22" +version = "0.1.23" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 81ea4ed..497a07b 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,16 @@ fn db_query(db: &Database) { ## Release Notes +### v0.1.23 + +- Added Sina-style per-function device comparison plots to local summaries: + - `cargo mobench ci run --plots ` + - `cargo mobench report summarize --plots ` +- Rendered one SVG plot per benchmark function in the `Device Comparison Plots` section of local markdown summaries. +- Switched summary resource reporting to `cpu_total_ms` and `peak_memory_kb`, and preserved BrowserStack-derived peak memory while backfilling CPU from raw benchmark results. +- Enabled BrowserStack app profiling on Android and iOS runs, including App Profiling v2 parsing for iOS peak-memory enrichment. +- Added baseline artifact download in the reusable CI workflow so `ci check-run` can compare PR results against the latest successful default-branch run. + ### v0.1.22 - Fixed BrowserStack result fetching so `cargo mobench ci run --fetch` falls back to downloaded session artifacts when live device logs do not expose benchmark JSON. diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index e578635..83e830a 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.22", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.23", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 128f048..62015d3 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.22", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.23", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true @@ -47,9 +47,9 @@ dotenvy = "0.15" time.workspace = true sha2 = "0.10" comfy-table = "7" +tempfile = "3" [dev-dependencies] -tempfile = "3" inventory = "0.3" sample-fns = { path = "../sample-fns" } jsonschema = "0.18" diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index 65255e6..6a8512c 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -241,6 +241,7 @@ impl BrowserStackClient { devices: devices.to_vec(), device_logs: true, disable_animations: true, + app_profiling: true, build_name: self.project.clone(), }; @@ -279,6 +280,7 @@ impl BrowserStackClient { test_suite: test_suite_url.to_string(), devices: devices.to_vec(), device_logs: true, + app_profiling: true, build_name: self.project.clone(), // Specify the test method to run (required by BrowserStack for XCUITest) only_testing: Some(vec![ @@ -906,6 +908,17 @@ impl BrowserStackClient { } } + if let Ok(app_profiling_v2) = self.get_app_profiling_v2(build_id, &device.session_id) { + if app_profiling_v2.sample_count > 0 { + println!(" Found App Profiling v2 metrics"); + device_performance_metrics = merge_performance_metrics( + Some(device_performance_metrics), + Some(app_profiling_v2), + ) + .unwrap_or_default(); + } + } + if let Some(results) = device_benchmark_results { benchmark_results.insert(device.device.clone(), results); } @@ -950,6 +963,18 @@ impl BrowserStackClient { }) } + /// Fetch App Profiling v2 metrics for a BrowserStack session. + pub fn get_app_profiling_v2( + &self, + build_id: &str, + session_id: &str, + ) -> Result { + let path = format!("/app-automate/builds/{build_id}/sessions/{session_id}/appprofiling/v2"); + let value = self.get_json(&path)?; + parse_app_profiling_v2_response(&value) + .with_context(|| format!("parsing App Profiling v2 for session {session_id}")) + } + /// Fetch build details with all sessions and performance data. pub fn get_build_summary(&self, build_id: &str, platform: &str) -> Result { let status = match platform { @@ -967,6 +992,9 @@ impl BrowserStackClient { .device_logs .as_ref() .and_then(|logs| self.extract_performance_metrics(logs).ok()); + let app_profiling_v2 = self + .get_app_profiling_v2(build_id, &device_session.session_id) + .ok(); sessions.push(SessionSummary { session_id: device_session.session_id.clone(), @@ -980,7 +1008,7 @@ impl BrowserStackClient { .map(|d| d.os_version.clone()) .unwrap_or_default(), duration_secs: details.as_ref().and_then(|d| d.duration), - performance: perf, + performance: merge_performance_metrics(perf, app_profiling_v2), }); } @@ -1444,6 +1472,112 @@ impl From for BuildStatus { } } +fn merge_performance_metrics( + base: Option, + preferred: Option, +) -> Option { + match (base, preferred) { + (None, None) => None, + (Some(base), None) => Some(base), + (None, Some(preferred)) => Some(preferred), + (Some(mut base), Some(preferred)) => { + if preferred.memory.is_some() { + base.memory = preferred.memory; + } + if preferred.cpu.is_some() { + base.cpu = preferred.cpu; + } + if !preferred.snapshots.is_empty() { + base.snapshots = preferred.snapshots; + } + base.sample_count = base.sample_count.max(preferred.sample_count); + Some(base) + } + } +} + +fn parse_app_profiling_v2_response(value: &Value) -> Result { + let data = value + .get("data") + .and_then(|data| data.as_object()) + .context("App Profiling v2 response missing data object")?; + + let mut selected_metrics = None; + + for (app_id, app_data) in data { + if app_id == "units" { + continue; + } + + let status = app_data.get("status").and_then(|status| status.as_str()); + let metrics = app_data.get("metrics"); + if status == Some("success") && metrics.is_some() { + selected_metrics = metrics; + break; + } + + if selected_metrics.is_none() && metrics.is_some() { + selected_metrics = metrics; + } + } + + let metrics = selected_metrics + .and_then(|metrics| metrics.as_object()) + .context("App Profiling v2 response missing metrics payload")?; + + let cpu_avg = metrics + .get("cpu") + .and_then(|cpu| cpu.get("avg")) + .and_then(|value| value.as_f64()); + let cpu_max = metrics + .get("cpu") + .and_then(|cpu| cpu.get("max")) + .and_then(|value| value.as_f64()); + let mem_avg = metrics + .get("mem") + .and_then(|mem| mem.get("avg")) + .and_then(|value| value.as_f64()); + let mem_max = metrics + .get("mem") + .and_then(|mem| mem.get("max")) + .and_then(|value| value.as_f64()); + + let cpu = match (cpu_avg, cpu_max) { + (None, None) => None, + (avg, max) => { + let average_percent = avg.or(max).unwrap_or_default(); + let peak_percent = max.or(avg).unwrap_or_default(); + Some(AggregateCpuMetrics { + peak_percent, + average_percent, + min_percent: average_percent.min(peak_percent), + }) + } + }; + + let memory = match (mem_avg, mem_max) { + (None, None) => None, + (avg, max) => { + let average_mb = avg.or(max).unwrap_or_default(); + let peak_mb = max.or(avg).unwrap_or_default(); + Some(AggregateMemoryMetrics { + peak_mb, + average_mb, + min_mb: average_mb.min(peak_mb), + }) + } + }; + + let sample_count = usize::from(cpu.is_some() || memory.is_some()); + + Ok(PerformanceMetrics { + sample_count, + memory, + cpu, + snapshots: Vec::new(), + }) +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct BuildRequest { @@ -1452,6 +1586,7 @@ struct BuildRequest { devices: Vec, device_logs: bool, disable_animations: bool, + app_profiling: bool, #[serde(skip_serializing_if = "Option::is_none")] build_name: Option, } @@ -1463,6 +1598,7 @@ struct XcuitestBuildRequest { test_suite: String, devices: Vec, device_logs: bool, + app_profiling: bool, #[serde(skip_serializing_if = "Option::is_none")] build_name: Option, #[serde(rename = "only-testing", skip_serializing_if = "Option::is_none")] @@ -2327,6 +2463,77 @@ Test completed assert_eq!(cpu.average_percent, 40.0); // (30 + 50) / 2 } + #[test] + fn parse_app_profiling_v2_response_extracts_memory_and_cpu() { + let metrics = parse_app_profiling_v2_response(&json!({ + "metadata": { + "device": "iPhone 15", + "os_version": "17" + }, + "data": { + "units": { + "cpu": "%", + "mem": "MB" + }, + "org.world.app": { + "status": "success", + "metrics": { + "cpu": { + "avg": 5.06, + "max": 12.52 + }, + "mem": { + "avg": 169.45, + "max": 243.57 + } + } + } + } + })) + .expect("parse v2"); + + assert_eq!(metrics.sample_count, 1); + let cpu = metrics.cpu.expect("cpu"); + assert!((cpu.average_percent - 5.06).abs() < 0.001); + assert!((cpu.peak_percent - 12.52).abs() < 0.001); + + let memory = metrics.memory.expect("memory"); + assert!((memory.average_mb - 169.45).abs() < 0.001); + assert!((memory.peak_mb - 243.57).abs() < 0.001); + } + + #[test] + fn build_request_serializes_with_app_profiling_enabled() { + let request = BuildRequest { + app: "bs://app".into(), + test_suite: "bs://suite".into(), + devices: vec!["Google Pixel 8-14.0".into()], + device_logs: true, + disable_animations: true, + build_name: Some("mobench".into()), + app_profiling: true, + }; + + let value = serde_json::to_value(&request).expect("serialize build request"); + assert_eq!(value["appProfiling"], true); + } + + #[test] + fn xcuitest_build_request_serializes_with_app_profiling_enabled() { + let request = XcuitestBuildRequest { + app: "bs://app".into(), + test_suite: "bs://suite".into(), + devices: vec!["iPhone 15-17".into()], + device_logs: true, + build_name: Some("mobench".into()), + only_testing: Some(vec!["BenchRunnerUITests/test".into()]), + app_profiling: true, + }; + + let value = serde_json::to_value(&request).expect("serialize xcuitest build request"); + assert_eq!(value["appProfiling"], true); + } + #[test] fn extract_benchmark_results_handles_ios_markers() { let client = BrowserStackClient::new( diff --git a/crates/mobench/src/summarize.rs b/crates/mobench/src/summarize.rs index cd38724..6116e8d 100644 --- a/crates/mobench/src/summarize.rs +++ b/crates/mobench/src/summarize.rs @@ -3,6 +3,7 @@ use anyhow::{Context, Result}; use comfy_table::{Attribute, Cell, ContentArrangement, Table, presets::UTF8_FULL}; use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; use std::path::{Path, PathBuf}; /// A fully-assembled summary ready for rendering. @@ -58,10 +59,18 @@ pub struct TimingStats { /// Resource usage metrics from BrowserStack session. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceUsage { - pub cpu_avg_percent: Option, - pub cpu_peak_percent: Option, - pub ram_avg_mb: Option, - pub ram_peak_mb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu_total_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub peak_memory_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_pss_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub private_dirty_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub native_heap_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub java_heap_kb: Option, } /// Parse a summary.json value into a [`SummarizeReport`]. @@ -188,7 +197,7 @@ fn parse_benchmark_entry(value: &serde_json::Value) -> Result { name, label, timing, - resource_usage: None, + resource_usage: parse_resource_usage(value), }) } @@ -206,22 +215,33 @@ fn humanize_benchmark_name(name: &str) -> String { /// Load all summary JSON files from a results directory. pub fn load_results_dir(dir: &Path) -> Result { let root_summary_path = dir.join("summary.json"); + let mut enrichment_values = Vec::new(); + let mut root_report = None; if root_summary_path.exists() { let content = std::fs::read_to_string(&root_summary_path) .with_context(|| format!("Failed to read {}", root_summary_path.display()))?; let value: serde_json::Value = serde_json::from_str(&content) .with_context(|| format!("Failed to parse {}", root_summary_path.display()))?; if let Ok(report) = parse_summary_value(&value) { - return Ok(report); + root_report = Some(report); + enrichment_values.push(value); } } let mut json_paths = Vec::new(); collect_json_files(dir, &mut json_paths)?; json_paths.retain(|path| path != &root_summary_path); + json_paths.sort_by(compare_summary_candidates); - let mut all_platforms = Vec::new(); + let mut all_platforms = root_report + .map(|report| report.platforms) + .unwrap_or_default(); let mut raw_candidates = Vec::new(); + let mut covered_summary_dirs = if all_platforms.is_empty() { + Vec::new() + } else { + vec![dir.to_path_buf()] + }; for path in json_paths { let content = std::fs::read_to_string(&path) @@ -230,6 +250,12 @@ pub fn load_results_dir(dir: &Path) -> Result { .with_context(|| format!("Failed to parse {}", path.display()))?; if let Ok(report) = parse_summary_value(&value) { + enrichment_values.push(value.clone()); + let summary_dir = path.parent().unwrap_or(dir); + if is_covered_by_canonical_summary(summary_dir, &covered_summary_dirs) { + continue; + } + covered_summary_dirs.push(summary_dir.to_path_buf()); all_platforms.extend(report.platforms); } else { raw_candidates.push((path, value)); @@ -248,9 +274,15 @@ pub fn load_results_dir(dir: &Path) -> Result { anyhow::bail!("No valid summary JSON files found in {}", dir.display()); } - Ok(SummarizeReport { + let mut report = SummarizeReport { platforms: all_platforms, - }) + }; + + for value in &enrichment_values { + enrich_report_with_summary_value(&mut report, value); + } + + Ok(report) } fn collect_json_files(dir: &Path, out: &mut Vec) -> Result<()> { @@ -272,6 +304,139 @@ fn collect_json_files(dir: &Path, out: &mut Vec) -> Result<()> { Ok(()) } +fn compare_summary_candidates(left: &PathBuf, right: &PathBuf) -> Ordering { + left.components() + .count() + .cmp(&right.components().count()) + .then_with(|| { + let left_is_summary = + left.file_name().and_then(|name| name.to_str()) == Some("summary.json"); + let right_is_summary = + right.file_name().and_then(|name| name.to_str()) == Some("summary.json"); + right_is_summary.cmp(&left_is_summary) + }) + .then_with(|| left.cmp(right)) +} + +fn is_covered_by_canonical_summary(path: &Path, canonical_dirs: &[PathBuf]) -> bool { + canonical_dirs + .iter() + .any(|dir| path == dir.as_path() || path.starts_with(dir)) +} + +fn enrich_report_with_summary_value(report: &mut SummarizeReport, value: &serde_json::Value) { + if let Ok(nested_report) = parse_summary_value(value) { + merge_resource_usage_from_report(report, &nested_report); + } + + let target = value + .get("summary") + .and_then(|summary| summary.get("target")) + .or_else(|| value.get("target")) + .and_then(|target| target.as_str()); + let benchmark_results = value + .get("benchmark_results") + .and_then(|results| results.as_object()); + + let (Some(target), Some(benchmark_results)) = (target, benchmark_results) else { + return; + }; + + for (device_name, entries) in benchmark_results { + let Some(entries) = entries.as_array() else { + continue; + }; + for entry in entries { + let Some(function) = entry.get("function").and_then(|function| function.as_str()) + else { + continue; + }; + let Some(resource_usage) = parse_resource_usage(entry) else { + continue; + }; + set_benchmark_resource_usage(report, target, device_name, function, resource_usage); + } + } +} + +fn merge_resource_usage_from_report(report: &mut SummarizeReport, nested_report: &SummarizeReport) { + for nested_platform in &nested_report.platforms { + for nested_benchmark in &nested_platform.benchmarks { + let Some(resource_usage) = nested_benchmark.resource_usage.clone() else { + continue; + }; + set_benchmark_resource_usage( + report, + &nested_platform.platform, + &nested_platform.device.name, + &nested_benchmark.name, + resource_usage, + ); + } + } +} + +fn set_benchmark_resource_usage( + report: &mut SummarizeReport, + target: &str, + device_name: &str, + function: &str, + resource_usage: ResourceUsage, +) { + for platform in &mut report.platforms { + if platform.platform != target || !device_names_match(&platform.device.name, device_name) { + continue; + } + if let Some(benchmark) = platform + .benchmarks + .iter_mut() + .find(|benchmark| benchmark.name == function) + { + if let Some(existing) = &mut benchmark.resource_usage { + existing.merge_missing(&resource_usage); + } else { + benchmark.resource_usage = Some(resource_usage.clone()); + } + } + } +} + +pub(crate) fn device_names_match(left: &str, right: &str) -> bool { + let left_name = parse_device_string(left).name; + let right_name = parse_device_string(right).name; + left_name == right_name +} + +fn session_matches_platform_row( + platform: &PlatformReport, + session: &crate::browserstack::SessionSummary, +) -> bool { + let session_is_ios = session.os.eq_ignore_ascii_case("ios") + || session.os.eq_ignore_ascii_case("iPhone") + || session.os.eq_ignore_ascii_case("iPad"); + let platform_is_ios = platform.platform == "ios"; + + if session_is_ios != platform_is_ios { + return false; + } + + if platform.device.name != "unknown" + && !device_names_match(&platform.device.name, &session.device) + { + return false; + } + + if platform.device.os_version != "unknown" + && !platform.device.os_version.is_empty() + && !session.os_version.is_empty() + && platform.device.os_version != session.os_version + { + return false; + } + + true +} + fn parse_raw_bench_report(path: &Path, value: &serde_json::Value) -> Result { let entries = match value { serde_json::Value::Array(items) => items @@ -427,6 +592,122 @@ fn default_device_info(platform: &str) -> DeviceInfo { } } +impl ResourceUsage { + fn is_empty(&self) -> bool { + self.cpu_total_ms.is_none() + && self.peak_memory_kb.is_none() + && self.total_pss_kb.is_none() + && self.private_dirty_kb.is_none() + && self.native_heap_kb.is_none() + && self.java_heap_kb.is_none() + } + + fn merge_missing(&mut self, other: &Self) { + if self.cpu_total_ms.is_none() { + self.cpu_total_ms = other.cpu_total_ms; + } + if self.peak_memory_kb.is_none() { + self.peak_memory_kb = other.peak_memory_kb; + } + if self.total_pss_kb.is_none() { + self.total_pss_kb = other.total_pss_kb; + } + if self.private_dirty_kb.is_none() { + self.private_dirty_kb = other.private_dirty_kb; + } + if self.native_heap_kb.is_none() { + self.native_heap_kb = other.native_heap_kb; + } + if self.java_heap_kb.is_none() { + self.java_heap_kb = other.java_heap_kb; + } + } +} + +fn parse_resource_usage(value: &serde_json::Value) -> Option { + value + .get("resource_usage") + .and_then(parse_resource_usage_object) + .or_else(|| value.get("resources").and_then(parse_resource_usage_object)) +} + +fn parse_resource_usage_object(value: &serde_json::Value) -> Option { + let object = value.as_object()?; + + let cpu_total_ms = object + .get("cpu_total_ms") + .or_else(|| object.get("elapsed_cpu_ms")) + .and_then(json_value_to_u64); + let total_pss_kb = object.get("total_pss_kb").and_then(json_value_to_u64); + let private_dirty_kb = object.get("private_dirty_kb").and_then(json_value_to_u64); + let native_heap_kb = object.get("native_heap_kb").and_then(json_value_to_u64); + let java_heap_kb = object.get("java_heap_kb").and_then(json_value_to_u64); + + let peak_memory_kb = object + .get("peak_memory_kb") + .and_then(json_value_to_u64) + .or_else(|| { + object + .get("ram_peak_mb") + .and_then(|value| value.as_f64()) + .map(|value| (value * 1024.0).round() as u64) + }) + .or_else(|| { + raw_peak_memory_kb(total_pss_kb, private_dirty_kb, native_heap_kb, java_heap_kb) + }); + + let resource_usage = ResourceUsage { + cpu_total_ms, + peak_memory_kb, + total_pss_kb, + private_dirty_kb, + native_heap_kb, + java_heap_kb, + }; + + (!resource_usage.is_empty()).then_some(resource_usage) +} + +fn json_value_to_u64(value: &serde_json::Value) -> Option { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok())) + .or_else(|| { + value + .as_f64() + .filter(|value| value.is_finite() && *value >= 0.0) + .map(|value| value.round() as u64) + }) +} + +fn raw_peak_memory_kb( + total_pss_kb: Option, + private_dirty_kb: Option, + native_heap_kb: Option, + java_heap_kb: Option, +) -> Option { + total_pss_kb + .or(private_dirty_kb) + .or_else(|| match (native_heap_kb, java_heap_kb) { + (Some(native), Some(java)) => Some(native + java), + (Some(native), None) => Some(native), + (None, Some(java)) => Some(java), + (None, None) => None, + }) +} + +fn format_cpu_total_ms(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "—".to_string()) +} + +fn format_peak_memory(value_kb: Option) -> String { + value_kb + .map(|value| format!("{:.2} MB", value as f64 / 1024.0)) + .unwrap_or_else(|| "—".to_string()) +} + /// Render the full report as terminal tables. pub fn render_table(report: &SummarizeReport) -> String { let mut output = String::new(); @@ -473,7 +754,7 @@ fn render_platform_table(platform: &PlatformReport) -> String { let mut headers = vec!["Benchmark", "Avg ms", "Best", "Worst", "Median", "P95"]; if has_resource_usage { - headers.extend(["CPU %", "RAM MB"]); + headers.extend(["CPU total (ms)", "Peak memory"]); } table.set_header( headers @@ -493,16 +774,8 @@ fn render_platform_table(platform: &PlatformReport) -> String { if has_resource_usage { if let Some(ru) = &bench.resource_usage { - row.push(Cell::new( - ru.cpu_avg_percent - .map(|v| format!("{v:.0}%")) - .unwrap_or_else(|| "—".to_string()), - )); - row.push(Cell::new( - ru.ram_avg_mb - .map(|v| format!("{v:.0}")) - .unwrap_or_else(|| "—".to_string()), - )); + row.push(Cell::new(format_cpu_total_ms(ru.cpu_total_ms))); + row.push(Cell::new(format_peak_memory(ru.peak_memory_kb))); } else { row.push(Cell::new("—")); row.push(Cell::new("—")); @@ -553,10 +826,10 @@ pub fn render_markdown(report: &SummarizeReport) -> String { if has_ru { output.push_str( - "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU % | RAM MB |\n", + "| Benchmark | Avg ms | Best | Worst | Median | P95 | CPU total (ms) | Peak memory |\n", ); output.push_str( - "|-----------|--------|------|-------|--------|-----|-------|--------|\n", + "|-----------|--------|------|-------|--------|-----|----------------|-------------|\n", ); } else { output.push_str("| Benchmark | Avg ms | Best | Worst | Median | P95 |\n"); @@ -578,12 +851,8 @@ pub fn render_markdown(report: &SummarizeReport) -> String { if let Some(ru) = &bench.resource_usage { row.push_str(&format!( " {} | {} |", - ru.cpu_avg_percent - .map(|v| format!("{v:.0}%")) - .unwrap_or_else(|| "—".into()), - ru.ram_avg_mb - .map(|v| format!("{v:.0}")) - .unwrap_or_else(|| "—".into()), + format_cpu_total_ms(ru.cpu_total_ms), + format_peak_memory(ru.peak_memory_kb), )); } else { row.push_str(" — | — |"); @@ -609,19 +878,11 @@ pub fn enrich_with_browserstack( build_summary: &crate::browserstack::BuildSummary, ) { for platform in &mut report.platforms { - // Find sessions that match this platform for session in &build_summary.sessions { - let session_is_ios = session.os.eq_ignore_ascii_case("ios") - || session.os.eq_ignore_ascii_case("iPhone") - || session.os.eq_ignore_ascii_case("iPad"); - let platform_is_ios = platform.platform == "ios"; - - // Skip if platform doesn't match - if session_is_ios != platform_is_ios { + if !session_matches_platform_row(platform, session) { continue; } - // Update device info from session details if !session.os.is_empty() { platform.device.os = session.os.clone(); platform.device.os_version = session.os_version.clone(); @@ -633,14 +894,22 @@ pub fn enrich_with_browserstack( // Enrich benchmarks with performance metrics if let Some(perf) = &session.performance { for bench in &mut platform.benchmarks { - if bench.resource_usage.is_none() { - bench.resource_usage = Some(ResourceUsage { - cpu_avg_percent: perf.cpu.as_ref().map(|c| c.average_percent), - cpu_peak_percent: perf.cpu.as_ref().map(|c| c.peak_percent), - ram_avg_mb: perf.memory.as_ref().map(|m| m.average_mb), - ram_peak_mb: perf.memory.as_ref().map(|m| m.peak_mb), - }); + let peak_memory_kb = perf + .memory + .as_ref() + .map(|memory| (memory.peak_mb * 1024.0).round() as u64); + if peak_memory_kb.is_none() { + continue; } + let resource_usage = bench.resource_usage.get_or_insert(ResourceUsage { + cpu_total_ms: None, + peak_memory_kb: None, + total_pss_kb: None, + private_dirty_kb: None, + native_heap_kb: None, + java_heap_kb: None, + }); + resource_usage.peak_memory_kb = peak_memory_kb; } } } @@ -656,6 +925,7 @@ pub fn render_json(report: &SummarizeReport) -> Result { mod tests { use super::*; use serde_json::json; + use std::path::PathBuf; fn sample_summary_json() -> serde_json::Value { json!({ @@ -820,6 +1090,139 @@ mod tests { ); } + #[test] + fn test_load_results_dir_prefers_canonical_target_summary_fixture() { + let fixture_dir = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ci-artifact-root"); + + let report = load_results_dir(&fixture_dir).unwrap(); + + assert_eq!(report.platforms.len(), 1); + assert_eq!(report.platforms[0].platform, "android"); + assert_eq!(report.platforms[0].device.name, "Google Pixel 8"); + assert_eq!(report.platforms[0].benchmarks.len(), 2); + assert_eq!( + report.platforms[0] + .benchmarks + .iter() + .map(|bench| bench.name.as_str()) + .collect::>(), + vec!["bench_alpha", "bench_beta"] + ); + } + + #[test] + fn test_load_results_dir_backfills_resource_usage_from_nested_summaries() { + let fixture_dir = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ci-artifact-root"); + + let report = load_results_dir(&fixture_dir).unwrap(); + let alpha = report.platforms[0] + .benchmarks + .iter() + .find(|bench| bench.name == "bench_alpha") + .expect("alpha benchmark"); + let beta = report.platforms[0] + .benchmarks + .iter() + .find(|bench| bench.name == "bench_beta") + .expect("beta benchmark"); + + assert_eq!( + alpha + .resource_usage + .as_ref() + .and_then(|usage| usage.cpu_total_ms), + Some(111) + ); + assert_eq!( + alpha + .resource_usage + .as_ref() + .and_then(|usage| usage.peak_memory_kb), + Some(222222) + ); + assert_eq!( + beta.resource_usage + .as_ref() + .and_then(|usage| usage.cpu_total_ms), + Some(333) + ); + assert_eq!( + beta.resource_usage + .as_ref() + .and_then(|usage| usage.peak_memory_kb), + Some(444444) + ); + } + + #[test] + fn test_load_results_dir_preserves_summary_peak_memory_when_raw_results_add_cpu() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("summary.json"), + serde_json::to_string(&json!({ + "summary": { + "generated_at": "2026-03-25T12:00:00Z", + "generated_at_unix": 1742904000, + "target": "ios", + "function": "bench_nullifier_proving_only", + "iterations": 3, + "warmup": 1, + "devices": ["iPhone 15-17.0"], + "device_summaries": [{ + "device": "iPhone 15-17.0", + "benchmarks": [{ + "function": "bench_nullifier_proving_only", + "samples": 3, + "mean_ns": 125000000_u64, + "median_ns": 125000000_u64, + "p95_ns": 130000000_u64, + "min_ns": 120000000_u64, + "max_ns": 130000000_u64, + "resource_usage": { + "peak_memory_kb": 249416 + } + }] + }] + }, + "benchmark_results": { + "iPhone 15-17.0": [{ + "function": "bench_nullifier_proving_only", + "mean_ns": 125000000_u64, + "samples": [ + 120000000_u64, + 130000000_u64 + ], + "resources": { + "elapsed_cpu_ms": 482 + } + }] + } + })) + .unwrap(), + ) + .unwrap(); + + let report = load_results_dir(dir.path()).unwrap(); + let benchmark = &report.platforms[0].benchmarks[0]; + + assert_eq!( + benchmark + .resource_usage + .as_ref() + .and_then(|usage| usage.cpu_total_ms), + Some(482) + ); + assert_eq!( + benchmark + .resource_usage + .as_ref() + .and_then(|usage| usage.peak_memory_kb), + Some(249416) + ); + } + #[test] fn test_load_results_dir_falls_back_to_raw_bench_report() { let dir = tempfile::tempdir().unwrap(); @@ -862,10 +1265,12 @@ mod tests { std_dev_ms: Some(35.2), }, resource_usage: Some(ResourceUsage { - cpu_avg_percent: Some(94.0), - cpu_peak_percent: Some(98.0), - ram_avg_mb: Some(623.0), - ram_peak_mb: Some(650.0), + cpu_total_ms: Some(482), + peak_memory_kb: Some(654321), + total_pss_kb: Some(654321), + private_dirty_kb: Some(321000), + native_heap_kb: Some(120000), + java_heap_kb: Some(45000), }), }], iterations: 30, @@ -914,6 +1319,88 @@ mod tests { assert!(output.contains("| Benchmark |")); } + #[test] + fn test_render_markdown_uses_cpu_total_and_peak_memory_columns() { + let report = parse_summary_value(&json!({ + "summary": { + "generated_at": "2026-02-26T12:00:00Z", + "target": "android", + "function": "bench_nullifier_proving_only", + "iterations": 30, + "warmup": 5, + "devices": ["Google Pixel 8-14.0"], + "device_summaries": [{ + "device": "Google Pixel 8-14.0", + "benchmarks": [{ + "function": "bench_nullifier_proving_only", + "samples": 30, + "mean_ns": 1204500000_u64, + "median_ns": 1198000000_u64, + "p95_ns": 1290000000_u64, + "min_ns": 1180200000_u64, + "max_ns": 1298100000_u64, + "resource_usage": { + "cpu_total_ms": 482, + "peak_memory_kb": 654321, + "total_pss_kb": 654321 + } + }] + }] + } + })) + .unwrap(); + + let output = render_markdown(&report); + + assert!(output.contains("CPU total (ms)")); + assert!(output.contains("Peak memory")); + assert!(output.contains("| 482 |")); + assert!(output.contains("638.99 MB")); + assert!(!output.contains("CPU %")); + assert!(!output.contains("RAM MB")); + } + + #[test] + fn test_render_table_uses_cpu_total_and_peak_memory_columns() { + let report = parse_summary_value(&json!({ + "summary": { + "generated_at": "2026-02-26T12:00:00Z", + "target": "android", + "function": "bench_nullifier_proving_only", + "iterations": 30, + "warmup": 5, + "devices": ["Google Pixel 8-14.0"], + "device_summaries": [{ + "device": "Google Pixel 8-14.0", + "benchmarks": [{ + "function": "bench_nullifier_proving_only", + "samples": 30, + "mean_ns": 1204500000_u64, + "median_ns": 1198000000_u64, + "p95_ns": 1290000000_u64, + "min_ns": 1180200000_u64, + "max_ns": 1298100000_u64, + "resource_usage": { + "cpu_total_ms": 482, + "peak_memory_kb": 654321, + "total_pss_kb": 654321 + } + }] + }] + } + })) + .unwrap(); + + let output = render_table(&report); + + assert!(output.contains("CPU total (ms)")); + assert!(output.contains("Peak memory")); + assert!(output.contains("482")); + assert!(output.contains("638.99 MB")); + assert!(!output.contains("CPU %")); + assert!(!output.contains("RAM MB")); + } + #[test] fn test_render_json_output() { let report = SummarizeReport { @@ -935,4 +1422,117 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); assert_eq!(parsed["platforms"][0]["platform"], "ios"); } + + #[test] + fn test_enrich_with_browserstack_matches_session_to_same_device_row() { + let mut report = SummarizeReport { + platforms: vec![ + PlatformReport { + platform: "android".to_string(), + device: DeviceInfo { + name: "Google Pixel 6".to_string(), + os: "Android".to_string(), + os_version: "14".to_string(), + chipset: None, + ram_gb: None, + }, + benchmarks: vec![BenchmarkResult { + name: "bench_alpha".to_string(), + label: "alpha".to_string(), + timing: TimingStats { + avg_ms: 10.0, + median_ms: 10.0, + best_ms: 10.0, + worst_ms: 10.0, + p95_ms: 10.0, + std_dev_ms: None, + }, + resource_usage: None, + }], + iterations: 5, + warmup: 1, + }, + PlatformReport { + platform: "android".to_string(), + device: DeviceInfo { + name: "Samsung Galaxy S24".to_string(), + os: "Android".to_string(), + os_version: "14".to_string(), + chipset: None, + ram_gb: None, + }, + benchmarks: vec![BenchmarkResult { + name: "bench_alpha".to_string(), + label: "alpha".to_string(), + timing: TimingStats { + avg_ms: 10.0, + median_ms: 10.0, + best_ms: 10.0, + worst_ms: 10.0, + p95_ms: 10.0, + std_dev_ms: None, + }, + resource_usage: None, + }], + iterations: 5, + warmup: 1, + }, + ], + }; + + let build_summary = crate::browserstack::BuildSummary { + build_id: "build-123".to_string(), + status: "done".to_string(), + sessions: vec![ + crate::browserstack::SessionSummary { + session_id: "session-pixel".to_string(), + device: "Google Pixel 6".to_string(), + os: "android".to_string(), + os_version: "14".to_string(), + duration_secs: Some(10), + performance: Some(crate::browserstack::PerformanceMetrics { + memory: Some(crate::browserstack::AggregateMemoryMetrics { + peak_mb: 100.0, + average_mb: 90.0, + min_mb: 80.0, + }), + cpu: None, + sample_count: 1, + snapshots: vec![], + }), + }, + crate::browserstack::SessionSummary { + session_id: "session-samsung".to_string(), + device: "Samsung Galaxy S24".to_string(), + os: "android".to_string(), + os_version: "14".to_string(), + duration_secs: Some(10), + performance: Some(crate::browserstack::PerformanceMetrics { + memory: Some(crate::browserstack::AggregateMemoryMetrics { + peak_mb: 200.0, + average_mb: 190.0, + min_mb: 180.0, + }), + cpu: None, + sample_count: 1, + snapshots: vec![], + }), + }, + ], + }; + + enrich_with_browserstack(&mut report, &build_summary); + + let pixel_memory = report.platforms[0].benchmarks[0] + .resource_usage + .as_ref() + .and_then(|usage| usage.peak_memory_kb); + let samsung_memory = report.platforms[1].benchmarks[0] + .resource_usage + .as_ref() + .and_then(|usage| usage.peak_memory_kb); + + assert_eq!(pixel_memory, Some(102_400)); + assert_eq!(samsung_memory, Some(204_800)); + } } diff --git a/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_alpha/summary.json b/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_alpha/summary.json new file mode 100644 index 0000000..5c0c3af --- /dev/null +++ b/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_alpha/summary.json @@ -0,0 +1,47 @@ +{ + "benchmark_results": { + "Google Pixel 8-14.0": [ + { + "function": "bench_alpha", + "mean_ns": 100000000, + "median_ns": 98000000, + "p95_ns": 120000000, + "min_ns": 95000000, + "max_ns": 123000000, + "samples": [95000000, 98000000, 100000000, 120000000, 123000000], + "resources": { + "elapsed_cpu_ms": 111, + "total_pss_kb": 222222, + "private_dirty_kb": 111111 + } + } + ] + }, + "summary": { + "generated_at": "2026-03-01T09:00:00Z", + "generated_at_unix": 1740819600, + "target": "android", + "function": "bench_alpha", + "iterations": 20, + "warmup": 5, + "devices": [ + "Google Pixel 8-14.0" + ], + "device_summaries": [ + { + "device": "Google Pixel 8-14.0", + "benchmarks": [ + { + "function": "bench_alpha", + "samples": 20, + "mean_ns": 100000000, + "median_ns": 98000000, + "p95_ns": 120000000, + "min_ns": 95000000, + "max_ns": 123000000 + } + ] + } + ] + } +} diff --git a/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_beta/summary.json b/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_beta/summary.json new file mode 100644 index 0000000..b404af9 --- /dev/null +++ b/crates/mobench/tests/fixtures/ci-artifact-root/android/bench_beta/summary.json @@ -0,0 +1,47 @@ +{ + "benchmark_results": { + "Google Pixel 8-14.0": [ + { + "function": "bench_beta", + "mean_ns": 200000000, + "median_ns": 198000000, + "p95_ns": 220000000, + "min_ns": 190000000, + "max_ns": 225000000, + "samples": [190000000, 198000000, 200000000, 220000000, 225000000], + "resources": { + "elapsed_cpu_ms": 333, + "total_pss_kb": 444444, + "private_dirty_kb": 222222 + } + } + ] + }, + "summary": { + "generated_at": "2026-03-01T09:05:00Z", + "generated_at_unix": 1740819900, + "target": "android", + "function": "bench_beta", + "iterations": 20, + "warmup": 5, + "devices": [ + "Google Pixel 8-14.0" + ], + "device_summaries": [ + { + "device": "Google Pixel 8-14.0", + "benchmarks": [ + { + "function": "bench_beta", + "samples": 20, + "mean_ns": 200000000, + "median_ns": 198000000, + "p95_ns": 220000000, + "min_ns": 190000000, + "max_ns": 225000000 + } + ] + } + ] + } +} diff --git a/crates/mobench/tests/fixtures/ci-artifact-root/android/summary.json b/crates/mobench/tests/fixtures/ci-artifact-root/android/summary.json new file mode 100644 index 0000000..77b4310 --- /dev/null +++ b/crates/mobench/tests/fixtures/ci-artifact-root/android/summary.json @@ -0,0 +1,38 @@ +{ + "summary": { + "generated_at": "2026-03-01T10:00:00Z", + "generated_at_unix": 1740823200, + "target": "android", + "function": "multiple", + "iterations": 20, + "warmup": 5, + "devices": [ + "Google Pixel 8-14.0" + ], + "device_summaries": [ + { + "device": "Google Pixel 8-14.0", + "benchmarks": [ + { + "function": "bench_alpha", + "samples": 20, + "mean_ns": 100000000, + "median_ns": 98000000, + "p95_ns": 120000000, + "min_ns": 95000000, + "max_ns": 123000000 + }, + { + "function": "bench_beta", + "samples": 20, + "mean_ns": 200000000, + "median_ns": 198000000, + "p95_ns": 220000000, + "min_ns": 190000000, + "max_ns": 225000000 + } + ] + } + ] + } +} diff --git a/docs/schemas/summary-v1.schema.json b/docs/schemas/summary-v1.schema.json index 4c7a254..a1ba4d7 100644 --- a/docs/schemas/summary-v1.schema.json +++ b/docs/schemas/summary-v1.schema.json @@ -47,7 +47,18 @@ "median_ns": { "type": ["integer", "null"] }, "p95_ns": { "type": ["integer", "null"] }, "min_ns": { "type": ["integer", "null"] }, - "max_ns": { "type": ["integer", "null"] } + "max_ns": { "type": ["integer", "null"] }, + "resource_usage": { + "type": ["object", "null"], + "properties": { + "cpu_total_ms": { "type": ["integer", "null"] }, + "peak_memory_kb": { "type": ["integer", "null"] }, + "total_pss_kb": { "type": ["integer", "null"] }, + "private_dirty_kb": { "type": ["integer", "null"] }, + "native_heap_kb": { "type": ["integer", "null"] }, + "java_heap_kb": { "type": ["integer", "null"] } + } + } } } } From 91672b6e9267701e84c662d1527c98738754f458 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Wed, 25 Mar 2026 21:16:18 -0700 Subject: [PATCH 110/196] docs: update ci and plotting docs for v0.1.23 --- .github/workflows/mobile-bench-selftest.yml | 1 + README.md | 10 +++++++--- docs/CONTRACT_CI_V1.md | 13 +++++++++++++ docs/MIGRATION_GUIDE.md | 13 +++++++++++-- docs/adr/0001-mobench-ci-contract-v1.md | 6 ++++-- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.github/workflows/mobile-bench-selftest.yml b/.github/workflows/mobile-bench-selftest.yml index 4ac8b3a..c0e878f 100644 --- a/.github/workflows/mobile-bench-selftest.yml +++ b/.github/workflows/mobile-bench-selftest.yml @@ -21,6 +21,7 @@ on: default: "1" permissions: + actions: read contents: read pull-requests: write diff --git a/README.md b/README.md index 497a07b..4b6d5f8 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,10 @@ cargo mobench fixture cache-key cargo mobench summary target/mobench/results.json # CI one-command orchestration with stable outputs -cargo mobench ci run --target android --function sample_fns::fibonacci --local-only +cargo mobench ci run --target android --function sample_fns::fibonacci --local-only --plots auto # Reporting helpers from standardized outputs -cargo mobench report summarize --summary target/mobench/ci/summary.json +cargo mobench report summarize --summary target/mobench/ci/summary.json --plots auto cargo mobench report github --pr 123 --summary target/mobench/ci/summary.json ``` @@ -88,6 +88,9 @@ CI contract outputs are written to `target/mobench/ci/`: - `summary.json` - `summary.md` - `results.csv` +- `plots/*.svg` when local plot rendering is enabled + +Local summary renderers (`ci run --plots ...` and `report summarize --plots ...`) append a `Device Comparison Plots` section with one Sina-style SVG per benchmark function. Summary resource fields use `cpu_total_ms` and `peak_memory_kb`; Android raw resource stats are preserved and iOS peak memory is enriched from BrowserStack app profiling when available. ## Configuration @@ -112,11 +115,12 @@ default_iterations = 100 default_warmup = 10 ``` -Resolution precedence in `0.1.22` is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence is: explicit CLI flags (`--project-root`, `--crate-path`) → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. CLI flags override config file values when provided. - In `cargo mobench run --config ` mode, `--device-matrix ` overrides `device_matrix` from the config file. - For regression comparisons, `--baseline` should point to a previous run summary; if it resolves to the same output path, mobench snapshots the prior file before writing the candidate summary. +- In the reusable GitHub workflow, the default baseline source is the latest successful run on the repository default branch when matching artifacts are available. - `cargo mobench verify --smoke-test` is only supported for benchmark crates linked into the `mobench` CLI binary. External crates discovered through `mobench.toml`, `--project-root`, or `--crate-path` should use `cargo mobench list` and `cargo mobench verify --check-artifacts`. ## Project docs diff --git a/docs/CONTRACT_CI_V1.md b/docs/CONTRACT_CI_V1.md index 447e992..4cc9fc5 100644 --- a/docs/CONTRACT_CI_V1.md +++ b/docs/CONTRACT_CI_V1.md @@ -27,6 +27,7 @@ It covers: - Iteration controls: `--iterations`, `--warmup` - Device selection: `--devices`, `--device-matrix`, `--device-tags` - Runtime mode: `--local-only`, `--release`, `--fetch` +- Local summary rendering: `--plots ` - iOS artifacts: `--ios-app`, `--ios-test-suite` - Regression mode: `--baseline`, `--regression-threshold-pct` - Output path: `--output-dir` @@ -54,6 +55,9 @@ Required files: - `summary.md` - `results.csv` +Optional additive artifacts: +- `plots/*.svg` when local plot rendering is enabled for `ci run` + `summary.json` MUST include: - run summary data - `ci.metadata` object with: @@ -67,6 +71,15 @@ Required files: - `summary_md` - `results_csv` +Additive summary fields currently emitted by v1 include: +- per-benchmark `resource_usage.cpu_total_ms` +- per-benchmark `resource_usage.peak_memory_kb` +- optional raw memory breakdown fields such as `total_pss_kb`, `private_dirty_kb`, `native_heap_kb`, and `java_heap_kb` + +Behavior notes: +- `summary.md` may contain relative image links into `plots/` for local viewing. These links are additive and are not required for contract consumers. +- The reusable workflow may resolve a baseline from the latest successful default-branch run and pass it explicitly to `ci check-run`; this does not change the required output set. + Machine-readable schema artifacts: - `docs/schemas/summary-v1.schema.json` - `docs/schemas/ci-contract-v1.schema.json` diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index bea46c6..90005c5 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -6,6 +6,7 @@ This guide migrates custom BrowserStack benchmark CI flows to the standardized ` - One-command orchestration via `cargo mobench ci run` - Stable contract outputs: `summary.json`, `summary.md`, `results.csv` +- Optional local plot artifacts under `plots/*.svg` - Optional sticky PR comments and deterministic matrix resolution ## Old -> New Mapping @@ -30,8 +31,8 @@ This guide migrates custom BrowserStack benchmark CI flows to the standardized ` | Fixture setup | `cargo mobench fixture init` | | Fixture verify | `cargo mobench fixture verify --config bench-config.toml` | | Fixture cache key | `cargo mobench fixture cache-key --config bench-config.toml --format json` | -| CI orchestration | `cargo mobench ci run --target both --function sample_fns::fibonacci --local-only` | -| Summary markdown | `cargo mobench report summarize --summary target/mobench/ci/summary.json` | +| CI orchestration | `cargo mobench ci run --target both --function sample_fns::fibonacci --local-only --plots auto` | +| Summary markdown | `cargo mobench report summarize --summary target/mobench/ci/summary.json --plots auto` | | Sticky PR comment | `cargo mobench report github --pr 123 --summary target/mobench/ci/summary.json --publish` | ## Minimal Reference Workflow @@ -74,6 +75,14 @@ jobs: - `command` is allow-listed to `cargo mobench ci run` and `cargo mobench run`. - `ci` only appends `--ci` when `command: cargo mobench run`. - Prefer multiline `run-args` with explicit quoting for values containing spaces. +- If you call `.github/workflows/reusable-bench.yml` directly, the caller workflow should grant `actions: read` in addition to any PR-comment permissions so baseline artifact lookup can read prior workflow runs. + +### Summary output notes + +- `summary.json`, `summary.md`, and `results.csv` remain the stable required outputs. +- `plots/*.svg` is additive and only appears when local plot rendering is enabled and a Python + Matplotlib runtime is available, or when `--plots require` is used successfully. +- Local markdown summaries now include `cpu_total_ms` and `peak_memory_kb` instead of percentage/average-RAM columns. +- The reusable workflow attempts to compare against the latest successful default-branch run by downloading its per-platform `summary.json` artifacts before calling `ci check-run`. ## Compatibility Notes diff --git a/docs/adr/0001-mobench-ci-contract-v1.md b/docs/adr/0001-mobench-ci-contract-v1.md index bf5d5a7..0394dcf 100644 --- a/docs/adr/0001-mobench-ci-contract-v1.md +++ b/docs/adr/0001-mobench-ci-contract-v1.md @@ -20,6 +20,7 @@ Included: - error taxonomy categories for CI-oriented validation - deterministic device-matrix override semantics (`--device-matrix` over config value when both are provided) - baseline comparison safety when baseline and candidate paths overlap +- additive local summary plot artifacts and resource-usage fields that do not modify the required v1 output set Excluded (non-goals): - provider-specific API payloads @@ -47,9 +48,10 @@ Excluded (non-goals): - v1 default reporting mode is descriptive-only. - Threshold gating remains explicit and opt-in. -### Baseline default (v1.1 planning) +### Baseline default -- Default baseline source is previous successful run, with pinned artifacts supported explicitly. +- The reusable workflow resolves the baseline from the latest successful default-branch run when matching artifacts are available. +- Pinned baseline artifacts remain supported explicitly through `--baseline`. ### Minimum supported CI environments/toolchains From 449fc7e45b040d1a14fb9f7bcb38352353540290 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 11:36:33 -0700 Subject: [PATCH 111/196] fix: use unified BrowserStack device inventory --- crates/mobench/src/browserstack.rs | 185 +++++++++++++++++++++++++---- 1 file changed, 164 insertions(+), 21 deletions(-) diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index 6a8512c..e0abadc 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -345,37 +345,32 @@ impl BrowserStackClient { Ok(()) } + fn fetch_devices_inventory(&self) -> Result> { + let json = self.get_json("app-automate/devices.json")?; + parse_device_list(json, "devices") + } + /// List available Android devices for Espresso testing. pub fn list_espresso_devices(&self) -> Result> { - let json = self.get_json("app-automate/espresso/v2/devices")?; - parse_device_list(json, "espresso") + Ok(self + .fetch_devices_inventory()? + .into_iter() + .filter(|device| device.os.eq_ignore_ascii_case("android")) + .collect()) } /// List available iOS devices for XCUITest testing. pub fn list_xcuitest_devices(&self) -> Result> { - let json = self.get_json("app-automate/xcuitest/v2/devices")?; - parse_device_list(json, "xcuitest") + Ok(self + .fetch_devices_inventory()? + .into_iter() + .filter(|device| device.os.eq_ignore_ascii_case("ios")) + .collect()) } /// List all available devices (both Android and iOS). pub fn list_all_devices(&self) -> Result> { - let mut devices = Vec::new(); - - match self.list_espresso_devices() { - Ok(android_devices) => devices.extend(android_devices), - Err(e) => { - eprintln!("Warning: Failed to fetch Android devices: {}", e); - } - } - - match self.list_xcuitest_devices() { - Ok(ios_devices) => devices.extend(ios_devices), - Err(e) => { - eprintln!("Warning: Failed to fetch iOS devices: {}", e); - } - } - - Ok(devices) + self.fetch_devices_inventory() } /// Validate device specifications against available devices. @@ -1896,6 +1891,90 @@ mod tests { use super::*; use anyhow::anyhow; use serde_json::json; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + use std::thread; + use std::time::{Duration, Instant}; + + fn test_client_with_base_url(base_url: impl Into) -> BrowserStackClient { + BrowserStackClient::new( + BrowserStackAuth { + username: "user".into(), + access_key: "key".into(), + }, + None, + ) + .unwrap() + .with_base_url(base_url) + } + + fn spawn_browserstack_json_server( + payload: Value, + ) -> ( + String, + Arc>>, + thread::JoinHandle<()>, + ) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + listener + .set_nonblocking(true) + .expect("configure nonblocking listener"); + let addr = listener.local_addr().expect("read test server address"); + let paths = Arc::new(Mutex::new(Vec::new())); + let recorded_paths = Arc::clone(&paths); + let body = payload.to_string(); + + let handle = thread::spawn(move || { + let mut last_activity = Instant::now(); + loop { + match listener.accept() { + Ok((mut stream, _peer)) => { + last_activity = Instant::now(); + + let mut buf = [0_u8; 4096]; + let bytes_read = stream.read(&mut buf).expect("read request"); + let request = String::from_utf8_lossy(&buf[..bytes_read]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/") + .to_string(); + + recorded_paths.lock().unwrap().push(path.clone()); + + let (status, response_body) = if path == "/app-automate/devices.json" { + ("200 OK", body.clone()) + } else { + ( + "404 Not Found", + format!("{{\"error\":\"unexpected path: {path}\"}}"), + ) + }; + + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if last_activity.elapsed() >= Duration::from_millis(250) { + break; + } + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept request: {error}"), + } + } + }); + + (format!("http://{addr}"), paths, handle) + } #[test] fn rejects_missing_artifact() { @@ -2873,6 +2952,70 @@ BENCH_REPORT_JSON_END assert_eq!(devices[1].device, "iPhone 14"); } + #[test] + fn device_discovery_uses_unified_inventory_and_filters_by_os() { + let payload = json!([ + { + "device": "Google Pixel 8", + "os": "ANDROID", + "os_version": "14.0", + "available": true + }, + { + "device": "iPhone 15", + "os": "iOS", + "os_version": "17", + "available": true + } + ]); + let (base_url, paths, handle) = spawn_browserstack_json_server(payload); + let client = test_client_with_base_url(base_url); + + let espresso = client.list_espresso_devices(); + let xcuitest = client.list_xcuitest_devices(); + let all = client.list_all_devices(); + + handle.join().expect("join test server"); + + let espresso = espresso.expect("fetch Android devices from unified inventory"); + let xcuitest = xcuitest.expect("fetch iOS devices from unified inventory"); + let all = all.expect("fetch all devices from unified inventory"); + + assert_eq!( + espresso + .iter() + .map(BrowserStackDevice::identifier) + .collect::>(), + vec!["Google Pixel 8-14.0".to_string()] + ); + assert_eq!( + xcuitest + .iter() + .map(BrowserStackDevice::identifier) + .collect::>(), + vec!["iPhone 15-17".to_string()] + ); + assert_eq!( + all.iter() + .map(BrowserStackDevice::identifier) + .collect::>(), + vec![ + "Google Pixel 8-14.0".to_string(), + "iPhone 15-17".to_string() + ] + ); + + let paths = paths.lock().unwrap().clone(); + assert_eq!( + paths, + vec![ + "/app-automate/devices.json".to_string(), + "/app-automate/devices.json".to_string(), + "/app-automate/devices.json".to_string(), + ] + ); + } + #[test] fn extract_results_from_session_artifacts_prefers_bench_report_json() { let client = BrowserStackClient::new( From ba2deb5c13473b2544079069bc2f07359cae1b83 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 11:53:01 -0700 Subject: [PATCH 112/196] chore: bump version to 0.1.24 --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- README.md | 6 ++++++ crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench/Cargo.toml | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7e7ac0..913fb9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,7 +1017,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.23" +version = "0.1.24" dependencies = [ "anyhow", "clap", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.23" +version = "0.1.24" dependencies = [ "proc-macro2", "quote", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.23" +version = "0.1.24" dependencies = [ "anyhow", "include_dir", @@ -1522,7 +1522,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.23" +version = "0.1.24" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index a6cb4b4..a471713 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.23" +version = "0.1.24" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 4b6d5f8..bd5efac 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,12 @@ fn db_query(db: &Database) { ## Release Notes +### v0.1.24 + +- Switched BrowserStack device discovery to the unified `app-automate/devices.json` inventory for Android, iOS, and combined device listing. +- Filtered unified BrowserStack inventory results locally by OS so Espresso resolution stays Android-only and XCUITest resolution stays iOS-only. +- Added regression coverage for mixed Android+iOS BrowserStack inventories used by device-resolution commands. + ### v0.1.23 - Added Sina-style per-function device comparison plots to local summaries: diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index 83e830a..a87914b 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.23", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.24", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 62015d3..1ccc23f 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.23", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.24", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true From 15038eed39be4b6ff8dfbb2cacdf10288c9421d2 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 14:53:48 -0700 Subject: [PATCH 113/196] feat: add experimental profiling session commands (#18) * docs: add eng-25 profiling design and plan * feat: add experimental profiling session commands * style: format browserstack test helper * fix: preserve profile session history and manifest metadata * test: remove timer-resolution assumption from noop benchmark * release: prepare 0.1.24 * chore: refresh lockfile for 0.1.24 * release: prepare 0.1.25 --------- Co-authored-by: dcbuilder.eth --- BUILD.md | 15 + CLAUDE.md | 6 +- Cargo.lock | 8 +- Cargo.toml | 2 +- README.md | 23 + TESTING.md | 27 + crates/mobench-sdk/Cargo.toml | 2 +- crates/mobench-sdk/src/timing.rs | 6 +- crates/mobench/Cargo.toml | 2 +- crates/mobench/README.md | 23 +- crates/mobench/src/browserstack.rs | 6 +- crates/mobench/src/lib.rs | 66 ++ crates/mobench/src/profile.rs | 683 ++++++++++++++++++ .../2026-03-26-eng-25-profiling-design.md | 272 +++++++ ...6-03-26-eng-25-profiling-implementation.md | 524 ++++++++++++++ 15 files changed, 1645 insertions(+), 20 deletions(-) create mode 100644 crates/mobench/src/profile.rs create mode 100644 docs/plans/2026-03-26-eng-25-profiling-design.md create mode 100644 docs/plans/2026-03-26-eng-25-profiling-implementation.md diff --git a/BUILD.md b/BUILD.md index 6d4aae7..0525273 100644 --- a/BUILD.md +++ b/BUILD.md @@ -530,6 +530,21 @@ Run host-side Rust tests: cargo test --all ``` +## Experimental Profiling Prerequisites + +The experimental `cargo mobench profile ...` commands currently write planned +profile-session artifacts and backend-specific output layouts. Each run is +written under `target/mobench/profile//`, while +`target/mobench/profile/profile.json` and `summary.md` track the latest session. +The commands do not yet invoke native profiling tools automatically, but the +expected local toolchain is: + +- Android: `adb` plus `simpleperf` +- iOS: `xcrun` plus `xctrace` + +BrowserStack remains an execution target for benchmark runs, but native +profiling through BrowserStack is not supported by the current MVP command path. + ## Additional Documentation - **`TESTING.md`**: Comprehensive testing guide with troubleshooting diff --git a/CLAUDE.md b/CLAUDE.md index ad2c1a8..c60107d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co mobile-bench-rs (now **mobench**) is a mobile benchmarking SDK for Rust that enables developers to benchmark Rust functions on real Android and iOS devices via BrowserStack. It provides a library-first design with a `#[benchmark]` attribute macro and CLI tools for building, testing, and running benchmarks. -**Published on crates.io as the mobench ecosystem (v0.1.22):** +**Published on crates.io as the mobench ecosystem (v0.1.25):** - **[mobench](https://crates.io/crates/mobench)** - CLI tool for mobile benchmarking - **[mobench-sdk](https://crates.io/crates/mobench-sdk)** - Core SDK library with timing harness and build automation @@ -180,7 +180,7 @@ cargo mobench verify --target android --check-artifacts Use `cargo mobench build --target ` for local or CI builds. The CLI handles library builds, binding generation, and app packaging without extra scripts. -In `mobench 0.1.22`, build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. +Build/run/list/verify/package commands resolve the benchmark crate and project root from `--project-root`, `--crate-path`, `mobench.toml`, Cargo workspace metadata, or git root before falling back to `bench-mobile/`. **Important iOS Build Details:** @@ -594,7 +594,7 @@ The workflow supports manual dispatch with platform selection: ```toml [dependencies] -mobench-sdk = "0.1.22" +mobench-sdk = "0.1.25" inventory = "0.3" ``` diff --git a/Cargo.lock b/Cargo.lock index 913fb9c..9759f8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,7 +1017,7 @@ dependencies = [ [[package]] name = "mobench" -version = "0.1.24" +version = "0.1.25" dependencies = [ "anyhow", "clap", @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "mobench-macros" -version = "0.1.24" +version = "0.1.25" dependencies = [ "proc-macro2", "quote", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "mobench-sdk" -version = "0.1.24" +version = "0.1.25" dependencies = [ "anyhow", "include_dir", @@ -1522,7 +1522,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-fns" -version = "0.1.24" +version = "0.1.25" dependencies = [ "camino", "mobench-sdk", diff --git a/Cargo.toml b/Cargo.toml index a471713..0b6e1ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.24" +version = "0.1.25" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index bd5efac..b8374f5 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,10 @@ cargo mobench ci run --target android --function sample_fns::fibonacci --local-o # Reporting helpers from standardized outputs cargo mobench report summarize --summary target/mobench/ci/summary.json --plots auto cargo mobench report github --pr 123 --summary target/mobench/ci/summary.json + +# Experimental profiling session contract +cargo mobench profile run --target android --function sample_fns::fibonacci --backend android-native +cargo mobench profile summarize --profile target/mobench/profile/profile.json ``` CI contract outputs are written to `target/mobench/ci/`: @@ -92,6 +96,17 @@ CI contract outputs are written to `target/mobench/ci/`: Local summary renderers (`ci run --plots ...` and `report summarize --plots ...`) append a `Device Comparison Plots` section with one Sina-style SVG per benchmark function. Summary resource fields use `cpu_total_ms` and `peak_memory_kb`; Android raw resource stats are preserved and iOS peak memory is enriched from BrowserStack app profiling when available. +Experimental profiling commands write each planned session under +`target/mobench/profile//` and also refresh top-level +`target/mobench/profile/profile.json` and `summary.md` as convenience copies of +the latest run. Backend-specific raw and processed artifact directories are +created under each run-scoped `artifacts/` tree, and the normalized manifest +records the selected provider and requested output format. The current +implementation captures the profile-session contract and platform-specific +artifact layout; it does not yet execute native capture tools automatically. +BrowserStack-backed native profiling backends fail explicitly rather than +silently degrading. + ## Configuration mobench supports a `mobench.toml` configuration file for project settings: @@ -210,6 +225,14 @@ fn db_query(db: &Database) { ## Release Notes +### v0.1.25 + +- Added experimental `cargo mobench profile run|summarize` commands for a normalized local profiling session contract across Android and iOS. +- Profile sessions now write run-scoped artifacts under `target/mobench/profile//` and refresh top-level latest-session `profile.json` and `summary.md` convenience files. +- Profile manifests now preserve the selected provider and requested output format, and the CLI rejects unsupported format/backend combinations explicitly instead of silently planning the wrong artifacts. +- Updated the profiling smoke-test docs to use working `cargo run -p mobench --bin mobench -- ...` invocations from the repo root. +- Stabilized the SDK timing test suite by removing a timer-resolution assumption from the noop benchmark test. + ### v0.1.24 - Switched BrowserStack device discovery to the unified `app-automate/devices.json` inventory for Android, iOS, and combined device listing. diff --git a/TESTING.md b/TESTING.md index b64406c..30ddf0f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -648,6 +648,33 @@ cargo mobench compare \ **Note**: The `--release` flag is recommended for BrowserStack runs to reduce APK size (debug: ~544MB, release: ~133MB) and prevent upload timeouts. +### Profiling Command Smoke Checks + +The profiling subsystem currently focuses on the normalized session contract and +backend-specific artifact planning. Use these commands as the first validation +layer: + +```bash +# Profile parser + contract + backend planning tests +cargo test -p mobench profile_ + +# Android planned session +cargo run -p mobench --bin mobench -- profile run \ + --target android \ + --function sample_fns::fibonacci \ + --backend android-native + +# Render markdown summary from the generated manifest +cargo run -p mobench --bin mobench -- profile summarize \ + --profile target/mobench/profile/profile.json +``` + +The current MVP should write a run-scoped session under +`target/mobench/profile//` plus top-level latest-session copies at +`target/mobench/profile/profile.json` and `target/mobench/profile/summary.md`. +Backend-specific planned artifact paths should live under the run-scoped +`artifacts/` tree. + ### Adding New Test Functions 1. Add function to `crates/sample-fns/src/lib.rs` diff --git a/crates/mobench-sdk/Cargo.toml b/crates/mobench-sdk/Cargo.toml index a87914b..21572f0 100644 --- a/crates/mobench-sdk/Cargo.toml +++ b/crates/mobench-sdk/Cargo.toml @@ -32,7 +32,7 @@ runner-only = [] [dependencies] # Proc macros (only with full feature) -mobench-macros = { version = "0.1.24", path = "../mobench-macros", optional = true } +mobench-macros = { version = "0.1.25", path = "../mobench-macros", optional = true } # Registry (only with full feature) inventory = { workspace = true, optional = true } diff --git a/crates/mobench-sdk/src/timing.rs b/crates/mobench-sdk/src/timing.rs index 2113ad8..6662872 100644 --- a/crates/mobench-sdk/src/timing.rs +++ b/crates/mobench-sdk/src/timing.rs @@ -668,13 +668,13 @@ mod tests { use super::*; #[test] - fn runs_benchmark() { + fn runs_benchmark_collects_requested_samples() { let spec = BenchSpec::new("noop", 3, 1).unwrap(); let report = run_closure(spec, || Ok(())).unwrap(); assert_eq!(report.samples.len(), 3); - let non_zero = report.samples.iter().filter(|s| s.duration_ns > 0).count(); - assert!(non_zero >= 1); + assert_eq!(report.spec.name, "noop"); + assert_eq!(report.spec.iterations, 3); } #[test] diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 1ccc23f..9e7afaa 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -36,7 +36,7 @@ path = "src/bin/cargo-mobench.rs" [dependencies] anyhow.workspace = true -mobench-sdk = { version = "0.1.24", path = "../mobench-sdk" } +mobench-sdk = { version = "0.1.25", path = "../mobench-sdk" } clap.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mobench/README.md b/crates/mobench/README.md index c4e6690..056d8e5 100644 --- a/crates/mobench/README.md +++ b/crates/mobench/README.md @@ -45,7 +45,7 @@ This creates: - `mobench.toml` - Project configuration file (when using `init`) - `benches/example.rs` - Example benchmarks (with `--examples`) -Generated scaffolding still uses `bench-mobile/` by default, but in `0.1.22` existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. +Generated scaffolding still uses `bench-mobile/` by default, but existing repositories can point mobench at any benchmark crate through `mobench.toml`, `--project-root`, or `--crate-path`. ### 2. Write Benchmarks @@ -101,6 +101,25 @@ cargo mobench run \ **Note**: Always use the `--release` flag for BrowserStack runs. Debug builds are significantly larger (~544MB vs ~133MB for release) and may cause upload timeouts. +### 5. Plan a Local Profiling Session + +```bash +# Write a run-scoped experimental profile session contract +cargo mobench profile run \ + --target android \ + --function fibonacci_30 \ + --backend android-native + +# Render the latest-session manifest as markdown +cargo mobench profile summarize \ + --profile target/mobench/profile/profile.json +``` + +The current profiling MVP writes a normalized manifest plus planned artifact +paths under `target/mobench/profile//` and refreshes top-level latest +copies at `target/mobench/profile/profile.json` and `summary.md`. It does not +yet drive native profiler tools automatically. + ## Commands ### `init` - Initialize Project @@ -514,7 +533,7 @@ default_warmup = 10 ``` CLI flags always override config file values when provided. -Resolution precedence in `0.1.22` is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. +Resolution precedence is: `--project-root` / `--crate-path` → explicit `--config` → discovered `mobench.toml` → Cargo workspace root → git root → legacy `bench-mobile` fallback. ### Run Config File Format (`bench-config.toml`) diff --git a/crates/mobench/src/browserstack.rs b/crates/mobench/src/browserstack.rs index e0abadc..729500e 100644 --- a/crates/mobench/src/browserstack.rs +++ b/crates/mobench/src/browserstack.rs @@ -1911,11 +1911,7 @@ mod tests { fn spawn_browserstack_json_server( payload: Value, - ) -> ( - String, - Arc>>, - thread::JoinHandle<()>, - ) { + ) -> (String, Arc>>, thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); listener .set_nonblocking(true) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index c003179..c2ca6c8 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -140,6 +140,7 @@ mod browserstack; pub mod config; mod github; mod plots; +mod profile; pub(crate) mod summarize; /// CLI orchestrator for building, packaging, and executing Rust benchmarks on mobile. @@ -494,6 +495,11 @@ enum Command { #[command(subcommand)] command: ReportCommand, }, + /// Profiling helpers for native profile capture and summary rendering. + Profile { + #[command(subcommand)] + command: ProfileCommand, + }, /// Check prerequisites for building mobile artifacts. /// /// Validates that all required tools and configurations are in place @@ -654,6 +660,14 @@ enum ReportCommand { }, } +#[derive(Subcommand, Debug)] +enum ProfileCommand { + /// Run a native profiling session and write profile artifacts. + Run(profile::ProfileRunArgs), + /// Render markdown or JSON from a normalized profile manifest. + Summarize(profile::ProfileSummarizeArgs), +} + #[derive(Args, Debug, Clone)] struct CiRunArgs { #[arg(long, value_enum)] @@ -1850,6 +1864,14 @@ pub fn run() -> Result<()> { cmd_report_github(pr, &summary, &marker, publish, output.as_deref())?; } }, + Command::Profile { command } => match command { + ProfileCommand::Run(args) => { + profile::cmd_profile_run(&args)?; + } + ProfileCommand::Summarize(args) => { + profile::cmd_profile_summarize(&args)?; + } + }, Command::Check { target, format } => { cmd_check(target, format)?; } @@ -8542,6 +8564,50 @@ project = "proj" } } + #[test] + fn profile_run_parses_with_android_backend() { + let cli = Cli::parse_from([ + "mobench", + "profile", + "run", + "--target", + "android", + "--function", + "sample_fns::fibonacci", + "--backend", + "android-native", + ]); + + match cli.command { + Command::Profile { + command: ProfileCommand::Run(args), + } => { + assert_eq!(args.target, MobileTarget::Android); + assert_eq!(args.function, "sample_fns::fibonacci"); + assert_eq!(args.backend, profile::ProfileBackend::AndroidNative); + } + _ => panic!("expected profile run command"), + } + } + + #[test] + fn profile_summarize_parses_with_default_profile_path() { + let cli = Cli::parse_from(["mobench", "profile", "summarize"]); + + match cli.command { + Command::Profile { + command: ProfileCommand::Summarize(args), + } => { + assert_eq!( + args.profile, + PathBuf::from("target/mobench/profile/profile.json") + ); + assert_eq!(args.output_format, profile::ProfileSummaryFormat::Markdown); + } + _ => panic!("expected profile summarize command"), + } + } + #[test] fn report_github_parses() { let cli = Cli::parse_from(["mobench", "report", "github", "--pr", "123"]); diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs new file mode 100644 index 0000000..b7bf2ad --- /dev/null +++ b/crates/mobench/src/profile.rs @@ -0,0 +1,683 @@ +use anyhow::{Result, bail}; +use clap::{Args, ValueEnum}; +use serde::{Deserialize, Serialize}; +use std::fmt::Write; +use std::path::{Path, PathBuf}; + +use crate::MobileTarget; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProfileBackend { + Auto, + AndroidNative, + IosInstruments, + RustTracing, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProfileFormat { + Native, + Processed, + Both, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProfileProvider { + Local, + Browserstack, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProfileSummaryFormat { + Markdown, + Json, +} + +#[derive(Debug, Clone, Args)] +pub struct ProfileRunArgs { + #[arg(long, value_enum)] + pub target: MobileTarget, + #[arg(long, help = "Fully-qualified Rust function to profile")] + pub function: String, + #[arg( + long, + help = "Path to the benchmark crate directory containing Cargo.toml" + )] + pub crate_path: Option, + #[arg(long, help = "Optional path to config file")] + pub config: Option, + #[arg(long, default_value = "target/mobench/profile")] + pub output_dir: PathBuf, + #[arg(long, value_enum, default_value_t = ProfileProvider::Local)] + pub provider: ProfileProvider, + #[arg(long, value_enum, default_value_t = ProfileBackend::Auto)] + pub backend: ProfileBackend, + #[arg(long, value_enum, default_value_t = ProfileFormat::Both)] + pub format: ProfileFormat, +} + +#[derive(Debug, Clone, Args)] +pub struct ProfileSummarizeArgs { + #[arg(long, default_value = "target/mobench/profile/profile.json")] + pub profile: PathBuf, + #[arg(long)] + pub output: Option, + #[arg(long, value_enum, default_value_t = ProfileSummaryFormat::Markdown)] + pub output_format: ProfileSummaryFormat, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CaptureStatus { + Planned, + Captured, + Partial, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactRecord { + pub label: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileManifest { + pub run_id: String, + pub target: MobileTarget, + pub function: String, + #[serde(default = "default_profile_provider")] + pub provider: ProfileProvider, + pub backend: ProfileBackend, + pub format: ProfileFormat, + pub capture_status: CaptureStatus, + pub raw_artifacts: Vec, + pub processed_artifacts: Vec, + pub warnings: Vec, + pub viewer_hint: Option, +} + +fn default_profile_provider() -> ProfileProvider { + ProfileProvider::Local +} + +pub fn render_profile_markdown(manifest: &ProfileManifest) -> String { + let mut markdown = String::new(); + let _ = writeln!(markdown, "# Profile Summary"); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "- Run ID: `{}`", manifest.run_id); + let _ = writeln!(markdown, "- Target: `{}`", manifest.target.as_str()); + let _ = writeln!(markdown, "- Function: `{}`", manifest.function); + let _ = writeln!( + markdown, + "- Provider: `{}`", + manifest.provider.to_possible_value().unwrap().get_name() + ); + let _ = writeln!( + markdown, + "- Backend: `{}`", + manifest.backend.to_possible_value().unwrap().get_name() + ); + let _ = writeln!( + markdown, + "- Status: `{}`", + capture_status_label(manifest.capture_status) + ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## Raw Artifacts"); + let _ = writeln!(markdown); + for artifact in &manifest.raw_artifacts { + let _ = writeln!( + markdown, + "- `{}`: `{}`", + artifact.label, + artifact.path.display() + ); + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## Processed Artifacts"); + let _ = writeln!(markdown); + for artifact in &manifest.processed_artifacts { + let _ = writeln!( + markdown, + "- `{}`: `{}`", + artifact.label, + artifact.path.display() + ); + } + if !manifest.warnings.is_empty() { + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## Warnings"); + let _ = writeln!(markdown); + for warning in &manifest.warnings { + let _ = writeln!(markdown, "- {}", warning); + } + } + if let Some(viewer_hint) = &manifest.viewer_hint { + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## Viewer"); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "{}", viewer_hint); + } + + markdown +} + +pub fn write_profile_manifest(path: &Path, manifest: &ProfileManifest) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let body = serde_json::to_string_pretty(manifest)?; + std::fs::write(path, body)?; + Ok(()) +} + +pub fn cmd_profile_run(args: &ProfileRunArgs) -> Result<()> { + std::fs::create_dir_all(&args.output_dir)?; + let run_id = build_run_id(args.target, &args.function); + let run_output_dir = args.output_dir.join(&run_id); + std::fs::create_dir_all(&run_output_dir)?; + + let manifest = build_capture_plan(args, &run_output_dir)?; + let rendered_summary = render_profile_markdown(&manifest); + + let run_profile_path = run_output_dir.join("profile.json"); + let run_summary_path = run_output_dir.join("summary.md"); + write_profile_manifest(&run_profile_path, &manifest)?; + std::fs::write(&run_summary_path, rendered_summary.as_bytes())?; + + let latest_profile_path = args.output_dir.join("profile.json"); + let latest_summary_path = args.output_dir.join("summary.md"); + write_profile_manifest(&latest_profile_path, &manifest)?; + std::fs::write(&latest_summary_path, rendered_summary.as_bytes())?; + + println!("Profile session written to {}", run_profile_path.display()); + println!("Profile summary written to {}", run_summary_path.display()); + println!( + "Latest profile manifest refreshed at {}", + latest_profile_path.display() + ); + println!( + "Latest profile summary refreshed at {}", + latest_summary_path.display() + ); + Ok(()) +} + +pub fn cmd_profile_summarize(args: &ProfileSummarizeArgs) -> Result<()> { + let rendered = cmd_profile_summarize_for_test(args)?; + if let Some(path) = &args.output { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, rendered.as_bytes())?; + } else { + println!("{rendered}"); + } + Ok(()) +} + +fn capture_status_label(status: CaptureStatus) -> &'static str { + match status { + CaptureStatus::Planned => "planned", + CaptureStatus::Captured => "captured", + CaptureStatus::Partial => "partial", + CaptureStatus::Failed => "failed", + } +} + +fn load_profile_manifest(path: &Path) -> Result { + let body = std::fs::read_to_string(path)?; + Ok(serde_json::from_str(&body)?) +} + +pub fn cmd_profile_summarize_for_test(args: &ProfileSummarizeArgs) -> Result { + let manifest = load_profile_manifest(&args.profile)?; + match args.output_format { + ProfileSummaryFormat::Markdown => Ok(render_profile_markdown(&manifest)), + ProfileSummaryFormat::Json => Ok(serde_json::to_string_pretty(&manifest)?), + } +} + +fn build_capture_plan(args: &ProfileRunArgs, output_root: &Path) -> Result { + let backend = resolve_backend(args.target, args.backend); + validate_profile_capabilities(args.provider, backend)?; + validate_format_capabilities(backend, args.format)?; + + let raw_root = output_root.join("artifacts/raw"); + let processed_root = output_root.join("artifacts/processed"); + + let (raw_artifacts, processed_artifacts) = match backend { + ProfileBackend::AndroidNative => ( + vec![ArtifactRecord { + label: "simpleperf".into(), + path: raw_root.join("sample.perf"), + }], + vec![ArtifactRecord { + label: "flamegraph".into(), + path: processed_root.join("flamegraph.html"), + }], + ), + ProfileBackend::IosInstruments => ( + vec![ArtifactRecord { + label: "time-profiler".into(), + path: raw_root.join("time-profiler.trace"), + }], + vec![ArtifactRecord { + label: "xctrace-export".into(), + path: processed_root.join("time-profiler.xml"), + }], + ), + ProfileBackend::RustTracing => ( + vec![ArtifactRecord { + label: "trace-events".into(), + path: raw_root.join("trace-events.json"), + }], + Vec::new(), + ), + ProfileBackend::Auto => unreachable!("auto backend should resolve before planning"), + }; + + let raw_artifacts = select_artifacts(raw_artifacts, args.format, ArtifactKind::Raw); + let processed_artifacts = + select_artifacts(processed_artifacts, args.format, ArtifactKind::Processed); + ensure_selected_artifact_roots(&raw_artifacts, &processed_artifacts)?; + let viewer_hint = + select_viewer_hint(backend, args.format, &raw_artifacts, &processed_artifacts); + + Ok(ProfileManifest { + run_id: build_run_id(args.target, &args.function), + target: args.target, + function: args.function.clone(), + provider: args.provider, + backend, + format: args.format, + capture_status: CaptureStatus::Planned, + raw_artifacts, + processed_artifacts, + warnings: vec![ + "capture execution is not implemented yet; this session records the planned artifact contract only" + .into(), + ], + viewer_hint, + }) +} + +fn build_run_id(target: MobileTarget, function: &str) -> String { + format!("{}-{}", target.as_str(), slugify_function_name(function)) +} + +fn resolve_backend(target: MobileTarget, backend: ProfileBackend) -> ProfileBackend { + match backend { + ProfileBackend::Auto => match target { + MobileTarget::Android => ProfileBackend::AndroidNative, + MobileTarget::Ios => ProfileBackend::IosInstruments, + }, + _ => backend, + } +} + +fn validate_profile_capabilities(provider: ProfileProvider, backend: ProfileBackend) -> Result<()> { + if provider == ProfileProvider::Browserstack + && matches!( + backend, + ProfileBackend::AndroidNative | ProfileBackend::IosInstruments + ) + { + bail!( + "BrowserStack native profile capture is unsupported for the MVP; use local provider for {:?}", + backend + ); + } + Ok(()) +} + +fn validate_format_capabilities(backend: ProfileBackend, format: ProfileFormat) -> Result<()> { + if backend == ProfileBackend::RustTracing && format == ProfileFormat::Processed { + bail!( + "processed output is unsupported for rust-tracing backend; use --format native or both" + ); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ArtifactKind { + Raw, + Processed, +} + +fn select_artifacts( + artifacts: Vec, + format: ProfileFormat, + kind: ArtifactKind, +) -> Vec { + match format { + ProfileFormat::Both => artifacts, + ProfileFormat::Native if kind == ArtifactKind::Raw => artifacts, + ProfileFormat::Processed if kind == ArtifactKind::Processed => artifacts, + _ => Vec::new(), + } +} + +fn ensure_selected_artifact_roots( + raw_artifacts: &[ArtifactRecord], + processed_artifacts: &[ArtifactRecord], +) -> Result<()> { + for artifact in raw_artifacts.iter().chain(processed_artifacts.iter()) { + if let Some(parent) = artifact.path.parent() { + std::fs::create_dir_all(parent)?; + } + } + Ok(()) +} + +fn select_viewer_hint( + backend: ProfileBackend, + format: ProfileFormat, + raw_artifacts: &[ArtifactRecord], + processed_artifacts: &[ArtifactRecord], +) -> Option { + match backend { + ProfileBackend::AndroidNative => { + if format != ProfileFormat::Native && !processed_artifacts.is_empty() { + Some("Open artifacts/processed/flamegraph.html in a browser".into()) + } else if !raw_artifacts.is_empty() { + Some( + "Inspect artifacts/raw/sample.perf with the Android profiling toolchain".into(), + ) + } else { + None + } + } + ProfileBackend::IosInstruments => { + if !raw_artifacts.is_empty() { + Some("Open artifacts/raw/time-profiler.trace in Instruments".into()) + } else if !processed_artifacts.is_empty() { + Some( + "Inspect artifacts/processed/time-profiler.xml or rerun with --format both to keep the .trace bundle" + .into(), + ) + } else { + None + } + } + ProfileBackend::RustTracing => { + if !raw_artifacts.is_empty() { + Some("Open artifacts/raw/trace-events.json in a trace viewer".into()) + } else { + None + } + } + ProfileBackend::Auto => None, + } +} + +fn slugify_function_name(function: &str) -> String { + let mut slug = String::new(); + for ch in function.chars() { + match ch { + ':' | '/' | ' ' => slug.push('-'), + '_' | '-' => slug.push(ch), + ch if ch.is_ascii_alphanumeric() => slug.push(ch), + _ => slug.push('_'), + } + } + slug +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_manifest_serializes_partial_failure_state() { + let manifest = sample_manifest(); + + let json = serde_json::to_value(&manifest).expect("serialize manifest"); + assert_eq!(json["warnings"][0], "missing symbols"); + assert_eq!(json["capture_status"], "partial"); + } + + #[test] + fn render_profile_summary_mentions_backend_and_artifacts() { + let manifest = sample_manifest(); + let markdown = render_profile_markdown(&manifest); + + assert!(markdown.contains("android-native")); + assert!(markdown.contains("artifacts/raw/sample.perf")); + assert!(markdown.contains("missing symbols")); + } + + #[test] + fn summarize_command_reads_manifest_and_renders_markdown() { + let dir = tempfile::tempdir().expect("temp dir"); + let manifest_path = dir.path().join("profile.json"); + write_profile_manifest(&manifest_path, &sample_manifest()).expect("write manifest"); + + let rendered = cmd_profile_summarize_for_test(&ProfileSummarizeArgs { + profile: manifest_path, + output: None, + output_format: ProfileSummaryFormat::Markdown, + }) + .expect("summarize profile"); + + assert!(rendered.contains("sample_fns::fibonacci")); + assert!(rendered.contains("Profile Summary")); + } + + #[test] + fn android_backend_builds_capture_plan_with_flamegraph_artifacts() { + let plan = build_capture_plan( + &ProfileRunArgs { + target: MobileTarget::Android, + function: "sample_fns::fibonacci".into(), + crate_path: None, + config: None, + output_dir: PathBuf::from("target/mobench/profile"), + provider: ProfileProvider::Local, + backend: ProfileBackend::AndroidNative, + format: ProfileFormat::Both, + }, + &PathBuf::from("target/mobench/profile"), + ) + .expect("android capture plan"); + + assert!( + plan.raw_artifacts + .iter() + .any(|p| p.path.ends_with("sample.perf")) + ); + assert!( + plan.processed_artifacts + .iter() + .any(|p| p.path.ends_with("flamegraph.html")) + ); + } + + #[test] + fn profile_native_format_excludes_processed_artifacts_from_plan() { + let plan = build_capture_plan( + &ProfileRunArgs { + target: MobileTarget::Android, + function: "sample_fns::fibonacci".into(), + crate_path: None, + config: None, + output_dir: PathBuf::from("target/mobench/profile"), + provider: ProfileProvider::Local, + backend: ProfileBackend::AndroidNative, + format: ProfileFormat::Native, + }, + &PathBuf::from("target/mobench/profile"), + ) + .expect("native-only capture plan"); + + assert_eq!(plan.raw_artifacts.len(), 1); + assert!(plan.processed_artifacts.is_empty()); + assert_eq!( + plan.viewer_hint.as_deref(), + Some("Inspect artifacts/raw/sample.perf with the Android profiling toolchain") + ); + } + + #[test] + fn ios_backend_allocates_trace_bundle_and_export_paths() { + let plan = build_capture_plan( + &ProfileRunArgs { + target: MobileTarget::Ios, + function: "sample_fns::fibonacci".into(), + crate_path: None, + config: None, + output_dir: PathBuf::from("target/mobench/profile"), + provider: ProfileProvider::Local, + backend: ProfileBackend::IosInstruments, + format: ProfileFormat::Both, + }, + &PathBuf::from("target/mobench/profile"), + ) + .expect("ios capture plan"); + + assert!( + plan.raw_artifacts + .iter() + .any(|p| p.path.ends_with("time-profiler.trace")) + ); + assert!( + plan.processed_artifacts + .iter() + .any(|p| p.path.ends_with("time-profiler.xml")) + ); + } + + #[test] + fn browserstack_profile_run_reports_unsupported_native_capture() { + let error = build_capture_plan( + &ProfileRunArgs { + target: MobileTarget::Android, + function: "sample_fns::fibonacci".into(), + crate_path: None, + config: None, + output_dir: PathBuf::from("target/mobench/profile"), + provider: ProfileProvider::Browserstack, + backend: ProfileBackend::AndroidNative, + format: ProfileFormat::Both, + }, + &PathBuf::from("target/mobench/profile"), + ) + .unwrap_err(); + + assert!(error.to_string().contains("BrowserStack")); + assert!(error.to_string().contains("unsupported")); + } + + #[test] + fn profile_rust_tracing_processed_only_is_rejected() { + let error = build_capture_plan( + &ProfileRunArgs { + target: MobileTarget::Android, + function: "sample_fns::fibonacci".into(), + crate_path: None, + config: None, + output_dir: PathBuf::from("target/mobench/profile"), + provider: ProfileProvider::Local, + backend: ProfileBackend::RustTracing, + format: ProfileFormat::Processed, + }, + &PathBuf::from("target/mobench/profile"), + ) + .unwrap_err(); + + assert!(error.to_string().contains("processed")); + assert!(error.to_string().contains("rust-tracing")); + } + + #[test] + fn profile_run_writes_run_scoped_and_latest_manifest_files() { + let dir = tempfile::tempdir().expect("temp dir"); + let android_args = ProfileRunArgs { + target: MobileTarget::Android, + function: "sample_fns::fibonacci".into(), + crate_path: None, + config: None, + output_dir: dir.path().to_path_buf(), + provider: ProfileProvider::Local, + backend: ProfileBackend::AndroidNative, + format: ProfileFormat::Both, + }; + let ios_args = ProfileRunArgs { + target: MobileTarget::Ios, + function: "sample_fns::checksum".into(), + crate_path: None, + config: None, + output_dir: dir.path().to_path_buf(), + provider: ProfileProvider::Local, + backend: ProfileBackend::IosInstruments, + format: ProfileFormat::Both, + }; + + cmd_profile_run(&android_args).expect("write first planned profile session"); + cmd_profile_run(&ios_args).expect("write second planned profile session"); + + let android_run_dir = dir.path().join("android-sample_fns--fibonacci"); + let ios_run_dir = dir.path().join("ios-sample_fns--checksum"); + + assert!(android_run_dir.join("profile.json").exists()); + assert!(android_run_dir.join("summary.md").exists()); + assert!(ios_run_dir.join("profile.json").exists()); + assert!(ios_run_dir.join("summary.md").exists()); + assert!(dir.path().join("profile.json").exists()); + assert!(dir.path().join("summary.md").exists()); + + let latest_manifest = + load_profile_manifest(&dir.path().join("profile.json")).expect("load latest manifest"); + assert_eq!(latest_manifest.target, MobileTarget::Ios); + assert_eq!(latest_manifest.function, "sample_fns::checksum"); + } + + #[test] + fn profile_manifest_serializes_provider() { + let manifest = build_capture_plan( + &ProfileRunArgs { + target: MobileTarget::Android, + function: "sample_fns::fibonacci".into(), + crate_path: None, + config: None, + output_dir: PathBuf::from("target/mobench/profile"), + provider: ProfileProvider::Browserstack, + backend: ProfileBackend::RustTracing, + format: ProfileFormat::Both, + }, + &PathBuf::from("target/mobench/profile"), + ) + .expect("build manifest"); + + let json = serde_json::to_value(&manifest).expect("serialize manifest"); + assert_eq!(json["provider"], "browserstack"); + } + + fn sample_manifest() -> ProfileManifest { + ProfileManifest { + run_id: "run-123".into(), + target: MobileTarget::Android, + function: "sample_fns::fibonacci".into(), + provider: ProfileProvider::Local, + backend: ProfileBackend::AndroidNative, + format: ProfileFormat::Both, + capture_status: CaptureStatus::Partial, + raw_artifacts: vec![ArtifactRecord { + label: "simpleperf".into(), + path: PathBuf::from("artifacts/raw/sample.perf"), + }], + processed_artifacts: vec![ArtifactRecord { + label: "flamegraph".into(), + path: PathBuf::from("artifacts/processed/flamegraph.html"), + }], + warnings: vec!["missing symbols".into()], + viewer_hint: Some("Open flamegraph.html in a browser".into()), + } + } +} diff --git a/docs/plans/2026-03-26-eng-25-profiling-design.md b/docs/plans/2026-03-26-eng-25-profiling-design.md new file mode 100644 index 0000000..3e88e83 --- /dev/null +++ b/docs/plans/2026-03-26-eng-25-profiling-design.md @@ -0,0 +1,272 @@ +# Mobench Profiling Feature Set Design + +## Summary + +Add a new profiling subsystem to Mobench that complements timing benchmarks +with native profile artifacts and flamegraph-capable visualizations. The CLI +should remain the user-facing orchestrator and should reuse the same benchmark +selection, build, packaging, device resolution, and output conventions that +existing `run` and `ci run` commands already use. + +The design standardizes orchestration and artifact layout, not a fake common +raw profile format. Android and iOS should keep their native capture formats, +while Mobench provides a shared command surface, normalized metadata, and +reporting helpers. BrowserStack stays in scope as an execution environment, but +remote native sampling capture is not an MVP dependency. + +## Goals + +- Keep the primary workflow inside the existing `mobench` CLI. +- Add a dedicated profiling command family that feels parallel to `run` and + `report summarize`. +- Produce native raw profile artifacts plus normalized metadata for downstream + viewing and automation. +- Support local-first native profiling on Android and iOS with release-like + builds and explicit symbol handling. +- Make flamegraph-capable output first-class on at least the Android path. +- Preserve compatibility with the current benchmark/report contract. + +## Non-Goals + +- Replacing the existing timing benchmark flow. +- Inventing a single cross-platform raw profile format. +- Depending on undocumented BrowserStack native-profiler APIs for MVP success. +- Treating app-level instrumentation as a substitute for native stack sampling. +- Changing the required v1 CI outputs in `summary.json`, `summary.md`, or + `results.csv`. + +## User Experience + +The new workflow should add a dedicated profile command family: + +```bash +cargo mobench profile run --target android --function sample_fns::fibonacci +cargo mobench profile summarize --profile target/mobench/profile/profile.json +cargo mobench profile summarize --profile target/mobench/profile//profile.json +``` + +`profile run` should mirror existing benchmark selectors and project-resolution +flags: + +- `--target` +- `--function` +- `--iterations` +- `--warmup` +- `--config` +- `--crate-path` +- `--project-root` +- `--output-dir` +- device/provider flags already used by benchmark runs + +Profiling-specific controls should be additive: + +- `--backend auto|android-native|ios-instruments|rust-tracing` +- `--format native|processed|both` +- `--time-limit` +- `--symbolize auto|off|require` + +The command must be local-first. Developers should be able to run local Android +and local iOS profiling through Mobench, then continue in Android Studio, +Firefox Profiler, Xcode, or Instruments using artifacts that Mobench wrote. + +BrowserStack should remain a supported execution target conceptually, but the +MVP must not assume that remote native sampling capture is available. If that +path is unsupported, Mobench should fail explicitly and explain why. + +## Architecture + +### Orchestration Layer + +Mobench should own: + +- target and benchmark resolution +- build/profile mode selection +- device/provider selection +- output directory and artifact naming +- symbol discovery and policy handling +- backend selection and subprocess invocation + +This keeps profiling aligned with the existing CLI instead of becoming a set of +ad hoc shell recipes. + +### Platform Backend Layer + +Each platform backend should expose a shared interface to the CLI: + +- `prepare()` +- `capture()` +- `process()` +- `summarize()` + +Android and iOS should share orchestration semantics while keeping native tool +choices: + +- Android: native sampling capture, then optional import/conversion into a + flamegraph-capable viewer format. +- iOS: `xctrace` / Instruments capture with `.trace` bundles and exported + summaries. + +### Artifact And Report Layer + +Raw artifacts should stay native. Mobench should normalize only the metadata +around them: + +- what benchmark ran +- on which target/device/provider +- which backend executed +- where raw and processed artifacts live +- symbol status +- viewer hints +- partial-failure state + +This lets Mobench summarize captures without pretending `.trace` bundles and +Android sample captures are interchangeable. + +## Command Surface + +The MVP command family should be: + +- `cargo mobench profile run` +- `cargo mobench profile summarize` + +`profile run` creates a profile session and writes artifacts. `profile summarize` +reads the normalized metadata file and renders terminal or markdown output +without re-running the benchmark. + +Future commands can remain additive: + +- `cargo mobench profile open` +- `cargo mobench profile export` +- `cargo mobench profile import` + +## Artifacts + +Profiling should not modify the existing CI v1 contract. Instead it should add a +parallel additive artifact tree: + +```text +target/mobench/profile/ + profile.json # latest session convenience copy + summary.md # latest session convenience copy + / + profile.json + summary.md + artifacts/ + raw/ + processed/ +``` + +`profile.json` is the normalized metadata file. It should record: + +- benchmark identity and run parameters +- target/platform/device/provider +- backend, capture mode, and requested output format +- raw artifact paths +- processed artifact paths +- symbolization state +- capture timestamps and durations +- warnings and partial failures +- viewer hints and recommended next actions + +The raw artifacts remain platform-specific: + +- Android: native sampling capture plus processed flamegraph-capable output when + available +- iOS: `.trace` bundles and optional exported XML summaries +- optional Rust tracing output if a future fallback backend is added + +## Backend Behavior + +### Android + +Android should be the first flamegraph-capable path in the MVP. Mobench should +handle: + +- building release-like artifacts with symbol retention +- launching the benchmarked app on a local Android target +- coordinating native sampling capture +- emitting raw and processed artifacts + +The preferred viewer can differ from iOS as long as the processed output is +clearly documented and actionable. + +### iOS + +iOS should use Instruments via `xctrace`. Mobench should: + +- build release-like artifacts with dSYMs +- launch or attach to the benchmarked app on a local iOS target +- record a `Time Profiler` trace +- emit `.trace` artifacts plus exported metadata + +The first-class viewer is Instruments/Xcode, not Firefox Profiler. + +### BrowserStack + +BrowserStack remains in scope as a run environment, but the MVP should treat +native remote profile capture as optional. The benchmark may run on a real phone +there, but the profiling backend must fail explicitly if the provider cannot +support native capture. That keeps BrowserStack integration honest instead of +relying on undocumented capabilities. + +## Symbolization + +Profile runs are only useful if Mobench can report symbol quality clearly. + +Rules: + +- Android must preserve or locate unstripped native symbols. +- iOS must preserve or locate dSYMs. +- `--symbolize require` should fail the command when symbolization prerequisites + are missing. +- `--symbolize auto` may continue but must record warnings and degraded status + in `profile.json`. + +Unsymbolized captures are operationally failures even when raw files exist. + +## Error Handling + +The profiling subsystem should distinguish: + +- benchmark execution failure +- capture startup failure +- symbol discovery or symbolization failure +- artifact conversion/export failure +- unsupported backend or provider capability +- viewer/tool availability failure + +Partial success must be expressible in `profile.json`. For example, a benchmark +may run and produce a raw trace while processed exports fail. + +## Testing Strategy + +### Unit Tests + +- CLI parser coverage for `profile` commands and options +- artifact-path and manifest-serialization tests +- backend selection and capability gating tests +- command-construction tests for external tools + +### Fixture Tests + +- `profile.json` rendering and summary formatting +- partial-failure serialization +- symbol-warning propagation + +### Smoke Tests + +- opt-in local Android smoke tests +- opt-in local iOS smoke tests +- no assumption that CI can run full native profilers on both platforms + +## References + +- [docs/CONTRACT_CI_V1.md](/Users/dcbuilder/.config/superpowers/worktrees/mobile-bench-rs/codex-eng-25-profiling/docs/CONTRACT_CI_V1.md) +- [samply README](https://github.com/mstange/samply) +- [Firefox profiling with simpleperf](https://firefox-source-docs.mozilla.org/performance/profiling_with_simpleperf.html) +- [Android Studio profiling docs](https://developer.android.com/studio/profile) +- [Xcode performance and metrics](https://developer.apple.com/documentation/xcode/performance-and-metrics) + +Local verification on this machine also confirmed that `xcrun xctrace` exposes +`record`, `export`, and `import` commands, including the `Time Profiler` +template, which makes a CLI-orchestrated iOS path viable on Xcode hosts. diff --git a/docs/plans/2026-03-26-eng-25-profiling-implementation.md b/docs/plans/2026-03-26-eng-25-profiling-implementation.md new file mode 100644 index 0000000..df1abb7 --- /dev/null +++ b/docs/plans/2026-03-26-eng-25-profiling-implementation.md @@ -0,0 +1,524 @@ +# ENG-25 Profiling Feature Set Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a new `mobench profile` subsystem that standardizes profiling orchestration and artifacts across platforms, provides native-tool backends for Android and iOS local runs, and reports explicit capability limits for BrowserStack-backed execution. + +**Architecture:** Keep the `mobench` CLI as the orchestrator. Introduce a dedicated `profile` Rust module tree for command parsing, artifact contracts, backend dispatch, and summary rendering. Android and iOS backends build native command lines for external tools, write run-scoped `target/mobench/profile//profile.json` manifests plus top-level latest-session convenience files, and preserve raw platform-specific artifacts without changing the existing CI v1 benchmark contract. + +**Tech Stack:** Rust (`clap`, `serde`, `serde_json`, `anyhow`, `std::process`, `std::fs`), existing `mobench-sdk` builders, external platform tools (`adb`, `simpleperf`, `xcrun`, `xctrace`), existing summary/report patterns under `crates/mobench/src`. + +--- + +### Task 1: Add Profile CLI Parsing And Contract Types + +**Files:** +- Create: `crates/mobench/src/profile.rs` +- Modify: `crates/mobench/src/lib.rs` +- Test: `crates/mobench/src/profile.rs` + +**Step 1: Write the failing test** + +Add parser-focused tests in `crates/mobench/src/profile.rs` that assert the new +subcommand shape parses cleanly: + +```rust +#[test] +fn profile_run_parses_with_android_backend() { + let cli = Cli::try_parse_from([ + "mobench", + "profile", + "run", + "--target", + "android", + "--function", + "sample_fns::fibonacci", + "--backend", + "android-native", + ]) + .expect("parse profile run"); + + assert!(matches!(cli.command, Command::Profile { .. })); +} + +#[test] +fn profile_summarize_parses_with_default_profile_path() { + let cli = Cli::try_parse_from(["mobench", "profile", "summarize"]) + .expect("parse profile summarize"); + + assert!(matches!(cli.command, Command::Profile { .. })); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test -p mobench profile_run_parses_with_android_backend -- --exact` + +Expected: FAIL because `Command::Profile` and the profile argument types do not +exist yet. + +**Step 3: Write minimal implementation** + +Create `crates/mobench/src/profile.rs` with the shared enums and manifest types: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] +pub enum ProfileBackend { + Auto, + AndroidNative, + IosInstruments, + RustTracing, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] +pub enum ProfileFormat { + Native, + Processed, + Both, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileManifest { + pub run_id: String, + pub target: String, + pub function: String, + pub backend: ProfileBackend, + pub raw_artifacts: Vec, + pub processed_artifacts: Vec, + pub warnings: Vec, +} +``` + +Wire the module into `crates/mobench/src/lib.rs`: + +```rust +mod profile; +``` + +Then add `Command::Profile` plus `ProfileCommand::{Run,Summarize}` with a first +pass argument structure. + +**Step 4: Run test to verify it passes** + +Run: `cargo test -p mobench profile_run_parses_with_android_backend -- --exact` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/mobench/src/lib.rs crates/mobench/src/profile.rs +git commit -m "feat: add profile command scaffolding" +``` + +### Task 2: Add Normalized Profile Manifest Rendering + +**Files:** +- Modify: `crates/mobench/src/profile.rs` +- Test: `crates/mobench/src/profile.rs` + +**Step 1: Write the failing test** + +Add a manifest round-trip test and summary-render test: + +```rust +#[test] +fn profile_manifest_serializes_partial_failure_state() { + let manifest = ProfileManifest { + run_id: "run-123".into(), + target: "android".into(), + function: "sample_fns::fibonacci".into(), + backend: ProfileBackend::AndroidNative, + raw_artifacts: vec![PathBuf::from("artifacts/raw/sample.perf")], + processed_artifacts: vec![], + warnings: vec!["missing symbols".into()], + }; + + let json = serde_json::to_value(&manifest).expect("serialize manifest"); + assert_eq!(json["warnings"][0], "missing symbols"); +} + +#[test] +fn render_profile_summary_mentions_backend_and_artifacts() { + let manifest = sample_manifest(); + let markdown = render_profile_markdown(&manifest); + assert!(markdown.contains("android-native")); + assert!(markdown.contains("artifacts/raw")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test -p mobench profile_manifest_serializes_partial_failure_state -- --exact` + +Expected: FAIL because `render_profile_markdown` and the finalized manifest +shape do not exist yet. + +**Step 3: Write minimal implementation** + +Extend `ProfileManifest` with: + +- symbolization status +- viewer hints +- capture status enum +- explicit raw/processed artifact records + +Add: + +```rust +pub fn render_profile_markdown(manifest: &ProfileManifest) -> String { + // render backend, target, function, capture status, warnings, and artifact paths +} + +pub fn write_profile_manifest(path: &Path, manifest: &ProfileManifest) -> Result<()> { + // pretty JSON writer +} +``` + +Keep the output stable and deterministic so it is easy to diff in tests. + +**Step 4: Run test to verify it passes** + +Run: `cargo test -p mobench profile_manifest_serializes_partial_failure_state -- --exact` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/mobench/src/profile.rs +git commit -m "feat: add normalized profile manifests" +``` + +### Task 3: Add Command Dispatch For `profile run` And `profile summarize` + +**Files:** +- Modify: `crates/mobench/src/lib.rs` +- Modify: `crates/mobench/src/profile.rs` +- Test: `crates/mobench/src/lib.rs` + +**Step 1: Write the failing test** + +Add command-dispatch tests that verify the new commands produce expected files +in dry-run or fixture mode: + +```rust +#[test] +fn profile_summarize_reads_manifest_and_prints_markdown() { + let dir = tempfile::tempdir().unwrap(); + let manifest_path = dir.path().join("profile.json"); + write_file( + &manifest_path, + serde_json::to_string_pretty(&sample_manifest()).unwrap().as_bytes(), + ) + .unwrap(); + + let output = cmd_profile_summarize_for_test(&manifest_path).expect("summarize profile"); + assert!(output.contains("sample_fns::fibonacci")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test -p mobench profile_summarize_reads_manifest_and_prints_markdown -- --exact` + +Expected: FAIL because the profile commands are not wired into execution yet. + +**Step 3: Write minimal implementation** + +In `crates/mobench/src/lib.rs`: + +- add `Command::Profile { command: ProfileCommand }` +- dispatch to `cmd_profile_run` and `cmd_profile_summarize` +- reuse existing `write_file` and CLI output helpers + +In `crates/mobench/src/profile.rs`: + +- add helpers for default output paths +- add manifest loading +- add `cmd_profile_summarize_for_test` + +**Step 4: Run test to verify it passes** + +Run: `cargo test -p mobench profile_summarize_reads_manifest_and_prints_markdown -- --exact` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/mobench/src/lib.rs crates/mobench/src/profile.rs +git commit -m "feat: wire profile command dispatch" +``` + +### Task 4: Add Android Backend Command Construction With Capability Checks + +**Files:** +- Modify: `crates/mobench/src/profile.rs` +- Test: `crates/mobench/src/profile.rs` + +**Step 1: Write the failing test** + +Add a backend-unit test that verifies Android profiling builds the expected tool +commands and errors clearly when prerequisites are missing: + +```rust +#[test] +fn android_backend_requires_adb_and_simpleperf() { + let ctx = sample_profile_context().with_backend(ProfileBackend::AndroidNative); + let error = build_android_capture_plan(&ctx, None, None).unwrap_err(); + assert!(error.to_string().contains("simpleperf")); +} + +#[test] +fn android_backend_builds_capture_plan_with_processed_artifacts() { + let ctx = sample_profile_context().with_backend(ProfileBackend::AndroidNative); + let plan = build_android_capture_plan( + &ctx, + Some(Path::new("/usr/bin/adb")), + Some(Path::new("/opt/android/simpleperf")), + ) + .expect("android capture plan"); + + assert!(plan.raw_artifacts.iter().any(|p| p.ends_with("sample.perf"))); + assert!(plan.processed_artifacts.iter().any(|p| p.ends_with("flamegraph.html"))); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test -p mobench android_backend_requires_adb_and_simpleperf -- --exact` + +Expected: FAIL because the Android profiling backend does not exist yet. + +**Step 3: Write minimal implementation** + +Add an Android backend that: + +- detects `adb` and `simpleperf` +- allocates raw/processed artifact paths under `artifacts/raw` and + `artifacts/processed` +- records a capture plan structure without yet attempting to optimize + +Start with command construction and manifest emission only. Keep tool invocation +behind small helper functions so tests can cover planning without requiring a +real device. + +**Step 4: Run test to verify it passes** + +Run: `cargo test -p mobench android_backend_requires_adb_and_simpleperf -- --exact` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/mobench/src/profile.rs +git commit -m "feat: add android profiling backend planning" +``` + +### Task 5: Add iOS Backend Command Construction With `xctrace` + +**Files:** +- Modify: `crates/mobench/src/profile.rs` +- Test: `crates/mobench/src/profile.rs` + +**Step 1: Write the failing test** + +Add tests for iOS capability detection and artifact planning: + +```rust +#[test] +fn ios_backend_requires_xctrace() { + let ctx = sample_profile_context().with_backend(ProfileBackend::IosInstruments); + let error = build_ios_capture_plan(&ctx, None).unwrap_err(); + assert!(error.to_string().contains("xctrace")); +} + +#[test] +fn ios_backend_allocates_trace_bundle_and_export_paths() { + let ctx = sample_profile_context().with_backend(ProfileBackend::IosInstruments); + let plan = build_ios_capture_plan(&ctx, Some(Path::new("/usr/bin/xcrun"))) + .expect("ios capture plan"); + + assert!(plan.raw_artifacts.iter().any(|p| p.ends_with("time-profiler.trace"))); + assert!(plan.processed_artifacts.iter().any(|p| p.ends_with("time-profiler.xml"))); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test -p mobench ios_backend_requires_xctrace -- --exact` + +Expected: FAIL because the iOS backend is not implemented yet. + +**Step 3: Write minimal implementation** + +Add an iOS backend that: + +- detects `xcrun` / `xctrace` +- builds an `xctrace record` command plan with the `Time Profiler` template +- allocates `.trace` and exported XML artifact paths +- records viewer hints that point users to Instruments + +Again, keep command planning testable without requiring a live device. + +**Step 4: Run test to verify it passes** + +Run: `cargo test -p mobench ios_backend_requires_xctrace -- --exact` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/mobench/src/profile.rs +git commit -m "feat: add ios profiling backend planning" +``` + +### Task 6: Add BrowserStack Capability Gating And Provider Errors + +**Files:** +- Modify: `crates/mobench/src/profile.rs` +- Modify: `crates/mobench/src/lib.rs` +- Test: `crates/mobench/src/profile.rs` + +**Step 1: Write the failing test** + +Add a test that asserts BrowserStack-backed profile runs fail explicitly when a +native backend is requested: + +```rust +#[test] +fn browserstack_profile_run_reports_unsupported_native_capture() { + let ctx = sample_profile_context() + .with_backend(ProfileBackend::AndroidNative) + .with_provider("browserstack"); + + let error = validate_profile_capabilities(&ctx).unwrap_err(); + assert!(error.to_string().contains("BrowserStack")); + assert!(error.to_string().contains("unsupported")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test -p mobench browserstack_profile_run_reports_unsupported_native_capture -- --exact` + +Expected: FAIL because capability gating is not implemented. + +**Step 3: Write minimal implementation** + +Add capability validation that: + +- allows local Android and local iOS native backends +- rejects BrowserStack native profiling backends with a precise error +- records provider/capability warnings in dry-run and manifest output + +Do not silently downgrade to another backend. + +**Step 4: Run test to verify it passes** + +Run: `cargo test -p mobench browserstack_profile_run_reports_unsupported_native_capture -- --exact` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add crates/mobench/src/lib.rs crates/mobench/src/profile.rs +git commit -m "feat: gate unsupported browserstack profiling backends" +``` + +### Task 7: Add README And Build Documentation + +**Files:** +- Modify: `README.md` +- Modify: `BUILD.md` +- Modify: `TESTING.md` + +**Step 1: Write the failing doc check** + +Add a focused grep-based check to confirm the docs mention the new command +surface and prerequisites: + +```bash +rg -n "mobench profile run|simpleperf|xctrace|profile summarize" README.md BUILD.md TESTING.md +``` + +Expected: no matches before the doc update. + +**Step 2: Run check to verify it fails** + +Run: `rg -n "mobench profile run|simpleperf|xctrace|profile summarize" README.md BUILD.md TESTING.md` + +Expected: exit 1 or missing coverage for the new profiling workflow. + +**Step 3: Write minimal documentation** + +Document: + +- the new `mobench profile` commands +- Android prerequisites (`adb`, `simpleperf`, symbols) +- iOS prerequisites (`xcrun`, `xctrace`, dSYMs) +- BrowserStack profiling limitations in the MVP + +Keep the current CI v1 contract language unchanged. + +**Step 4: Run check to verify it passes** + +Run: `rg -n "mobench profile run|simpleperf|xctrace|profile summarize" README.md BUILD.md TESTING.md` + +Expected: matching lines in all relevant docs. + +**Step 5: Commit** + +```bash +git add README.md BUILD.md TESTING.md +git commit -m "docs: add profiling workflow guidance" +``` + +### Task 8: Run Final Verification + +**Files:** +- Modify: any files needed to fix verification failures + +**Step 1: Run targeted tests for the new profile subsystem** + +Run: `cargo test -p mobench profile_ -- --nocapture` + +Expected: PASS + +**Step 2: Run the full workspace test suite** + +Run: `cargo test` + +Expected: PASS + +**Step 3: Run formatting** + +Run: `cargo fmt --all --check` + +Expected: PASS + +**Step 4: Run a dry-run CLI smoke test** + +Run: + +```bash +cargo run -p mobench --bin mobench -- profile run \ + --target android \ + --function sample_fns::fibonacci \ + --backend android-native +``` + +Expected: PASS with a run-scoped profile session under +`target/mobench/profile//` and refreshed latest-session files at +`target/mobench/profile/profile.json` and `target/mobench/profile/summary.md`. + +**Step 5: Commit** + +```bash +git add . +git commit -m "feat: add mobench profiling subsystem" +``` From 031678ff7f77d9ed41d3ed0781c05e993c11d84c Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 15:09:06 -0700 Subject: [PATCH 114/196] ci: add mobench fixture workflows --- .github/actions/mobench/README.md | 6 +- .github/actions/mobench/action.yml | 12 +- .github/workflows/compile-gate.yml | 21 ++ .../workflows/mobile-bench-plot-fixtures.yml | 50 ++++ .github/workflows/mobile-bench-pr-auto.yml | 23 ++ .github/workflows/mobile-bench-pr-command.yml | 19 ++ .github/workflows/mobile-bench.yml | 279 ++++++++---------- crates/mobench/templates/ci/action.README.md | 6 +- crates/mobench/templates/ci/action.yml | 12 +- examples/fixtures/basic/summary.json | 84 ++++++ examples/fixtures/ffi/summary.json | 84 ++++++ scripts/ci/verify-example-plot-fixture.sh | 53 ++++ 12 files changed, 482 insertions(+), 167 deletions(-) create mode 100644 .github/workflows/compile-gate.yml create mode 100644 .github/workflows/mobile-bench-plot-fixtures.yml create mode 100644 .github/workflows/mobile-bench-pr-auto.yml create mode 100644 .github/workflows/mobile-bench-pr-command.yml create mode 100644 examples/fixtures/basic/summary.json create mode 100644 examples/fixtures/ffi/summary.json create mode 100755 scripts/ci/verify-example-plot-fixture.sh diff --git a/.github/actions/mobench/README.md b/.github/actions/mobench/README.md index b7f0d01..df41f08 100644 --- a/.github/actions/mobench/README.md +++ b/.github/actions/mobench/README.md @@ -7,7 +7,7 @@ Run `mobench ci run` in GitHub Actions with caching, Android SDK setup, and arti ```yaml - uses: ./.github/actions/mobench with: - command: cargo mobench ci run + command: mobench ci run run-args: | --target android --function sample_fns::fibonacci @@ -25,9 +25,9 @@ Run `mobench ci run` in GitHub Actions with caching, Android SDK setup, and arti ## Inputs -- `command`: command to invoke. Supported values are `cargo mobench ci run` (default) and `cargo mobench run`. +- `command`: command to invoke. Supported values are `mobench ci run` (default), `mobench run`, `cargo mobench ci run`, and `cargo mobench run`. - `run-args`: arguments passed to `command`. Use quoted values for arguments containing spaces (for example device names). -- `ci`: append `--ci` only when `command` is exactly `cargo mobench run`; ignored for `cargo mobench ci run`. +- `ci`: append `--ci` only when `command` is `mobench run` or `cargo mobench run`; ignored for `ci run`. - `install-mobench`: install `mobench` with cargo-binstall/cargo install. - `mobench-version`: optional version to install. - `install-cargo-ndk`: install `cargo-ndk` for Android builds. diff --git a/.github/actions/mobench/action.yml b/.github/actions/mobench/action.yml index 50dded6..89a4b6d 100644 --- a/.github/actions/mobench/action.yml +++ b/.github/actions/mobench/action.yml @@ -2,14 +2,14 @@ name: "mobench" description: "Run mobile benchmarks with mobench" inputs: command: - description: "Command to invoke (supported: cargo mobench ci run | cargo mobench run)" + description: "Command to invoke (supported: mobench ci run | mobench run | cargo mobench ci run | cargo mobench run)" required: false - default: "cargo mobench ci run" + default: "mobench ci run" run-args: description: "Arguments passed to command (quote values containing spaces)" required: true ci: - description: "Append --ci only when command is cargo mobench run (ignored for ci run)" + description: "Append --ci only when command is mobench run or cargo mobench run (ignored for ci run)" required: false default: "false" install-mobench: @@ -182,11 +182,11 @@ runs: export ANDROID_HOME="$ANDROID_SDK_ROOT_INPUT" export ANDROID_NDK_HOME="$ANDROID_SDK_ROOT_INPUT/ndk/$NDK_VERSION_INPUT" extra_args=() - if [ "$MOBENCH_CI" = "true" ] && [ "$MOBENCH_COMMAND" = "cargo mobench run" ]; then + if [ "$MOBENCH_CI" = "true" ] && { [ "$MOBENCH_COMMAND" = "cargo mobench run" ] || [ "$MOBENCH_COMMAND" = "mobench run" ]; }; then extra_args=(--ci) fi case "$MOBENCH_COMMAND" in - "cargo mobench run"|"cargo mobench ci run") + "cargo mobench run"|"cargo mobench ci run"|"mobench run"|"mobench ci run") ;; *) echo "Unsupported command input: $MOBENCH_COMMAND" @@ -230,7 +230,7 @@ runs: echo "GITHUB_TOKEN is empty; skipping sticky comment publish." exit 0 fi - cargo mobench report github \ + mobench report github \ --pr "$pr_number" \ --summary target/mobench/ci/summary.json \ --marker "$PR_COMMENT_MARKER" \ diff --git a/.github/workflows/compile-gate.yml b/.github/workflows/compile-gate.yml new file mode 100644 index 0000000..92f47fc --- /dev/null +++ b/.github/workflows/compile-gate.yml @@ -0,0 +1,21 @@ +name: Compile Gate + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: compile-gate-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + compile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - name: Compile the workspace + run: cargo test --workspace --locked --no-run diff --git a/.github/workflows/mobile-bench-plot-fixtures.yml b/.github/workflows/mobile-bench-plot-fixtures.yml new file mode 100644 index 0000000..f02c56a --- /dev/null +++ b/.github/workflows/mobile-bench-plot-fixtures.yml @@ -0,0 +1,50 @@ +name: Mobile Bench Plot Fixtures + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + plot-fixtures: + name: Plot fixture ${{ matrix.fixture }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + fixture: [basic, ffi] + + steps: + - uses: actions/checkout@v5 + + - uses: dtolnay/rust-toolchain@stable + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install matplotlib + run: python -m pip install --upgrade pip matplotlib + + - name: Build local mobench binaries + run: cargo build -p mobench --bins --locked + + - name: Add local mobench binaries to PATH + run: echo "${GITHUB_WORKSPACE}/target/debug" >> "$GITHUB_PATH" + + - name: Render and verify plot fixtures + run: scripts/ci/verify-example-plot-fixture.sh ${{ matrix.fixture }} + + - name: Upload plot artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: mobench-plot-${{ matrix.fixture }} + path: target/mobench/plot-fixtures/${{ matrix.fixture }} + if-no-files-found: error diff --git a/.github/workflows/mobile-bench-pr-auto.yml b/.github/workflows/mobile-bench-pr-auto.yml new file mode 100644 index 0000000..3b92800 --- /dev/null +++ b/.github/workflows/mobile-bench-pr-auto.yml @@ -0,0 +1,23 @@ +name: Mobile Bench PR Auto + +on: + pull_request: + types: [labeled] + workflow_run: + workflows: ["Compile Gate"] + types: [completed] + +permissions: + actions: write + contents: read + pull-requests: read + checks: read + +jobs: + dispatch: + uses: ./.github/workflows/reusable-pr-auto.yml + with: + benchmark_workflow: .github/workflows/mobile-bench.yml + compile_gate_workflow_name: "Compile Gate" + crate_path: examples/ffi-benchmark + functions: ffi_benchmark::bench_fibonacci,ffi_benchmark::bench_checksum diff --git a/.github/workflows/mobile-bench-pr-command.yml b/.github/workflows/mobile-bench-pr-command.yml new file mode 100644 index 0000000..2ca5d64 --- /dev/null +++ b/.github/workflows/mobile-bench-pr-command.yml @@ -0,0 +1,19 @@ +name: Mobile Bench PR Command + +on: + issue_comment: + types: [created] + +permissions: + actions: write + contents: read + issues: read + pull-requests: read + +jobs: + dispatch: + uses: ./.github/workflows/reusable-pr-command.yml + with: + benchmark_workflow: .github/workflows/mobile-bench.yml + crate_path: examples/ffi-benchmark + functions: ffi_benchmark::bench_fibonacci,ffi_benchmark::bench_checksum diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index b8b46b5..6aaa1a2 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -1,4 +1,4 @@ -name: Mobile Bench (manual) +name: Mobile Bench on: workflow_dispatch: @@ -6,173 +6,154 @@ on: platform: description: "android | ios | both" required: false - default: "android" + type: choice + default: both + options: + - android + - ios + - both + crate_path: + description: "Benchmark crate fixture path" + required: false + type: string + default: "examples/ffi-benchmark" + functions: + description: "Comma-separated benchmark functions" + required: false + type: string + default: "ffi_benchmark::bench_fibonacci,ffi_benchmark::bench_checksum" + iterations: + description: "Number of benchmark iterations" + required: false + type: string + default: "5" + warmup: + description: "Number of warmup iterations" + required: false + type: string + default: "1" + pr_number: + description: "PR number for sticky comment publishing" + required: false + type: string + default: "" + head_sha: + description: "Exact commit SHA to benchmark" + required: false + type: string + default: "" + requested_by: + description: "Who triggered the run" + required: false + type: string + default: "" permissions: + actions: read contents: read + issues: write + pull-requests: write -jobs: - tests: - name: Host tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Rust toolchain - uses: dtolnay/rust-toolchain@stable +concurrency: + group: mobench-${{ inputs.pr_number != '' && inputs.pr_number || github.run_id }} + cancel-in-progress: false - - name: Cargo test - run: cargo test --all +env: + CARGO_TERM_COLOR: always - - name: Host benchmark summary - run: | - cargo run -p mobench -- run \ - --target android \ - --function sample_fns::fibonacci \ - --iterations 5 \ - --warmup 1 \ - --local-only \ - --output run-summary.json \ - --summary-csv - - - name: Publish host summary - if: ${{ always() }} - run: | - if [ -f run-summary.md ]; then - cat run-summary.md >> "$GITHUB_STEP_SUMMARY" - echo "::notice title=Host benchmark summary::Published to job summary" - fi - - - name: Upload host summaries - uses: actions/upload-artifact@v4 - with: - name: host-run-summary - path: | - run-summary.json - run-summary.md - run-summary.csv - - android: - if: ${{ github.event.inputs.platform == 'android' || github.event.inputs.platform == 'both' || github.event.inputs.platform == '' }} - name: Android build (APK) - needs: tests +jobs: + benchmark: + name: Example fixture benchmark runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Rust toolchain - uses: dtolnay/rust-toolchain@stable - - name: Install cargo-ndk - run: cargo install cargo-ndk - - - name: Setup Android SDK/NDK - uses: android-actions/setup-android@v3 - with: - packages: | - platform-tools - platforms;android-34 - build-tools;34.0.0 - ndk;26.1.10909125 - - - name: Build Android artifacts - run: cargo mobench build --target android - - - name: Setup Gradle - uses: gradle/gradle-build-action@v3 - with: - gradle-version: 8.7 - - - name: Assemble APK - working-directory: target/mobench/android - run: gradle :app:assembleDebug - - - name: Upload APK artifact - uses: actions/upload-artifact@v4 - with: - name: mobile-bench-android-apk - path: target/mobench/android/app/build/outputs/apk/debug/*.apk - - ios: - if: ${{ github.event.inputs.platform == 'ios' || github.event.inputs.platform == 'both' || github.event.inputs.platform == '' }} - name: iOS xcframework - needs: tests - runs-on: macos-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Rust toolchain - uses: dtolnay/rust-toolchain@stable + - name: Checkout caller repo + uses: actions/checkout@v5 with: - targets: aarch64-apple-ios,aarch64-apple-ios-sim - - - name: Build iOS xcframework + header - run: cargo mobench build --target ios + ref: ${{ inputs.head_sha || github.sha }} - - name: Upload iOS artifacts - uses: actions/upload-artifact@v4 - with: - name: mobile-bench-ios - path: target/mobench/ios/sample_fns.xcframework - - browserstack: - name: BrowserStack run - needs: android - if: ${{ secrets.BROWSERSTACK_USERNAME != '' && secrets.BROWSERSTACK_ACCESS_KEY != '' }} - runs-on: ubuntu-latest - env: - BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} - BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Rust toolchain + - name: Setup Rust uses: dtolnay/rust-toolchain@stable - - name: Install cargo-ndk - run: cargo install cargo-ndk + - name: Build local mobench binaries + run: cargo build -p mobench --bins --locked - - name: Setup Android SDK/NDK - uses: android-actions/setup-android@v3 - with: - packages: | - platform-tools - platforms;android-34 - build-tools;34.0.0 - ndk;26.1.10909125 + - name: Add local mobench binaries to PATH + run: echo "${GITHUB_WORKSPACE}/target/debug" >> "$GITHUB_PATH" - - name: BrowserStack benchmark run + - name: Resolve fixture metadata + id: fixture env: - ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/26.1.10909125 + CRATE_PATH: ${{ inputs.crate_path }} + shell: bash run: | - cargo run -p mobench -- run \ - --target android \ - --function sample_fns::fibonacci \ - --iterations 5 \ - --warmup 1 \ - --devices "Pixel 7-13" \ - --fetch \ - --output run-summary.json \ - --summary-csv + set -euo pipefail + slug=$(basename "${CRATE_PATH}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//') + if [ -z "${slug}" ]; then + slug="example-fixture" + fi + echo "slug=${slug}" >> "$GITHUB_OUTPUT" + echo "comment_marker=" >> "$GITHUB_OUTPUT" - - name: Publish BrowserStack summary - if: ${{ always() }} + - name: Build mobench run args + id: args + env: + PLATFORM: ${{ inputs.platform }} + CRATE_PATH: ${{ inputs.crate_path }} + FUNCTIONS: ${{ inputs.functions }} + ITERATIONS: ${{ inputs.iterations }} + WARMUP: ${{ inputs.warmup }} + PR_NUMBER: ${{ inputs.pr_number }} + REQUESTED_BY_INPUT: ${{ inputs.requested_by }} + MOBENCH_REF_INPUT: ${{ inputs.head_sha || github.sha }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + shell: bash run: | - if [ -f run-summary.md ]; then - cat run-summary.md >> "$GITHUB_STEP_SUMMARY" - echo "::notice title=BrowserStack benchmark summary::Published to job summary" + set -euo pipefail + + requested_by="${REQUESTED_BY_INPUT:-}" + if [ -z "${requested_by}" ]; then + requested_by="${GITHUB_EVENT_NAME}:${GITHUB_ACTOR}" fi - - name: Upload BrowserStack summaries - uses: actions/upload-artifact@v4 + { + echo "run_args<> "$GITHUB_OUTPUT" + + - name: Run mobench fixture CI + uses: ./.github/actions/mobench with: - name: browserstack-run-summary - path: | - run-summary.json - run-summary.md - run-summary.csv - target/browserstack + command: mobench ci run + run-args: ${{ steps.args.outputs.run_args }} + install-mobench: "false" + install-cargo-ndk: "false" + setup-android: "false" + cache-gradle: "false" + cache-android: "false" + artifact-name: mobench-${{ steps.fixture.outputs.slug }}-results + pr-comment: ${{ inputs.pr_number != '' && 'true' || 'false' }} + pr-number: ${{ inputs.pr_number }} + pr-comment-marker: ${{ steps.fixture.outputs.comment_marker }} + github-token: ${{ github.token }} + + - name: Publish job summary + if: always() + shell: bash + run: | + if [ -f target/mobench/ci/summary.md ]; then + cat target/mobench/ci/summary.md >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/crates/mobench/templates/ci/action.README.md b/crates/mobench/templates/ci/action.README.md index b7f0d01..df41f08 100644 --- a/crates/mobench/templates/ci/action.README.md +++ b/crates/mobench/templates/ci/action.README.md @@ -7,7 +7,7 @@ Run `mobench ci run` in GitHub Actions with caching, Android SDK setup, and arti ```yaml - uses: ./.github/actions/mobench with: - command: cargo mobench ci run + command: mobench ci run run-args: | --target android --function sample_fns::fibonacci @@ -25,9 +25,9 @@ Run `mobench ci run` in GitHub Actions with caching, Android SDK setup, and arti ## Inputs -- `command`: command to invoke. Supported values are `cargo mobench ci run` (default) and `cargo mobench run`. +- `command`: command to invoke. Supported values are `mobench ci run` (default), `mobench run`, `cargo mobench ci run`, and `cargo mobench run`. - `run-args`: arguments passed to `command`. Use quoted values for arguments containing spaces (for example device names). -- `ci`: append `--ci` only when `command` is exactly `cargo mobench run`; ignored for `cargo mobench ci run`. +- `ci`: append `--ci` only when `command` is `mobench run` or `cargo mobench run`; ignored for `ci run`. - `install-mobench`: install `mobench` with cargo-binstall/cargo install. - `mobench-version`: optional version to install. - `install-cargo-ndk`: install `cargo-ndk` for Android builds. diff --git a/crates/mobench/templates/ci/action.yml b/crates/mobench/templates/ci/action.yml index 50dded6..89a4b6d 100644 --- a/crates/mobench/templates/ci/action.yml +++ b/crates/mobench/templates/ci/action.yml @@ -2,14 +2,14 @@ name: "mobench" description: "Run mobile benchmarks with mobench" inputs: command: - description: "Command to invoke (supported: cargo mobench ci run | cargo mobench run)" + description: "Command to invoke (supported: mobench ci run | mobench run | cargo mobench ci run | cargo mobench run)" required: false - default: "cargo mobench ci run" + default: "mobench ci run" run-args: description: "Arguments passed to command (quote values containing spaces)" required: true ci: - description: "Append --ci only when command is cargo mobench run (ignored for ci run)" + description: "Append --ci only when command is mobench run or cargo mobench run (ignored for ci run)" required: false default: "false" install-mobench: @@ -182,11 +182,11 @@ runs: export ANDROID_HOME="$ANDROID_SDK_ROOT_INPUT" export ANDROID_NDK_HOME="$ANDROID_SDK_ROOT_INPUT/ndk/$NDK_VERSION_INPUT" extra_args=() - if [ "$MOBENCH_CI" = "true" ] && [ "$MOBENCH_COMMAND" = "cargo mobench run" ]; then + if [ "$MOBENCH_CI" = "true" ] && { [ "$MOBENCH_COMMAND" = "cargo mobench run" ] || [ "$MOBENCH_COMMAND" = "mobench run" ]; }; then extra_args=(--ci) fi case "$MOBENCH_COMMAND" in - "cargo mobench run"|"cargo mobench ci run") + "cargo mobench run"|"cargo mobench ci run"|"mobench run"|"mobench ci run") ;; *) echo "Unsupported command input: $MOBENCH_COMMAND" @@ -230,7 +230,7 @@ runs: echo "GITHUB_TOKEN is empty; skipping sticky comment publish." exit 0 fi - cargo mobench report github \ + mobench report github \ --pr "$pr_number" \ --summary target/mobench/ci/summary.json \ --marker "$PR_COMMENT_MARKER" \ diff --git a/examples/fixtures/basic/summary.json b/examples/fixtures/basic/summary.json new file mode 100644 index 0000000..ff47d09 --- /dev/null +++ b/examples/fixtures/basic/summary.json @@ -0,0 +1,84 @@ +{ + "summary": { + "generated_at": "2026-03-26T00:00:00Z", + "generated_at_unix": 1774483200, + "target": "android", + "function": "multiple", + "iterations": 5, + "warmup": 1, + "devices": [ + "Google Pixel 8-14.0", + "Samsung Galaxy S23-14.0" + ], + "device_summaries": [ + { + "device": "Google Pixel 8-14.0", + "benchmarks": [ + { + "function": "basic_benchmark::bench_fibonacci", + "samples": 5, + "mean_ns": 100000000, + "median_ns": 100000000, + "p95_ns": 105000000, + "min_ns": 95000000, + "max_ns": 105000000 + }, + { + "function": "basic_benchmark::bench_checksum", + "samples": 5, + "mean_ns": 145000000, + "median_ns": 145000000, + "p95_ns": 151000000, + "min_ns": 140000000, + "max_ns": 151000000 + } + ] + }, + { + "device": "Samsung Galaxy S23-14.0", + "benchmarks": [ + { + "function": "basic_benchmark::bench_fibonacci", + "samples": 5, + "mean_ns": 94000000, + "median_ns": 94000000, + "p95_ns": 98000000, + "min_ns": 90000000, + "max_ns": 98000000 + }, + { + "function": "basic_benchmark::bench_checksum", + "samples": 5, + "mean_ns": 136000000, + "median_ns": 136000000, + "p95_ns": 140000000, + "min_ns": 132000000, + "max_ns": 140000000 + } + ] + } + ] + }, + "benchmark_results": { + "Google Pixel 8-14.0": [ + { + "function": "basic_benchmark::bench_fibonacci", + "samples": [95000000, 98000000, 100000000, 102000000, 105000000] + }, + { + "function": "basic_benchmark::bench_checksum", + "samples": [140000000, 142000000, 145000000, 147000000, 151000000] + } + ], + "Samsung Galaxy S23-14.0": [ + { + "function": "basic_benchmark::bench_fibonacci", + "samples": [90000000, 92000000, 94000000, 96000000, 98000000] + }, + { + "function": "basic_benchmark::bench_checksum", + "samples": [132000000, 134000000, 136000000, 138000000, 140000000] + } + ] + } +} diff --git a/examples/fixtures/ffi/summary.json b/examples/fixtures/ffi/summary.json new file mode 100644 index 0000000..be49897 --- /dev/null +++ b/examples/fixtures/ffi/summary.json @@ -0,0 +1,84 @@ +{ + "summary": { + "generated_at": "2026-03-26T00:00:00Z", + "generated_at_unix": 1774483200, + "target": "android", + "function": "multiple", + "iterations": 5, + "warmup": 1, + "devices": [ + "Google Pixel 8 Pro-14.0", + "OnePlus 12-14.0" + ], + "device_summaries": [ + { + "device": "Google Pixel 8 Pro-14.0", + "benchmarks": [ + { + "function": "ffi_benchmark::bench_fibonacci", + "samples": 5, + "mean_ns": 112000000, + "median_ns": 112000000, + "p95_ns": 118000000, + "min_ns": 108000000, + "max_ns": 118000000 + }, + { + "function": "ffi_benchmark::bench_checksum", + "samples": 5, + "mean_ns": 159000000, + "median_ns": 159000000, + "p95_ns": 165000000, + "min_ns": 154000000, + "max_ns": 165000000 + } + ] + }, + { + "device": "OnePlus 12-14.0", + "benchmarks": [ + { + "function": "ffi_benchmark::bench_fibonacci", + "samples": 5, + "mean_ns": 103000000, + "median_ns": 103000000, + "p95_ns": 107000000, + "min_ns": 99000000, + "max_ns": 107000000 + }, + { + "function": "ffi_benchmark::bench_checksum", + "samples": 5, + "mean_ns": 149000000, + "median_ns": 149000000, + "p95_ns": 153000000, + "min_ns": 145000000, + "max_ns": 153000000 + } + ] + } + ] + }, + "benchmark_results": { + "Google Pixel 8 Pro-14.0": [ + { + "function": "ffi_benchmark::bench_fibonacci", + "samples": [108000000, 110000000, 112000000, 114000000, 118000000] + }, + { + "function": "ffi_benchmark::bench_checksum", + "samples": [154000000, 156000000, 159000000, 161000000, 165000000] + } + ], + "OnePlus 12-14.0": [ + { + "function": "ffi_benchmark::bench_fibonacci", + "samples": [99000000, 101000000, 103000000, 105000000, 107000000] + }, + { + "function": "ffi_benchmark::bench_checksum", + "samples": [145000000, 147000000, 149000000, 151000000, 153000000] + } + ] + } +} diff --git a/scripts/ci/verify-example-plot-fixture.sh b/scripts/ci/verify-example-plot-fixture.sh new file mode 100755 index 0000000..59c7a9f --- /dev/null +++ b/scripts/ci/verify-example-plot-fixture.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +fixture="${1:-}" + +if [ -z "${fixture}" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +case "${fixture}" in + basic) + summary_path="examples/fixtures/basic/summary.json" + output_dir="target/mobench/plot-fixtures/basic" + expected_plots=("fibonacci.svg" "checksum.svg") + ;; + ffi) + summary_path="examples/fixtures/ffi/summary.json" + output_dir="target/mobench/plot-fixtures/ffi" + expected_plots=("fibonacci.svg" "checksum.svg") + ;; + *) + echo "unknown fixture: ${fixture}" >&2 + exit 1 + ;; +esac + +rm -rf "${output_dir}" +mkdir -p "${output_dir}" + +mobench report summarize \ + --summary "${summary_path}" \ + --output "${output_dir}/summary.md" \ + --plots require + +markdown="${output_dir}/summary.md" + +if ! grep -Fq "## Device Comparison Plots" "${markdown}"; then + echo "expected Device Comparison Plots section in ${markdown}" >&2 + exit 1 +fi + +for plot in "${expected_plots[@]}"; do + plot_path="${output_dir}/plots/${plot}" + if [ ! -s "${plot_path}" ]; then + echo "expected rendered plot at ${plot_path}" >&2 + exit 1 + fi + if ! grep -Fq "](plots/${plot})" "${markdown}"; then + echo "expected markdown link for ${plot}" >&2 + exit 1 + fi +done From d83835eb03035c7c20852b74b0b4fd02d0304fbf Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 15:09:30 -0700 Subject: [PATCH 115/196] chore: ignore project worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6605443..a3871ef 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ target/ .claude/ .conductor/ .cursor/ +.worktrees/ android/app/src/main/jniLibs/*/libsample_fns.so android/app/src/main/jniLibs/*/libuniffi_sample_fns.so android/build/ From fdbb3e17cbc25a87b7edec783054cec9112c7eaf Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 15:21:31 -0700 Subject: [PATCH 116/196] feat: make profiling local-first and explicit --- README.md | 48 ++- crates/mobench/src/lib.rs | 179 ++++++++-- crates/mobench/src/profile.rs | 506 +++++++++++++++++++++------- crates/mobench/tests/profile_cli.rs | 124 +++++++ 4 files changed, 710 insertions(+), 147 deletions(-) create mode 100644 crates/mobench/tests/profile_cli.rs diff --git a/README.md b/README.md index b8374f5..7881faa 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,9 @@ cargo mobench ci run --target android --function sample_fns::fibonacci --local-o cargo mobench report summarize --summary target/mobench/ci/summary.json --plots auto cargo mobench report github --pr 123 --summary target/mobench/ci/summary.json -# Experimental profiling session contract -cargo mobench profile run --target android --function sample_fns::fibonacci --backend android-native +# Experimental profiling session contract (local-first in this release) +cargo mobench profile run --target android --function sample_fns::fibonacci \ + --provider local --backend android-native cargo mobench profile summarize --profile target/mobench/profile/profile.json ``` @@ -96,16 +97,34 @@ CI contract outputs are written to `target/mobench/ci/`: Local summary renderers (`ci run --plots ...` and `report summarize --plots ...`) append a `Device Comparison Plots` section with one Sina-style SVG per benchmark function. Summary resource fields use `cpu_total_ms` and `peak_memory_kb`; Android raw resource stats are preserved and iOS peak memory is enriched from BrowserStack app profiling when available. -Experimental profiling commands write each planned session under -`target/mobench/profile//` and also refresh top-level -`target/mobench/profile/profile.json` and `summary.md` as convenience copies of -the latest run. Backend-specific raw and processed artifact directories are -created under each run-scoped `artifacts/` tree, and the normalized manifest -records the selected provider and requested output format. The current -implementation captures the profile-session contract and platform-specific -artifact layout; it does not yet execute native capture tools automatically. -BrowserStack-backed native profiling backends fail explicitly rather than -silently degrading. +Experimental profiling commands are local-first in this release. Each planned +session is written under `target/mobench/profile//`, and the CLI also +refreshes top-level `target/mobench/profile/profile.json` and `summary.md` as +convenience copies of the latest run. + +Profiling capability matrix: + +| Provider | Backend | Current behavior | Notes | +|----------|---------|------------------|-------| +| `local` | `android-native` | Planned manifest only | Native `simpleperf` capture is not implemented yet | +| `local` | `ios-instruments` | Planned manifest only | iOS output is an Instruments trace (`time-profiler.trace`) plus XML export (`time-profiler.xml`), not a flamegraph | +| `local` | `rust-tracing` | Planned manifest only | Structured trace output is local-only | +| `browserstack` | `android-native` | Unsupported | Use `--provider local` for planning/local capture, or a normal BrowserStack benchmark for timing/memory metrics | +| `browserstack` | `ios-instruments` | Unsupported | BrowserStack does not provide retrievable native Instruments trace artifacts in this release | +| `browserstack` | `rust-tracing` | Unsupported | Use `--provider local` for trace-events output | + +`profile run --dry-run` always stops after target resolution plus planning and +writes the planned manifest only. Non-dry-run profile runs currently do not +execute local native capture tools automatically, and BrowserStack-backed native +profiling fails deliberately with an explanatory error instead of silently +pretending to capture data. + +When you need device-specific planning inputs for profiling, `profile run` +reuses the same resolution model as `devices resolve`: + +- `--device "iPhone 14" --os-version 16` +- `--profile high-spec` +- `--profile high-spec --device-matrix device-matrix.yaml` ## Configuration @@ -227,6 +246,11 @@ fn db_query(db: &Database) { ### v0.1.25 +- Clarified that profiling remains local-first in this release; BrowserStack native profiling is explicitly unsupported with actionable error text and a visible capability matrix. +- Split `profile run` into target resolution, capture planning, and capture execution seams so planned manifests no longer imply that native capture actually ran. +- Added device-selection inputs to `profile run` (`--device`, `--os-version`, `--profile`, `--device-matrix`) by reusing the existing deterministic device-resolution flow. +- Corrected the iOS artifact story: the planned output is an Instruments trace/XML export contract, not a flamegraph. +- Added regression coverage for profile help text, BrowserStack unsupported execution, dry-run planning semantics, and direct device target resolution. - Added experimental `cargo mobench profile run|summarize` commands for a normalized local profiling session contract across Android and iOS. - Profile sessions now write run-scoped artifacts under `target/mobench/profile//` and refresh top-level latest-session `profile.json` and `summary.md` convenience files. - Profile manifests now preserve the selected provider and requested output format, and the CLI rejects unsupported format/backend combinations explicitly instead of silently planning the wrong artifacts. diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index c2ca6c8..c58bf57 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -662,7 +662,7 @@ enum ReportCommand { #[derive(Subcommand, Debug)] enum ProfileCommand { - /// Run a native profiling session and write profile artifacts. + /// Plan or execute a native profiling session depending on backend/provider support. Run(profile::ProfileRunArgs), /// Render markdown or JSON from a normalized profile manifest. Summarize(profile::ProfileSummarizeArgs), @@ -846,7 +846,7 @@ impl CiTarget { #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] #[clap(rename_all = "lowercase")] -enum DevicePlatform { +pub(crate) enum DevicePlatform { Android, Ios, } @@ -1866,7 +1866,7 @@ pub fn run() -> Result<()> { }, Command::Profile { command } => match command { ProfileCommand::Run(args) => { - profile::cmd_profile_run(&args)?; + profile::cmd_profile_run(&args, cli.dry_run)?; } ProfileCommand::Summarize(args) => { profile::cmd_profile_summarize(&args)?; @@ -6697,14 +6697,21 @@ fn cmd_devices( Ok(()) } -#[derive(Debug, Clone, Serialize)] -struct ResolvedMatrixDevice { - name: String, - os: String, - os_version: String, - identifier: String, +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ResolvedMatrixDevice { + pub(crate) name: String, + pub(crate) os: String, + pub(crate) os_version: String, + pub(crate) identifier: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] - tags: Vec, + pub(crate) tags: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResolvedDeviceProfile { + pub(crate) profile: String, + pub(crate) source: String, + pub(crate) devices: Vec, } /// Built-in device profiles so `devices resolve` works without a YAML file. @@ -6730,21 +6737,18 @@ fn builtin_device_for_profile( }) } -fn cmd_devices_resolve( +pub(crate) fn resolve_devices_for_profile( platform: DevicePlatform, - profile: Option, + profile: Option<&str>, config_path: Option<&Path>, device_matrix_path: Option<&Path>, - format: CheckOutputFormat, -) -> Result<()> { +) -> Result { let profile_str = profile - .as_deref() - .map(|v| v.trim()) - .filter(|v| !v.is_empty()) + .map(str::trim) + .filter(|value| !value.is_empty()) .unwrap_or("default"); - // Try loading matrix from file first; fall back to built-in profiles - let (resolved, source) = match resolve_matrix_for_cli(config_path, device_matrix_path) { + let (devices, source) = match resolve_matrix_for_cli(config_path, device_matrix_path) { Ok((matrix_path, config_tags)) => { let matrix = load_device_matrix(&matrix_path).with_context(|| { format!( @@ -6763,7 +6767,6 @@ fn cmd_devices_resolve( (devices, format!("matrix:{}", matrix_path.display())) } Err(_) => { - // No matrix file found — use built-in profiles if let Some(device) = builtin_device_for_profile(platform, profile_str) { (vec![device], "builtin".to_string()) } else { @@ -6776,9 +6779,33 @@ fn cmd_devices_resolve( } }; + Ok(ResolvedDeviceProfile { + profile: profile_str.to_string(), + source, + devices, + }) +} + +fn cmd_devices_resolve( + platform: DevicePlatform, + profile: Option, + config_path: Option<&Path>, + device_matrix_path: Option<&Path>, + format: CheckOutputFormat, +) -> Result<()> { + let resolved_profile = resolve_devices_for_profile( + platform, + profile.as_deref(), + config_path, + device_matrix_path, + )?; + let profile_str = resolved_profile.profile.as_str(); + let resolved = &resolved_profile.devices; + let source = resolved_profile.source.as_str(); + match format { CheckOutputFormat::Text => { - for device in &resolved { + for device in resolved { println!("{}", device.identifier); } } @@ -7879,10 +7906,25 @@ fn check_xcodegen() -> PrereqCheck { #[cfg(test)] mod tests { use super::*; + use clap::CommandFactory; use jsonschema::JSONSchema; use std::path::Path; use tempfile::TempDir; + fn render_profile_run_help() -> String { + let mut root = Cli::command(); + let profile = root + .find_subcommand_mut("profile") + .expect("profile subcommand"); + let run = profile + .find_subcommand_mut("run") + .expect("profile run subcommand"); + let mut buffer = Vec::new(); + run.write_long_help(&mut buffer) + .expect("render profile run help"); + String::from_utf8(buffer).expect("help is utf-8") + } + #[cfg(unix)] pub(crate) fn write_fake_plot_python(dir: &Path) -> PathBuf { use std::os::unix::fs::PermissionsExt; @@ -8590,6 +8632,101 @@ project = "proj" } } + #[test] + fn profile_run_parses_direct_device_selection() { + let cli = Cli::parse_from([ + "mobench", + "profile", + "run", + "--target", + "ios", + "--function", + "sample_fns::fibonacci", + "--provider", + "browserstack", + "--backend", + "ios-instruments", + "--device", + "iPhone 14", + "--os-version", + "16", + ]); + + match cli.command { + Command::Profile { + command: ProfileCommand::Run(args), + } => { + assert_eq!(args.target, MobileTarget::Ios); + assert_eq!(args.device.as_deref(), Some("iPhone 14")); + assert_eq!(args.os_version.as_deref(), Some("16")); + } + _ => panic!("expected profile run command"), + } + } + + #[test] + fn profile_run_parses_profile_device_resolution_inputs() { + let cli = Cli::parse_from([ + "mobench", + "profile", + "run", + "--target", + "ios", + "--function", + "sample_fns::fibonacci", + "--provider", + "browserstack", + "--backend", + "ios-instruments", + "--profile", + "high-spec", + "--device-matrix", + "device-matrix.yaml", + ]); + + match cli.command { + Command::Profile { + command: ProfileCommand::Run(args), + } => { + assert_eq!(args.profile.as_deref(), Some("high-spec")); + assert_eq!( + args.device_matrix, + Some(PathBuf::from("device-matrix.yaml")) + ); + } + _ => panic!("expected profile run command"), + } + } + + #[test] + fn profile_run_help_mentions_planned_only_or_execution_scope() { + let help = render_profile_run_help(); + + assert!( + help.contains("plan") + || help.contains("Plan") + || help.contains("depending on backend/provider support"), + "expected profile run help to explain whether it plans or executes capture, got:\n{help}" + ); + assert!( + help.contains("BrowserStack") || help.contains("browserstack"), + "expected profile run help to mention BrowserStack capability scope, got:\n{help}" + ); + } + + #[test] + fn profile_run_cli_surface_exposes_or_explicitly_omits_device_selection() { + let help = render_profile_run_help(); + + assert!( + help.contains("--device") + || help.contains("--profile") + || help.contains("--device-matrix") + || help.contains("device selection is unavailable"), + "expected profile run help to either expose device selection or explicitly document that it is unavailable, got:\n{help}" + ); + } + #[test] fn profile_summarize_parses_with_default_profile_path() { let cli = Cli::parse_from(["mobench", "profile", "summarize"]); diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index b7bf2ad..d106f85 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use std::fmt::Write; use std::path::{Path, PathBuf}; -use crate::MobileTarget; +use crate::{DevicePlatform, MobileTarget, ResolvedMatrixDevice, resolve_devices_for_profile}; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -38,6 +38,23 @@ pub enum ProfileSummaryFormat { } #[derive(Debug, Clone, Args)] +#[command( + about = "Plan or execute a native profiling session depending on backend/provider support", + after_help = concat!( + "Capability matrix:\n", + " local + android-native: planned manifest today; native simpleperf capture is not implemented yet\n", + " local + ios-instruments: planned manifest today; Instruments trace export capture is not implemented yet\n", + " local + rust-tracing: planned manifest today; structured trace output is local-only\n", + " browserstack + android-native: unsupported for native capture in this release\n", + " browserstack + ios-instruments: unsupported for native capture in this release\n", + " browserstack + rust-tracing: unsupported; use local provider for trace-events output\n", + "\n", + "Device selection:\n", + " Use --device/--os-version for one explicit BrowserStack device, or --profile with\n", + " optional --device-matrix/--config to reuse the same deterministic resolution model as\n", + " `mobench devices resolve`.\n" + ) +)] pub struct ProfileRunArgs { #[arg(long, value_enum)] pub target: MobileTarget, @@ -52,6 +69,27 @@ pub struct ProfileRunArgs { pub config: Option, #[arg(long, default_value = "target/mobench/profile")] pub output_dir: PathBuf, + #[arg( + long, + help = "Explicit BrowserStack device name to resolve for this profiling request", + requires = "os_version", + conflicts_with_all = ["profile", "device_matrix"] + )] + pub device: Option, + #[arg( + long, + help = "OS version for --device (for example `16` or `14.0`)", + requires = "device", + conflicts_with_all = ["profile", "device_matrix"] + )] + pub os_version: Option, + #[arg(long, help = "Device profile/tag to resolve (for example `high-spec`)")] + pub profile: Option, + #[arg( + long, + help = "Path to device matrix YAML file used with --profile or config-based device tags" + )] + pub device_matrix: Option, #[arg(long, value_enum, default_value_t = ProfileProvider::Local)] pub provider: ProfileProvider, #[arg(long, value_enum, default_value_t = ProfileBackend::Auto)] @@ -105,6 +143,22 @@ fn default_profile_provider() -> ProfileProvider { ProfileProvider::Local } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResolvedProfileTarget { + backend: ProfileBackend, + device: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResolvedProfileDevice { + name: String, + os: String, + os_version: String, + identifier: String, + profile: Option, + source: String, +} + pub fn render_profile_markdown(manifest: &ProfileManifest) -> String { let mut markdown = String::new(); let _ = writeln!(markdown, "# Profile Summary"); @@ -176,13 +230,23 @@ pub fn write_profile_manifest(path: &Path, manifest: &ProfileManifest) -> Result Ok(()) } -pub fn cmd_profile_run(args: &ProfileRunArgs) -> Result<()> { - std::fs::create_dir_all(&args.output_dir)?; +pub fn cmd_profile_run(args: &ProfileRunArgs, dry_run: bool) -> Result<()> { + let target = resolve_profile_target(args)?; let run_id = build_run_id(args.target, &args.function); let run_output_dir = args.output_dir.join(&run_id); - std::fs::create_dir_all(&run_output_dir)?; + let mut manifest = build_capture_plan(args, &run_output_dir)?; + if dry_run { + manifest.warnings.push( + "dry-run enabled; capture planning stopped before execution and recorded the planned artifact contract only" + .into(), + ); + } else { + execute_capture(args, &target, &mut manifest)?; + } - let manifest = build_capture_plan(args, &run_output_dir)?; + std::fs::create_dir_all(&args.output_dir)?; + std::fs::create_dir_all(&run_output_dir)?; + create_selected_artifact_roots(&manifest.raw_artifacts, &manifest.processed_artifacts)?; let rendered_summary = render_profile_markdown(&manifest); let run_profile_path = run_output_dir.join("profile.json"); @@ -243,9 +307,16 @@ pub fn cmd_profile_summarize_for_test(args: &ProfileSummarizeArgs) -> Result Result { + let backend = resolve_backend(args.target, args.backend); + validate_format_capabilities(backend, args.format)?; + + let device = resolve_profile_device(args)?; + Ok(ResolvedProfileTarget { backend, device }) +} + fn build_capture_plan(args: &ProfileRunArgs, output_root: &Path) -> Result { let backend = resolve_backend(args.target, args.backend); - validate_profile_capabilities(args.provider, backend)?; validate_format_capabilities(backend, args.format)?; let raw_root = output_root.join("artifacts/raw"); @@ -285,7 +356,6 @@ fn build_capture_plan(args: &ProfileRunArgs, output_root: &Path) -> Result Result ProfileBack } } -fn validate_profile_capabilities(provider: ProfileProvider, backend: ProfileBackend) -> Result<()> { - if provider == ProfileProvider::Browserstack - && matches!( - backend, - ProfileBackend::AndroidNative | ProfileBackend::IosInstruments - ) - { - bail!( - "BrowserStack native profile capture is unsupported for the MVP; use local provider for {:?}", - backend - ); - } - Ok(()) -} - fn validate_format_capabilities(backend: ProfileBackend, format: ProfileFormat) -> Result<()> { if backend == ProfileBackend::RustTracing && format == ProfileFormat::Processed { bail!( @@ -364,7 +416,7 @@ fn select_artifacts( } } -fn ensure_selected_artifact_roots( +fn create_selected_artifact_roots( raw_artifacts: &[ArtifactRecord], processed_artifacts: &[ArtifactRecord], ) -> Result<()> { @@ -376,6 +428,123 @@ fn ensure_selected_artifact_roots( Ok(()) } +fn resolve_profile_device(args: &ProfileRunArgs) -> Result> { + match (args.device.as_deref(), args.os_version.as_deref()) { + (Some(device), Some(os_version)) => { + let identifier = format!("{device}-{os_version}"); + Ok(Some(ResolvedProfileDevice { + name: device.to_string(), + os: args.target.as_str().to_string(), + os_version: os_version.to_string(), + identifier, + profile: None, + source: "direct".into(), + })) + } + (None, None) => { + if args.profile.is_none() && args.device_matrix.is_none() { + return Ok(None); + } + + let platform = match args.target { + MobileTarget::Android => DevicePlatform::Android, + MobileTarget::Ios => DevicePlatform::Ios, + }; + let resolved = resolve_devices_for_profile( + platform, + args.profile.as_deref(), + args.config.as_deref(), + args.device_matrix.as_deref(), + )?; + if resolved.devices.len() != 1 { + bail!( + "profile run requires exactly one resolved device, but profile `{}` from {} produced {} devices; use --device/--os-version or a single-device profile", + resolved.profile, + resolved.source, + resolved.devices.len() + ); + } + Ok(Some(resolved_profile_device_from_matrix( + resolved.devices.into_iter().next().expect("single device"), + resolved.profile, + resolved.source, + ))) + } + _ => unreachable!("clap enforces paired --device/--os-version"), + } +} + +fn resolved_profile_device_from_matrix( + device: ResolvedMatrixDevice, + profile: String, + source: String, +) -> ResolvedProfileDevice { + ResolvedProfileDevice { + name: device.name, + os: device.os, + os_version: device.os_version, + identifier: device.identifier, + profile: Some(profile), + source, + } +} + +fn execute_capture( + args: &ProfileRunArgs, + target: &ResolvedProfileTarget, + manifest: &mut ProfileManifest, +) -> Result<()> { + let plan_only_warning = match (args.provider, target.backend) { + (ProfileProvider::Local, ProfileBackend::AndroidNative) => Some( + "local android-native capture is not implemented yet; this session records the planned simpleperf artifact contract only", + ), + (ProfileProvider::Local, ProfileBackend::IosInstruments) => Some( + "local ios-instruments capture is not implemented yet; this session records the planned Instruments trace/XML artifact contract only", + ), + (ProfileProvider::Local, ProfileBackend::RustTracing) => Some( + "local rust-tracing capture is not implemented yet; this session records the planned trace-events artifact contract only", + ), + (ProfileProvider::Browserstack, ProfileBackend::AndroidNative) => { + bail!(browserstack_native_capture_unsupported_message( + "android-native", + "local Android profiling produces simpleperf artifacts and flamegraphs when implemented", + )); + } + (ProfileProvider::Browserstack, ProfileBackend::IosInstruments) => { + bail!(browserstack_native_capture_unsupported_message( + "ios-instruments", + "local iOS profiling produces Instruments traces (`time-profiler.trace`) and XML exports (`time-profiler.xml`), not flamegraphs", + )); + } + (ProfileProvider::Browserstack, ProfileBackend::RustTracing) => { + bail!( + "BrowserStack rust-tracing capture is not implemented.\nThis command currently writes a local-first profile contract only.\nUse --provider local for trace-events output, or run a normal BrowserStack benchmark if you only need timing/memory metrics." + ); + } + (_, ProfileBackend::Auto) => unreachable!("auto backend should resolve before execution"), + }; + + if let Some(device) = &target.device { + manifest.warnings.push(format!( + "resolved target device: {} ({}, source: {})", + device.identifier, device.os, device.source + )); + } + if let Some(warning) = plan_only_warning { + manifest.warnings.push(warning.into()); + } + Ok(()) +} + +fn browserstack_native_capture_unsupported_message( + backend_label: &str, + artifact_guidance: &str, +) -> String { + format!( + "BrowserStack native profiling is not implemented for {backend_label}.\nThis command currently writes a local-first profile contract only.\nUse --provider local for planning/local capture, or run a normal BrowserStack benchmark if you only need timing/memory metrics.\n{artifact_guidance}." + ) +} + fn select_viewer_hint( backend: ProfileBackend, format: ProfileFormat, @@ -434,6 +603,28 @@ fn slugify_function_name(function: &str) -> String { mod tests { use super::*; + fn sample_run_args( + target: MobileTarget, + provider: ProfileProvider, + backend: ProfileBackend, + format: ProfileFormat, + ) -> ProfileRunArgs { + ProfileRunArgs { + target, + function: "sample_fns::fibonacci".into(), + crate_path: None, + config: None, + output_dir: PathBuf::from("target/mobench/profile"), + device: None, + os_version: None, + profile: None, + device_matrix: None, + provider, + backend, + format, + } + } + #[test] fn profile_manifest_serializes_partial_failure_state() { let manifest = sample_manifest(); @@ -473,16 +664,12 @@ mod tests { #[test] fn android_backend_builds_capture_plan_with_flamegraph_artifacts() { let plan = build_capture_plan( - &ProfileRunArgs { - target: MobileTarget::Android, - function: "sample_fns::fibonacci".into(), - crate_path: None, - config: None, - output_dir: PathBuf::from("target/mobench/profile"), - provider: ProfileProvider::Local, - backend: ProfileBackend::AndroidNative, - format: ProfileFormat::Both, - }, + &sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ), &PathBuf::from("target/mobench/profile"), ) .expect("android capture plan"); @@ -502,16 +689,12 @@ mod tests { #[test] fn profile_native_format_excludes_processed_artifacts_from_plan() { let plan = build_capture_plan( - &ProfileRunArgs { - target: MobileTarget::Android, - function: "sample_fns::fibonacci".into(), - crate_path: None, - config: None, - output_dir: PathBuf::from("target/mobench/profile"), - provider: ProfileProvider::Local, - backend: ProfileBackend::AndroidNative, - format: ProfileFormat::Native, - }, + &sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Native, + ), &PathBuf::from("target/mobench/profile"), ) .expect("native-only capture plan"); @@ -527,16 +710,12 @@ mod tests { #[test] fn ios_backend_allocates_trace_bundle_and_export_paths() { let plan = build_capture_plan( - &ProfileRunArgs { - target: MobileTarget::Ios, - function: "sample_fns::fibonacci".into(), - crate_path: None, - config: None, - output_dir: PathBuf::from("target/mobench/profile"), - provider: ProfileProvider::Local, - backend: ProfileBackend::IosInstruments, - format: ProfileFormat::Both, - }, + &sample_run_args( + MobileTarget::Ios, + ProfileProvider::Local, + ProfileBackend::IosInstruments, + ProfileFormat::Both, + ), &PathBuf::from("target/mobench/profile"), ) .expect("ios capture plan"); @@ -555,38 +734,69 @@ mod tests { #[test] fn browserstack_profile_run_reports_unsupported_native_capture() { - let error = build_capture_plan( - &ProfileRunArgs { - target: MobileTarget::Android, - function: "sample_fns::fibonacci".into(), - crate_path: None, - config: None, - output_dir: PathBuf::from("target/mobench/profile"), - provider: ProfileProvider::Browserstack, - backend: ProfileBackend::AndroidNative, - format: ProfileFormat::Both, - }, - &PathBuf::from("target/mobench/profile"), - ) - .unwrap_err(); + let args = sample_run_args( + MobileTarget::Android, + ProfileProvider::Browserstack, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ); + let target = resolve_profile_target(&args).expect("resolve target"); + let mut manifest = + build_capture_plan(&args, &PathBuf::from("target/mobench/profile")).expect("plan"); + let error = execute_capture(&args, &target, &mut manifest).unwrap_err(); assert!(error.to_string().contains("BrowserStack")); - assert!(error.to_string().contains("unsupported")); + assert!( + error.to_string().contains("unsupported") + || error.to_string().contains("not implemented") + ); + } + + #[test] + fn browserstack_native_profile_error_is_actionable() { + let args = sample_run_args( + MobileTarget::Ios, + ProfileProvider::Browserstack, + ProfileBackend::IosInstruments, + ProfileFormat::Both, + ); + let target = resolve_profile_target(&args).expect("resolve target"); + let mut manifest = + build_capture_plan(&args, &PathBuf::from("target/mobench/profile")).expect("plan"); + let error = execute_capture(&args, &target, &mut manifest).unwrap_err(); + + let message = error.to_string(); + assert!( + message.contains("BrowserStack native profiling is not implemented"), + "expected an explicit unsupported message, got: {message}" + ); + assert!( + message.contains("local-first profile contract") + || message.contains("planned artifact contract only"), + "expected the error to explain that profile run only records planned artifacts today, got: {message}" + ); + assert!( + message.contains("Use --provider local"), + "expected the error to tell the user what to do instead, got: {message}" + ); + assert!( + message.contains("Instruments") + || message.contains("time-profiler.trace") + || message.contains("time-profiler.xml") + || message.contains("flamegraph"), + "expected the error to clarify the iOS artifact story, got: {message}" + ); } #[test] fn profile_rust_tracing_processed_only_is_rejected() { let error = build_capture_plan( - &ProfileRunArgs { - target: MobileTarget::Android, - function: "sample_fns::fibonacci".into(), - crate_path: None, - config: None, - output_dir: PathBuf::from("target/mobench/profile"), - provider: ProfileProvider::Local, - backend: ProfileBackend::RustTracing, - format: ProfileFormat::Processed, - }, + &sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::RustTracing, + ProfileFormat::Processed, + ), &PathBuf::from("target/mobench/profile"), ) .unwrap_err(); @@ -598,29 +808,24 @@ mod tests { #[test] fn profile_run_writes_run_scoped_and_latest_manifest_files() { let dir = tempfile::tempdir().expect("temp dir"); - let android_args = ProfileRunArgs { - target: MobileTarget::Android, - function: "sample_fns::fibonacci".into(), - crate_path: None, - config: None, - output_dir: dir.path().to_path_buf(), - provider: ProfileProvider::Local, - backend: ProfileBackend::AndroidNative, - format: ProfileFormat::Both, - }; - let ios_args = ProfileRunArgs { - target: MobileTarget::Ios, - function: "sample_fns::checksum".into(), - crate_path: None, - config: None, - output_dir: dir.path().to_path_buf(), - provider: ProfileProvider::Local, - backend: ProfileBackend::IosInstruments, - format: ProfileFormat::Both, - }; + let mut android_args = sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + ); + android_args.output_dir = dir.path().to_path_buf(); + let mut ios_args = sample_run_args( + MobileTarget::Ios, + ProfileProvider::Local, + ProfileBackend::IosInstruments, + ProfileFormat::Both, + ); + ios_args.function = "sample_fns::checksum".into(); + ios_args.output_dir = dir.path().to_path_buf(); - cmd_profile_run(&android_args).expect("write first planned profile session"); - cmd_profile_run(&ios_args).expect("write second planned profile session"); + cmd_profile_run(&android_args, false).expect("write first planned profile session"); + cmd_profile_run(&ios_args, false).expect("write second planned profile session"); let android_run_dir = dir.path().join("android-sample_fns--fibonacci"); let ios_run_dir = dir.path().join("ios-sample_fns--checksum"); @@ -641,16 +846,12 @@ mod tests { #[test] fn profile_manifest_serializes_provider() { let manifest = build_capture_plan( - &ProfileRunArgs { - target: MobileTarget::Android, - function: "sample_fns::fibonacci".into(), - crate_path: None, - config: None, - output_dir: PathBuf::from("target/mobench/profile"), - provider: ProfileProvider::Browserstack, - backend: ProfileBackend::RustTracing, - format: ProfileFormat::Both, - }, + &sample_run_args( + MobileTarget::Android, + ProfileProvider::Browserstack, + ProfileBackend::RustTracing, + ProfileFormat::Both, + ), &PathBuf::from("target/mobench/profile"), ) .expect("build manifest"); @@ -659,6 +860,83 @@ mod tests { assert_eq!(json["provider"], "browserstack"); } + #[test] + fn resolve_profile_target_accepts_direct_ios_browserstack_device_request() { + let mut args = sample_run_args( + MobileTarget::Ios, + ProfileProvider::Browserstack, + ProfileBackend::IosInstruments, + ProfileFormat::Both, + ); + args.device = Some("iPhone 14".into()); + args.os_version = Some("16".into()); + + let target = resolve_profile_target(&args).expect("resolve direct device"); + let device = target.device.expect("device"); + + assert_eq!(device.identifier, "iPhone 14-16"); + assert_eq!(device.name, "iPhone 14"); + assert_eq!(device.os_version, "16"); + assert_eq!(device.source, "direct"); + } + + #[test] + fn profile_dry_run_always_stays_planned() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut args = sample_run_args( + MobileTarget::Ios, + ProfileProvider::Browserstack, + ProfileBackend::IosInstruments, + ProfileFormat::Both, + ); + args.output_dir = dir.path().to_path_buf(); + args.device = Some("iPhone 14".into()); + args.os_version = Some("16".into()); + + cmd_profile_run(&args, true).expect("dry-run should stop after planning"); + + let manifest = load_profile_manifest( + &dir.path() + .join("ios-sample_fns--fibonacci") + .join("profile.json"), + ) + .expect("load planned manifest"); + assert_eq!(manifest.capture_status, CaptureStatus::Planned); + assert!( + manifest + .warnings + .iter() + .any(|warning| warning.contains("dry-run enabled")), + "expected dry-run warning in manifest: {:?}", + manifest.warnings + ); + } + + #[test] + fn unsupported_browserstack_capture_fails_before_writing_fake_artifacts() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut args = sample_run_args( + MobileTarget::Ios, + ProfileProvider::Browserstack, + ProfileBackend::IosInstruments, + ProfileFormat::Both, + ); + args.output_dir = dir.path().to_path_buf(); + args.device = Some("iPhone 14".into()); + args.os_version = Some("16".into()); + + let error = cmd_profile_run(&args, false).unwrap_err(); + + assert!(error.to_string().contains("BrowserStack native profiling")); + assert!( + !dir.path() + .join("ios-sample_fns--fibonacci") + .join("profile.json") + .exists(), + "unsupported execution should not write fake captured artifacts" + ); + } + fn sample_manifest() -> ProfileManifest { ProfileManifest { run_id: "run-123".into(), diff --git a/crates/mobench/tests/profile_cli.rs b/crates/mobench/tests/profile_cli.rs new file mode 100644 index 0000000..87ff833 --- /dev/null +++ b/crates/mobench/tests/profile_cli.rs @@ -0,0 +1,124 @@ +use std::ffi::OsStr; +use std::process::{Command, Output}; + +fn run_mobench(args: I) -> Output +where + I: IntoIterator, + S: AsRef, +{ + Command::new(env!("CARGO_BIN_EXE_mobench")) + .args(args) + .output() + .expect("run mobench binary") +} + +#[test] +fn browserstack_profile_run_reports_unsupported_native_capture() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let output = run_mobench([ + "profile", + "run", + "--target", + "ios", + "--backend", + "ios-instruments", + "--provider", + "browserstack", + "--crate-path", + "crates/zk-mobile-bench", + "--function", + "zk_mobile_bench::bench_query_proof_generation", + "--output-dir", + temp_dir.path().to_str().expect("utf-8 path"), + ]); + + assert!(!output.status.success(), "expected unsupported run to fail"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("BrowserStack"), + "expected BrowserStack failure context, got:\n{stderr}" + ); + assert!( + stderr.contains("unsupported") || stderr.contains("not implemented"), + "expected unsupported capability explanation, got:\n{stderr}" + ); +} + +#[test] +fn browserstack_native_profile_error_is_actionable() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let output = run_mobench([ + "profile", + "run", + "--target", + "ios", + "--backend", + "ios-instruments", + "--provider", + "browserstack", + "--crate-path", + "crates/zk-mobile-bench", + "--function", + "zk_mobile_bench::bench_query_proof_generation", + "--output-dir", + temp_dir.path().to_str().expect("utf-8 path"), + ]); + + assert!(!output.status.success(), "expected unsupported run to fail"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("BrowserStack native profiling is not implemented"), + "expected explicit unsupported wording, got:\n{stderr}" + ); + assert!( + stderr.contains("local-first profile contract") + || stderr.contains("planned artifact contract only"), + "expected explanation that the command only records planned artifacts today, got:\n{stderr}" + ); + assert!( + stderr.contains("Use --provider local"), + "expected an actionable local fallback, got:\n{stderr}" + ); + assert!( + stderr.contains("Instruments") + || stderr.contains("time-profiler.trace") + || stderr.contains("time-profiler.xml") + || stderr.contains("flamegraph"), + "expected iOS artifact clarification, got:\n{stderr}" + ); +} + +#[test] +fn profile_run_help_mentions_planned_only_or_execution_scope() { + let output = run_mobench(["profile", "run", "--help"]); + assert!(output.status.success(), "expected help to succeed"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("plan") + || stdout.contains("Plan") + || stdout.contains("depending on backend/provider support"), + "expected help to explain whether capture is planned or executed, got:\n{stdout}" + ); + assert!( + stdout.contains("BrowserStack") || stdout.contains("browserstack"), + "expected help to mention BrowserStack capability scope, got:\n{stdout}" + ); +} + +#[test] +fn profile_run_cli_surface_exposes_or_explicitly_omits_device_selection() { + let output = run_mobench(["profile", "run", "--help"]); + assert!(output.status.success(), "expected help to succeed"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("--device") + || stdout.contains("--profile") + || stdout.contains("--device-matrix") + || stdout.contains("device selection is unavailable"), + "expected help to expose device selection or explicitly document its absence, got:\n{stdout}" + ); +} From d693dd2aab74538f0f508c7aa02e918a90ed1304 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 15:26:07 -0700 Subject: [PATCH 117/196] fix: exercise mobench fixture CI end to end --- .github/workflows/mobile-bench.yml | 116 ++++------------ .github/workflows/reusable-bench.yml | 36 ++++- crates/mobench/src/profile.rs | 192 +++++++++++++++++++++++++-- 3 files changed, 236 insertions(+), 108 deletions(-) diff --git a/.github/workflows/mobile-bench.yml b/.github/workflows/mobile-bench.yml index 6aaa1a2..59639d4 100644 --- a/.github/workflows/mobile-bench.yml +++ b/.github/workflows/mobile-bench.yml @@ -12,6 +12,11 @@ on: - android - ios - both + device_profile: + description: "BrowserStack device profile" + required: false + type: string + default: "low-spec" crate_path: description: "Benchmark crate fixture path" required: false @@ -64,96 +69,21 @@ env: jobs: benchmark: name: Example fixture benchmark - runs-on: ubuntu-latest - - steps: - - name: Checkout caller repo - uses: actions/checkout@v5 - with: - ref: ${{ inputs.head_sha || github.sha }} - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build local mobench binaries - run: cargo build -p mobench --bins --locked - - - name: Add local mobench binaries to PATH - run: echo "${GITHUB_WORKSPACE}/target/debug" >> "$GITHUB_PATH" - - - name: Resolve fixture metadata - id: fixture - env: - CRATE_PATH: ${{ inputs.crate_path }} - shell: bash - run: | - set -euo pipefail - slug=$(basename "${CRATE_PATH}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//') - if [ -z "${slug}" ]; then - slug="example-fixture" - fi - echo "slug=${slug}" >> "$GITHUB_OUTPUT" - echo "comment_marker=" >> "$GITHUB_OUTPUT" - - - name: Build mobench run args - id: args - env: - PLATFORM: ${{ inputs.platform }} - CRATE_PATH: ${{ inputs.crate_path }} - FUNCTIONS: ${{ inputs.functions }} - ITERATIONS: ${{ inputs.iterations }} - WARMUP: ${{ inputs.warmup }} - PR_NUMBER: ${{ inputs.pr_number }} - REQUESTED_BY_INPUT: ${{ inputs.requested_by }} - MOBENCH_REF_INPUT: ${{ inputs.head_sha || github.sha }} - GITHUB_ACTOR: ${{ github.actor }} - GITHUB_EVENT_NAME: ${{ github.event_name }} - shell: bash - run: | - set -euo pipefail - - requested_by="${REQUESTED_BY_INPUT:-}" - if [ -z "${requested_by}" ]; then - requested_by="${GITHUB_EVENT_NAME}:${GITHUB_ACTOR}" - fi - - { - echo "run_args<> "$GITHUB_OUTPUT" - - - name: Run mobench fixture CI - uses: ./.github/actions/mobench - with: - command: mobench ci run - run-args: ${{ steps.args.outputs.run_args }} - install-mobench: "false" - install-cargo-ndk: "false" - setup-android: "false" - cache-gradle: "false" - cache-android: "false" - artifact-name: mobench-${{ steps.fixture.outputs.slug }}-results - pr-comment: ${{ inputs.pr_number != '' && 'true' || 'false' }} - pr-number: ${{ inputs.pr_number }} - pr-comment-marker: ${{ steps.fixture.outputs.comment_marker }} - github-token: ${{ github.token }} - - - name: Publish job summary - if: always() - shell: bash - run: | - if [ -f target/mobench/ci/summary.md ]; then - cat target/mobench/ci/summary.md >> "$GITHUB_STEP_SUMMARY" - fi + uses: ./.github/workflows/reusable-bench.yml + with: + crate_path: ${{ inputs.crate_path }} + functions: ${{ inputs.functions }} + platform: ${{ inputs.platform }} + device_profile: ${{ inputs.device_profile }} + iterations: ${{ inputs.iterations }} + warmup: ${{ inputs.warmup }} + build_release: true + pr_number: ${{ inputs.pr_number }} + requested_by: ${{ inputs.requested_by }} + head_sha: ${{ inputs.head_sha }} + check_run_name: "Mobench Fixtures" + secrets: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + MOBENCH_APP_PRIVATE_KEY: ${{ secrets.MOBENCH_APP_PRIVATE_KEY }} + MOBENCH_APP_ID: ${{ secrets.MOBENCH_APP_ID }} diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index e85220f..76242f6 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -175,7 +175,15 @@ jobs: MOBENCH_VERSION: ${{ inputs.mobench_version }} MOBENCH_REF: ${{ inputs.mobench_ref }} run: | - if [ -n "$MOBENCH_REF" ]; then + set -euo pipefail + if [ -f caller/crates/mobench/Cargo.toml ] && grep -Eq '^name\s*=\s*"mobench"' caller/crates/mobench/Cargo.toml; then + cargo install --path caller/crates/mobench --locked --force + elif [ -n "$MOBENCH_REF" ] && printf '%s' "$MOBENCH_REF" | grep -Eq '^[0-9a-fA-F]{40}$'; then + cargo install mobench \ + --git https://github.com/worldcoin/mobile-bench-rs \ + --rev "$MOBENCH_REF" \ + --locked + elif [ -n "$MOBENCH_REF" ]; then cargo install mobench \ --git https://github.com/worldcoin/mobile-bench-rs \ --branch "$MOBENCH_REF" \ @@ -383,7 +391,15 @@ jobs: MOBENCH_VERSION: ${{ inputs.mobench_version }} MOBENCH_REF: ${{ inputs.mobench_ref }} run: | - if [ -n "$MOBENCH_REF" ]; then + set -euo pipefail + if [ -f caller/crates/mobench/Cargo.toml ] && grep -Eq '^name\s*=\s*"mobench"' caller/crates/mobench/Cargo.toml; then + cargo install --path caller/crates/mobench --locked --force + elif [ -n "$MOBENCH_REF" ] && printf '%s' "$MOBENCH_REF" | grep -Eq '^[0-9a-fA-F]{40}$'; then + cargo install mobench \ + --git https://github.com/worldcoin/mobile-bench-rs \ + --rev "$MOBENCH_REF" \ + --locked + elif [ -n "$MOBENCH_REF" ]; then cargo install mobench \ --git https://github.com/worldcoin/mobile-bench-rs \ --branch "$MOBENCH_REF" \ @@ -480,6 +496,12 @@ jobs: runs-on: ubuntu-latest steps: + - name: Checkout caller repo + uses: actions/checkout@v4 + with: + path: caller + ref: ${{ inputs.head_sha || github.sha }} + - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -489,7 +511,15 @@ jobs: MOBENCH_VERSION: ${{ inputs.mobench_version }} MOBENCH_REF: ${{ inputs.mobench_ref }} run: | - if [ -n "$MOBENCH_REF" ]; then + set -euo pipefail + if [ -f caller/crates/mobench/Cargo.toml ] && grep -Eq '^name\s*=\s*"mobench"' caller/crates/mobench/Cargo.toml; then + cargo install --path caller/crates/mobench --locked --force + elif [ -n "$MOBENCH_REF" ] && printf '%s' "$MOBENCH_REF" | grep -Eq '^[0-9a-fA-F]{40}$'; then + cargo install mobench \ + --git https://github.com/worldcoin/mobile-bench-rs \ + --rev "$MOBENCH_REF" \ + --locked + elif [ -n "$MOBENCH_REF" ]; then cargo install mobench \ --git https://github.com/worldcoin/mobile-bench-rs \ --branch "$MOBENCH_REF" \ diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index b7bf2ad..3cce655 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -1,8 +1,9 @@ -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; use std::fmt::Write; use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::MobileTarget; @@ -177,12 +178,13 @@ pub fn write_profile_manifest(path: &Path, manifest: &ProfileManifest) -> Result } pub fn cmd_profile_run(args: &ProfileRunArgs) -> Result<()> { + validate_profile_request(args)?; std::fs::create_dir_all(&args.output_dir)?; - let run_id = build_run_id(args.target, &args.function); + let run_id = allocate_run_id(&args.output_dir, args.target, &args.function)?; let run_output_dir = args.output_dir.join(&run_id); std::fs::create_dir_all(&run_output_dir)?; - let manifest = build_capture_plan(args, &run_output_dir)?; + let manifest = build_capture_plan(args, &run_output_dir, &run_id)?; let rendered_summary = render_profile_markdown(&manifest); let run_profile_path = run_output_dir.join("profile.json"); @@ -243,7 +245,44 @@ pub fn cmd_profile_summarize_for_test(args: &ProfileSummarizeArgs) -> Result Result { +fn validate_profile_request(args: &ProfileRunArgs) -> Result<()> { + let layout = crate::resolve_project_layout(crate::ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: args.crate_path.as_deref(), + config_path: args.config.as_deref(), + }) + .context("failed to resolve benchmark layout for profile run")?; + let benchmarks = crate::discover_benchmarks_for_layout(&layout) + .context("failed to discover benchmarks for profile run")?; + + if benchmarks.is_empty() { + bail!( + "no benchmark functions found in {}; add #[benchmark] functions before profiling", + layout.crate_dir.display() + ); + } + + if !benchmarks + .iter() + .any(|candidate| candidate == &args.function) + { + bail!( + "benchmark `{}` was not found in {}. Available benchmarks: {}", + args.function, + layout.crate_dir.display(), + benchmarks.join(", ") + ); + } + + Ok(()) +} + +fn build_capture_plan( + args: &ProfileRunArgs, + output_root: &Path, + run_id: &str, +) -> Result { let backend = resolve_backend(args.target, args.backend); validate_profile_capabilities(args.provider, backend)?; validate_format_capabilities(backend, args.format)?; @@ -290,7 +329,7 @@ fn build_capture_plan(args: &ProfileRunArgs, output_root: &Path) -> Result Result Result { + let prefix = build_run_id(target, function); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + + for suffix in 0..1000 { + let candidate = if suffix == 0 { + format!("{prefix}-{timestamp}") + } else { + format!("{prefix}-{timestamp}-{suffix}") + }; + + if !output_dir.join(&candidate).exists() { + return Ok(candidate); + } + } + + bail!( + "failed to allocate a unique profile run id for `{}` after 1000 attempts", + function + ) +} + fn build_run_id(target: MobileTarget, function: &str) -> String { format!("{}-{}", target.as_str(), slugify_function_name(function)) } @@ -484,6 +548,7 @@ mod tests { format: ProfileFormat::Both, }, &PathBuf::from("target/mobench/profile"), + "android-sample_fns--fibonacci", ) .expect("android capture plan"); @@ -513,6 +578,7 @@ mod tests { format: ProfileFormat::Native, }, &PathBuf::from("target/mobench/profile"), + "android-sample_fns--fibonacci", ) .expect("native-only capture plan"); @@ -538,6 +604,7 @@ mod tests { format: ProfileFormat::Both, }, &PathBuf::from("target/mobench/profile"), + "ios-sample_fns--fibonacci", ) .expect("ios capture plan"); @@ -567,6 +634,7 @@ mod tests { format: ProfileFormat::Both, }, &PathBuf::from("target/mobench/profile"), + "android-sample_fns--fibonacci", ) .unwrap_err(); @@ -588,6 +656,7 @@ mod tests { format: ProfileFormat::Processed, }, &PathBuf::from("target/mobench/profile"), + "android-sample_fns--fibonacci", ) .unwrap_err(); @@ -598,10 +667,11 @@ mod tests { #[test] fn profile_run_writes_run_scoped_and_latest_manifest_files() { let dir = tempfile::tempdir().expect("temp dir"); + let crate_path = workspace_root().join("examples/ffi-benchmark"); let android_args = ProfileRunArgs { target: MobileTarget::Android, - function: "sample_fns::fibonacci".into(), - crate_path: None, + function: "ffi_benchmark::bench_fibonacci".into(), + crate_path: Some(crate_path.clone()), config: None, output_dir: dir.path().to_path_buf(), provider: ProfileProvider::Local, @@ -610,8 +680,8 @@ mod tests { }; let ios_args = ProfileRunArgs { target: MobileTarget::Ios, - function: "sample_fns::checksum".into(), - crate_path: None, + function: "ffi_benchmark::bench_checksum".into(), + crate_path: Some(crate_path), config: None, output_dir: dir.path().to_path_buf(), provider: ProfileProvider::Local, @@ -622,8 +692,14 @@ mod tests { cmd_profile_run(&android_args).expect("write first planned profile session"); cmd_profile_run(&ios_args).expect("write second planned profile session"); - let android_run_dir = dir.path().join("android-sample_fns--fibonacci"); - let ios_run_dir = dir.path().join("ios-sample_fns--checksum"); + let android_run_dir = find_single_run_dir( + &dir.path().to_path_buf(), + "android-ffi_benchmark--bench_fibonacci", + ); + let ios_run_dir = find_single_run_dir( + &dir.path().to_path_buf(), + "ios-ffi_benchmark--bench_checksum", + ); assert!(android_run_dir.join("profile.json").exists()); assert!(android_run_dir.join("summary.md").exists()); @@ -635,7 +711,74 @@ mod tests { let latest_manifest = load_profile_manifest(&dir.path().join("profile.json")).expect("load latest manifest"); assert_eq!(latest_manifest.target, MobileTarget::Ios); - assert_eq!(latest_manifest.function, "sample_fns::checksum"); + assert_eq!(latest_manifest.function, "ffi_benchmark::bench_checksum"); + assert_eq!( + latest_manifest.run_id, + ios_run_dir + .file_name() + .expect("ios run dir name") + .to_string_lossy() + ); + } + + #[test] + fn profile_run_rejects_unknown_benchmark_function() { + let dir = tempfile::tempdir().expect("temp dir"); + let error = cmd_profile_run(&ProfileRunArgs { + target: MobileTarget::Android, + function: "does_not_exist::bench".into(), + crate_path: Some(workspace_root().join("examples/ffi-benchmark")), + config: None, + output_dir: dir.path().join("profile"), + provider: ProfileProvider::Local, + backend: ProfileBackend::AndroidNative, + format: ProfileFormat::Both, + }) + .expect_err("invalid benchmark selector should fail"); + + assert!(error.to_string().contains("does_not_exist::bench")); + assert!(error.to_string().contains("Available benchmarks")); + assert!(!dir.path().join("profile").exists()); + } + + #[test] + fn profile_run_preserves_history_for_repeated_runs() { + let dir = tempfile::tempdir().expect("temp dir"); + let args = ProfileRunArgs { + target: MobileTarget::Android, + function: "ffi_benchmark::bench_fibonacci".into(), + crate_path: Some(workspace_root().join("examples/ffi-benchmark")), + config: None, + output_dir: dir.path().to_path_buf(), + provider: ProfileProvider::Local, + backend: ProfileBackend::AndroidNative, + format: ProfileFormat::Both, + }; + + cmd_profile_run(&args).expect("write first profile session"); + cmd_profile_run(&args).expect("write second profile session"); + + let run_dirs = collect_run_dirs( + &dir.path().to_path_buf(), + "android-ffi_benchmark--bench_fibonacci", + ); + assert_eq!(run_dirs.len(), 2); + assert_ne!(run_dirs[0], run_dirs[1]); + for run_dir in &run_dirs { + assert!(run_dir.join("profile.json").exists()); + assert!(run_dir.join("summary.md").exists()); + } + + let latest_manifest = + load_profile_manifest(&dir.path().join("profile.json")).expect("load latest manifest"); + let latest_run_dir = run_dirs + .iter() + .find(|dir| { + dir.file_name() + .is_some_and(|name| name == latest_manifest.run_id.as_str()) + }) + .expect("latest manifest should point at a run-scoped directory"); + assert!(latest_run_dir.join("profile.json").exists()); } #[test] @@ -652,6 +795,7 @@ mod tests { format: ProfileFormat::Both, }, &PathBuf::from("target/mobench/profile"), + "android-sample_fns--fibonacci", ) .expect("build manifest"); @@ -659,6 +803,30 @@ mod tests { assert_eq!(json["provider"], "browserstack"); } + fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + fn collect_run_dirs(root: &PathBuf, prefix: &str) -> Vec { + let mut entries: Vec = std::fs::read_dir(root) + .expect("read run dir") + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.is_dir()) + .filter(|path| { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with(prefix)) + }) + .collect(); + entries.sort(); + entries + } + + fn find_single_run_dir(root: &PathBuf, prefix: &str) -> PathBuf { + let matches = collect_run_dirs(root, prefix); + assert_eq!(matches.len(), 1, "expected one run dir for prefix {prefix}"); + matches.into_iter().next().expect("single run dir") + } + fn sample_manifest() -> ProfileManifest { ProfileManifest { run_id: "run-123".into(), From 437ce0e295f79d6e87a3435f6e5d91826cc99086 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 17:02:02 -0700 Subject: [PATCH 118/196] fix: install uniffi bindgen in benchmark CI --- .github/workflows/reusable-bench.yml | 38 ++++++++++++++++++++++ crates/mobench-sdk/src/builders/android.rs | 2 +- crates/mobench-sdk/src/builders/ios.rs | 2 +- crates/mobench-sdk/src/lib.rs | 2 +- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 76242f6..8641c52 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -194,6 +194,25 @@ jobs: cargo install mobench --locked fi + - name: Install uniffi-bindgen + shell: bash + run: | + set -euo pipefail + uniffi_version=$(awk ' + $0 == "[[package]]" { in_pkg=0 } + $0 == "name = \"uniffi_bindgen\"" { in_pkg=1; next } + in_pkg && /^version = / { + gsub(/^version = "|\"$/, "", $0) + print $0 + exit + } + ' caller/Cargo.lock) + if [ -n "${uniffi_version}" ]; then + cargo install uniffi_bindgen --version "${uniffi_version}" --locked + else + cargo install uniffi_bindgen --locked + fi + - name: Install XcodeGen run: brew install xcodegen @@ -410,6 +429,25 @@ jobs: cargo install mobench --locked fi + - name: Install uniffi-bindgen + shell: bash + run: | + set -euo pipefail + uniffi_version=$(awk ' + $0 == "[[package]]" { in_pkg=0 } + $0 == "name = \"uniffi_bindgen\"" { in_pkg=1; next } + in_pkg && /^version = / { + gsub(/^version = "|\"$/, "", $0) + print $0 + exit + } + ' caller/Cargo.lock) + if [ -n "${uniffi_version}" ]; then + cargo install uniffi_bindgen --version "${uniffi_version}" --locked + else + cargo install uniffi_bindgen --locked + fi + - name: Resolve Android device id: resolve_device working-directory: caller diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index eea03bb..aa0b568 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -653,7 +653,7 @@ impl AndroidBuilder { name = \"uniffi-bindgen\"\n\ path = \"src/bin/uniffi-bindgen.rs\"\n\n\ 2. Or install uniffi-bindgen globally:\n\ - cargo install uniffi-bindgen\n\n\ + cargo install uniffi_bindgen\n\n\ 3. Or pre-generate bindings and commit them." .to_string(), )); diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 7b56d95..fce3252 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -758,7 +758,7 @@ impl IosBuilder { name = \"uniffi-bindgen\"\n\ path = \"src/bin/uniffi-bindgen.rs\"\n\n\ 2. Or install uniffi-bindgen globally:\n\ - cargo install uniffi-bindgen\n\n\ + cargo install uniffi_bindgen\n\n\ 3. Or pre-generate bindings and commit them." .to_string(), )); diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index c2f8ea9..307fac9 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -274,7 +274,7 @@ //! ### iOS //! //! - Xcode with command line tools -//! - `uniffi-bindgen` (`cargo install uniffi-bindgen`) +//! - `uniffi-bindgen` (`cargo install uniffi_bindgen`) //! - `xcodegen` (optional, `brew install xcodegen`) //! - Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios` //! From fdaa86792a0859a1acff8cd816a3dcebc880de8d Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 17:09:45 -0700 Subject: [PATCH 119/196] fix: install the actual uniffi bindgen cli --- .github/workflows/reusable-bench.yml | 16 ++++++++++++---- crates/mobench-sdk/src/builders/android.rs | 4 ++-- crates/mobench-sdk/src/builders/ios.rs | 4 ++-- crates/mobench-sdk/src/lib.rs | 2 +- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/reusable-bench.yml b/.github/workflows/reusable-bench.yml index 8641c52..c996835 100644 --- a/.github/workflows/reusable-bench.yml +++ b/.github/workflows/reusable-bench.yml @@ -208,9 +208,13 @@ jobs: } ' caller/Cargo.lock) if [ -n "${uniffi_version}" ]; then - cargo install uniffi_bindgen --version "${uniffi_version}" --locked + cargo install \ + --git https://github.com/mozilla/uniffi-rs \ + --tag "v${uniffi_version}" \ + uniffi-bindgen-cli \ + --bin uniffi-bindgen else - cargo install uniffi_bindgen --locked + echo "No uniffi_bindgen entry in caller/Cargo.lock; skipping global uniffi-bindgen install" fi - name: Install XcodeGen @@ -443,9 +447,13 @@ jobs: } ' caller/Cargo.lock) if [ -n "${uniffi_version}" ]; then - cargo install uniffi_bindgen --version "${uniffi_version}" --locked + cargo install \ + --git https://github.com/mozilla/uniffi-rs \ + --tag "v${uniffi_version}" \ + uniffi-bindgen-cli \ + --bin uniffi-bindgen else - cargo install uniffi_bindgen --locked + echo "No uniffi_bindgen entry in caller/Cargo.lock; skipping global uniffi-bindgen install" fi - name: Resolve Android device diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index aa0b568..9f8ccce 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -652,8 +652,8 @@ impl AndroidBuilder { [[bin]]\n\ name = \"uniffi-bindgen\"\n\ path = \"src/bin/uniffi-bindgen.rs\"\n\n\ - 2. Or install uniffi-bindgen globally:\n\ - cargo install uniffi_bindgen\n\n\ + 2. Or install a matching uniffi-bindgen CLI globally:\n\ + cargo install --git https://github.com/mozilla/uniffi-rs --tag uniffi-bindgen-cli --bin uniffi-bindgen\n\n\ 3. Or pre-generate bindings and commit them." .to_string(), )); diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index fce3252..b364c81 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -757,8 +757,8 @@ impl IosBuilder { [[bin]]\n\ name = \"uniffi-bindgen\"\n\ path = \"src/bin/uniffi-bindgen.rs\"\n\n\ - 2. Or install uniffi-bindgen globally:\n\ - cargo install uniffi_bindgen\n\n\ + 2. Or install a matching uniffi-bindgen CLI globally:\n\ + cargo install --git https://github.com/mozilla/uniffi-rs --tag uniffi-bindgen-cli --bin uniffi-bindgen\n\n\ 3. Or pre-generate bindings and commit them." .to_string(), )); diff --git a/crates/mobench-sdk/src/lib.rs b/crates/mobench-sdk/src/lib.rs index 307fac9..6c4c615 100644 --- a/crates/mobench-sdk/src/lib.rs +++ b/crates/mobench-sdk/src/lib.rs @@ -274,7 +274,7 @@ //! ### iOS //! //! - Xcode with command line tools -//! - `uniffi-bindgen` (`cargo install uniffi_bindgen`) +//! - `uniffi-bindgen` (`cargo install --git https://github.com/mozilla/uniffi-rs --tag uniffi-bindgen-cli --bin uniffi-bindgen`) //! - `xcodegen` (optional, `brew install xcodegen`) //! - Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios` //! From b5af91b775edd2f9daaf2d99ce8b108e2290f2e9 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 17:23:09 -0700 Subject: [PATCH 120/196] docs: add profiling upgrades design --- .../2026-03-26-profiling-upgrades-design.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/plans/2026-03-26-profiling-upgrades-design.md diff --git a/docs/plans/2026-03-26-profiling-upgrades-design.md b/docs/plans/2026-03-26-profiling-upgrades-design.md new file mode 100644 index 0000000..230d294 --- /dev/null +++ b/docs/plans/2026-03-26-profiling-upgrades-design.md @@ -0,0 +1,294 @@ +# Mobench Profiling Upgrades Design + +## Summary + +Upgrade Mobench profiling from "real local capture with basic artifacts" to a +two-layer profiling system that answers both of the questions users actually +have when investigating mobile benchmark performance: + +- Which native and Rust functions were hot? +- Which benchmark phase was expensive? + +The design keeps native profilers as the source of truth for stack-level CPU +behavior while adding an explicit semantic profiling layer for benchmark phases +such as `load`, `prove`, `serialize`, and `verify`. + +## Goals + +- Improve the quality and interpretability of native profiling artifacts on + Android and iOS. +- Make Android flamegraphs show demangled Rust frames below the UniFFI/JNA + bridge. +- Preserve the current iOS ability to show Rust frames below the FFI boundary. +- Add an opt-in semantic profiling layer in `mobench-sdk` for benchmark phases. +- Keep the normalized `profile.json` contract, but separate native capture from + semantic instrumentation clearly. +- Keep BrowserStack native profiling explicitly unsupported unless real + artifact retrieval exists. + +## Non-Goals + +- Replacing native profilers with benchmark instrumentation. +- Promising exact per-iteration call traces from sampled profiling. +- Migrating the Android bridge from JNA/UniFFI to Java FFM in this upgrade. +- Treating BrowserStack timing or memory metrics as native stack profiling. + +## Current State + +The branch already supports real local profiling: + +- Android local profiling uses `simpleperf`, writes `sample.perf`, + `stacks.folded`, and `flamegraph.html`. +- iOS local profiling samples the simulator-host process and writes + `sample.txt`, `stacks.folded`, and `flamegraph.html`. +- BrowserStack native profiling is explicitly unsupported. + +Observed limitations: + +- iOS preserves the FFI and Rust call chain well in current outputs. +- Android captures native samples successfully, but the current folded/flamegraph + outputs do not fully symbolize internal Rust frames and still show unresolved + native offsets in practice. +- Current native profiling is good at identifying hot call stacks, but it does + not answer semantic questions such as "how much time was spent proving vs + serializing?" + +## Recommended Approach + +Use a two-layer profiling model: + +1. Native capture layer + - Android: improve `simpleperf` symbol fidelity and capture hygiene. + - iOS: keep the current working local flow, but hide it behind a capture + interface that can later support richer native tools such as `xctrace`. +2. Semantic profiling layer + - Add an opt-in phase API in `mobench-sdk`. + - Record flat benchmark phases such as `prove`, `serialize`, and `verify`. + - Merge these phase timings into `profile.json` and `summary.md` without + pretending they came from native profilers. + +This approach preserves native profiling accuracy while making benchmark-level +performance reports actionable. + +## Product Shape + +The upgraded profiling experience should answer two questions explicitly: + +### Stack-level + +Which Rust or native functions were hot during the benchmark? + +This is answered by: + +- raw native capture artifacts +- symbolized folded stacks +- rendered flamegraphs +- optional native plain-text reports + +### Phase-level + +How much time was spent in major benchmark stages such as proving, +serialization, or verification? + +This is answered by: + +- benchmark-side semantic phase instrumentation +- merged phase timing output in `profile.json` +- rendered phase summaries in `summary.md` + +## Architecture + +### 1. Capture Executor + +Platform-specific native profilers should remain responsible for producing raw +capture data. + +- Android executor: run `simpleperf` and persist all intermediate artifacts + needed for later symbolization. +- iOS executor: keep the current working local capture path, but return a raw + capture bundle through a stable interface instead of assuming the raw text + file is the final product. + +### 2. Symbolizer + +A platform-specific post-processing layer should resolve native frames into +stable, readable function names. + +- Android: + - consume unresolved native offsets from `simpleperf` output + - resolve them with `llvm-addr2line -Cfpe` against unstripped Rust shared + libraries + - rewrite folded stacks before flamegraph generation +- iOS: + - continue using already-symbolized frames from the working local path + - keep the symbolizer interface generic enough to support `xctrace` later + +### 3. Semantic Profiler + +An opt-in semantic profiling API should be added to `mobench-sdk`. + +Start with flat phases only: + +- `profile_phase("load", || ...)` +- `profile_phase("prove", || ...)` +- `profile_phase("serialize", || ...)` +- `profile_phase("verify", || ...)` + +This layer records benchmark meaning, not stack structure. + +### 4. Renderers + +Mobench should render: + +- native folded stacks into `flamegraph.html` +- a native plain-text call tree report for inspection +- semantic phase summaries into Markdown and JSON outputs + +## Output Contract + +The manifest should distinguish native capture from semantic instrumentation. + +Recommended top-level sections: + +- `native_capture` + - `status` + - `raw_artifacts` + - `processed_artifacts` + - `symbolization` +- `semantic_profile` + - `status` + - `phases` + - optional `spans_path` +- `capture_metadata` + - target device/runtime + - sample duration / sample frequency + - warmup mode + - capture method details + +The on-disk run directory can remain compatible with the current layout: + +```text +target/mobench/profile// + profile.json + summary.md + artifacts/ + raw/ + processed/ + semantic/ +``` + +Suggested additions: + +- `artifacts/processed/native-report.txt` +- `artifacts/semantic/phases.json` + +## Native Profiling Improvements + +### Android + +Android needs better symbol fidelity and less setup noise. + +Required improvements: + +- warm the app and bridge before recording so JNA/UniFFI startup does not + dominate the flamegraph +- preserve and use unstripped Rust `.so` files for symbolization +- symbolize `lib*.so[+offset]` frames with `llvm-addr2line -Cfpe` +- record unresolved-frame counts and expose them in the manifest and summary +- prefer release builds with debuginfo for profiling runs + +Desired outputs: + +- symbolized `stacks.folded` +- symbolized `flamegraph.html` +- `native-report.txt` for text inspection + +### iOS + +iOS should keep the current working capture path while improving capture +quality and metadata. + +Required improvements: + +- warm the benchmark before recording to reduce launch/setup noise +- record the exact capture method in metadata +- preserve current symbol visibility in folded stacks and flamegraphs + +Future-compatible improvements: + +- abstract the working capture path behind an executor interface +- allow a richer `xctrace` backend later without changing the manifest model + +## Semantic Profiling Layer + +The semantic profiling layer exists to answer benchmark-specific questions that +sampled native stacks cannot answer reliably. + +The initial semantic profiling API should be: + +- opt-in +- flat rather than nested +- cheap enough to use in hot benchmarks without distorting results + +Output example: + +- `prove = 92%` +- `serialize = 5%` +- `verify = 3%` + +The summary must label this clearly as benchmark instrumentation rather than +native profiling. + +## Android Interop Direction + +Do not include an Android FFM migration in this upgrade. + +Rationale: + +- JNA still relies on JNI internally, but avoids handwritten JNI glue. +- The Java Foreign Function and Memory API is a standard JDK feature in Java SE + 22, but it is not an Android-supported surface that this project can rely on + today. +- The immediate profiling problems are symbolization and startup noise, not the + existence of the bridge itself. + +Short-term plan: + +- keep JNA/UniFFI +- warm the bridge before native capture +- make the Rust frames below the bridge visible in Android outputs + +## Testing Strategy + +### Unit Tests + +- manifest serialization for native plus semantic sections +- Android symbolization rewrite logic +- iOS/native renderer behavior +- semantic phase merge and summary rendering + +### Smoke Tests + +- local Android smoke test confirming symbolized Rust frames appear in rendered + outputs +- local iOS smoke test confirming the FFI-to-Rust chain is preserved +- semantic phase smoke test confirming phase timings land in `profile.json` and + `summary.md` + +### Documentation Checks + +- README capability matrix remains honest +- docs distinguish sampled native stacks from semantic phase profiling + +## Rollout + +Roll out in this order: + +1. Android native symbolization and warm profiling +2. native plain-text report output +3. semantic profiling API in `mobench-sdk` +4. manifest and summary extensions for semantic phases +5. iOS capture metadata cleanup and warm profiling polish + +This ordering improves the current flamegraphs first, then adds the semantic +layer users need for proving benchmarks. From c4faf3a54c875b0563aaae49c5315ae7a85eb128 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 17:28:02 -0700 Subject: [PATCH 121/196] test: freeze profiling upgrade targets --- crates/mobench-sdk/src/builders/android.rs | 52 +- crates/mobench/src/profile.rs | 1084 +++++++++++++++++++- crates/mobench/tests/profile_cli.rs | 35 +- 3 files changed, 1105 insertions(+), 66 deletions(-) diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index eea03bb..e843f04 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -103,6 +103,19 @@ pub struct AndroidBuilder { } impl AndroidBuilder { + fn android_test_profile_name(&self) -> &'static str { + // The generated template pins instrumentation to the release app via + // `testBuildType "release"`, so the test APK always lands under the + // release androidTest output regardless of the main app profile. + "release" + } + + fn android_test_gradle_task(&self) -> &'static str { + // Android Gradle Plugin exposes instrumentation assembly on the app + // module, not as a root-project shorthand task in this generated app. + "app:assembleReleaseAndroidTest" + } + /// Creates a new Android builder /// /// # Arguments @@ -247,7 +260,8 @@ impl AndroidBuilder { )), test_suite_path: Some(android_dir.join(format!( "app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk", - profile_name, profile_name + self.android_test_profile_name(), + self.android_test_profile_name() ))), }); } @@ -1116,7 +1130,7 @@ impl AndroidBuilder { } /// Builds the Android test APK using Gradle - fn build_test_apk(&self, config: &BuildConfig) -> Result { + fn build_test_apk(&self, _config: &BuildConfig) -> Result { let android_dir = self.output_dir.join("android"); if !android_dir.exists() { @@ -1128,10 +1142,7 @@ impl AndroidBuilder { ))); } - let gradle_task = match config.profile { - BuildProfile::Debug => "assembleDebugAndroidTest", - BuildProfile::Release => "assembleReleaseAndroidTest", - }; + let gradle_task = self.android_test_gradle_task(); let mut cmd = Command::new("./gradlew"); cmd.arg(gradle_task).current_dir(&android_dir); @@ -1177,10 +1188,7 @@ impl AndroidBuilder { ))); } - let profile_name = match config.profile { - BuildProfile::Debug => "debug", - BuildProfile::Release => "release", - }; + let profile_name = self.android_test_profile_name(); let test_apk_dir = android_dir .join("app/build/outputs/apk/androidTest") @@ -1246,6 +1254,11 @@ impl AndroidBuilder { } } +#[cfg(test)] +fn symbolize_android_native_stack_line(line: &str) -> String { + line.to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -1273,6 +1286,25 @@ mod tests { assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); } + #[test] + fn test_android_test_task_matches_generated_app_module() { + let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); + assert_eq!(builder.android_test_gradle_task(), "app:assembleReleaseAndroidTest"); + assert_eq!(builder.android_test_profile_name(), "release"); + } + + #[test] + fn android_native_offsets_are_symbolized_into_rust_frames() { + let input = + "dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1"; + let output = symbolize_android_native_stack_line(input); + + assert!( + output.contains("sample_fns::fibonacci"), + "expected unresolved native offsets to be rewritten into Rust symbols, got: {output}" + ); + } + #[test] fn test_parse_output_metadata_unsigned() { let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index d106f85..40e8da9 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -1,10 +1,20 @@ -use anyhow::{Result, bail}; +use anyhow::{Context, Result, anyhow, bail}; use clap::{Args, ValueEnum}; +use inferno::collapse::Collapse; +use inferno::{collapse::sample as inferno_sample, flamegraph}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::fmt::Write; +use std::fs::{self, File}; +use std::io::{BufReader, BufWriter, Cursor}; use std::path::{Path, PathBuf}; +use std::process::Command; -use crate::{DevicePlatform, MobileTarget, ResolvedMatrixDevice, resolve_devices_for_profile}; +use crate::{ + DevicePlatform, MobileTarget, ProjectLayoutOptions, ResolvedMatrixDevice, RunSpec, + load_dotenv_for_layout, persist_mobile_spec, resolve_devices_for_profile, + resolve_project_layout, run_android_build, run_ios_build, validate_benchmark_function, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -39,20 +49,23 @@ pub enum ProfileSummaryFormat { #[derive(Debug, Clone, Args)] #[command( - about = "Plan or execute a native profiling session depending on backend/provider support", + about = "Execute a native profiling session locally, or write a profile contract when execution is unsupported", after_help = concat!( "Capability matrix:\n", - " local + android-native: planned manifest today; native simpleperf capture is not implemented yet\n", - " local + ios-instruments: planned manifest today; Instruments trace export capture is not implemented yet\n", - " local + rust-tracing: planned manifest today; structured trace output is local-only\n", + " local + android-native: builds the Android bench app, captures simpleperf, writes folded stacks, and renders flamegraph.html\n", + " local + ios-instruments: builds the iOS Simulator bench app, samples the simulator-host process, writes folded stacks, and renders flamegraph.html\n", + " local + rust-tracing: planned manifest today; structured trace output is local-only and still not implemented\n", " browserstack + android-native: unsupported for native capture in this release\n", " browserstack + ios-instruments: unsupported for native capture in this release\n", " browserstack + rust-tracing: unsupported; use local provider for trace-events output\n", "\n", + "Local capture requirements:\n", + " Android native capture requires one connected adb device or booted emulator plus Android SDK/NDK simpleperf tools.\n", + " iOS native capture runs against a local iOS Simulator selected by --device/--os-version when provided.\n", + "\n", "Device selection:\n", - " Use --device/--os-version for one explicit BrowserStack device, or --profile with\n", - " optional --device-matrix/--config to reuse the same deterministic resolution model as\n", - " `mobench devices resolve`.\n" + " Use --device/--os-version for one explicit device request, or --profile with optional\n", + " --device-matrix/--config to reuse the same deterministic resolution model as `mobench devices resolve`.\n" ) )] pub struct ProfileRunArgs { @@ -65,6 +78,10 @@ pub struct ProfileRunArgs { help = "Path to the benchmark crate directory containing Cargo.toml" )] pub crate_path: Option, + #[arg(long, default_value_t = 100)] + pub iterations: u32, + #[arg(long, default_value_t = 10)] + pub warmup: u32, #[arg(long, help = "Optional path to config file")] pub config: Option, #[arg(long, default_value = "target/mobench/profile")] @@ -96,6 +113,14 @@ pub struct ProfileRunArgs { pub backend: ProfileBackend, #[arg(long, value_enum, default_value_t = ProfileFormat::Both)] pub format: ProfileFormat, + #[arg(long, help = "Build mobile artifacts in release mode")] + pub release: bool, + #[arg( + long, + default_value_t = 10, + help = "Capture duration in seconds for native profiling sessions" + )] + pub capture_duration_secs: u64, } #[derive(Debug, Clone, Args)] @@ -328,20 +353,32 @@ fn build_capture_plan(args: &ProfileRunArgs, output_root: &Path) -> Result ( vec![ArtifactRecord { - label: "time-profiler".into(), - path: raw_root.join("time-profiler.trace"), - }], - vec![ArtifactRecord { - label: "xctrace-export".into(), - path: processed_root.join("time-profiler.xml"), + label: "sample".into(), + path: raw_root.join("sample.txt"), }], + vec![ + ArtifactRecord { + label: "collapsed-stacks".into(), + path: processed_root.join("stacks.folded"), + }, + ArtifactRecord { + label: "flamegraph".into(), + path: processed_root.join("flamegraph.html"), + }, + ], ), ProfileBackend::RustTracing => ( vec![ArtifactRecord { @@ -494,26 +531,34 @@ fn execute_capture( target: &ResolvedProfileTarget, manifest: &mut ProfileManifest, ) -> Result<()> { - let plan_only_warning = match (args.provider, target.backend) { - (ProfileProvider::Local, ProfileBackend::AndroidNative) => Some( - "local android-native capture is not implemented yet; this session records the planned simpleperf artifact contract only", - ), - (ProfileProvider::Local, ProfileBackend::IosInstruments) => Some( - "local ios-instruments capture is not implemented yet; this session records the planned Instruments trace/XML artifact contract only", - ), - (ProfileProvider::Local, ProfileBackend::RustTracing) => Some( - "local rust-tracing capture is not implemented yet; this session records the planned trace-events artifact contract only", + if let Some(device) = &target.device { + manifest.warnings.push(format!( + "resolved target device: {} ({}, source: {})", + device.identifier, device.os, device.source + )); + } + + match (args.provider, target.backend) { + (ProfileProvider::Local, ProfileBackend::AndroidNative) => { + execute_local_android_native(args, target, manifest)? + } + (ProfileProvider::Local, ProfileBackend::IosInstruments) => { + execute_local_ios_instruments(args, target, manifest)? + } + (ProfileProvider::Local, ProfileBackend::RustTracing) => manifest.warnings.push( + "local rust-tracing capture is not implemented yet; this session records the planned trace-events artifact contract only" + .into(), ), (ProfileProvider::Browserstack, ProfileBackend::AndroidNative) => { bail!(browserstack_native_capture_unsupported_message( "android-native", - "local Android profiling produces simpleperf artifacts and flamegraphs when implemented", + "local Android profiling produces simpleperf captures, folded stacks, and flamegraph.html", )); } (ProfileProvider::Browserstack, ProfileBackend::IosInstruments) => { bail!(browserstack_native_capture_unsupported_message( "ios-instruments", - "local iOS profiling produces Instruments traces (`time-profiler.trace`) and XML exports (`time-profiler.xml`), not flamegraphs", + "local iOS profiling on simulator produces sample-based folded stacks and flamegraph.html", )); } (ProfileProvider::Browserstack, ProfileBackend::RustTracing) => { @@ -522,16 +567,6 @@ fn execute_capture( ); } (_, ProfileBackend::Auto) => unreachable!("auto backend should resolve before execution"), - }; - - if let Some(device) = &target.device { - manifest.warnings.push(format!( - "resolved target device: {} ({}, source: {})", - device.identifier, device.os, device.source - )); - } - if let Some(warning) = plan_only_warning { - manifest.warnings.push(warning.into()); } Ok(()) } @@ -545,6 +580,865 @@ fn browserstack_native_capture_unsupported_message( ) } +#[derive(Debug)] +struct PreparedLocalProfileRun { + layout: crate::ResolvedProjectLayout, +} + +#[derive(Debug)] +struct AndroidToolchain { + adb: PathBuf, + ndk_home: PathBuf, + app_profiler: PathBuf, + stackcollapse: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LocalIosSimulator { + name: String, + udid: String, + os_version: String, + state: String, +} + +#[derive(Debug, Deserialize)] +struct SimctlList { + devices: BTreeMap>, +} + +#[derive(Debug, Deserialize)] +struct SimctlDevice { + name: String, + udid: String, + state: String, + #[serde(rename = "isAvailable", default)] + is_available: bool, +} + +fn execute_local_android_native( + args: &ProfileRunArgs, + target: &ResolvedProfileTarget, + manifest: &mut ProfileManifest, +) -> Result<()> { + if target.device.is_some() { + manifest.warnings.push( + "local android-native capture uses the connected adb target; BrowserStack-style device resolution is ignored locally. Set ANDROID_SERIAL if more than one device is attached." + .into(), + ); + } + + let prepared = prepare_local_profile_run(args)?; + mobench_sdk::codegen::ensure_android_project_with_options( + &prepared.layout.output_dir, + &prepared.layout.crate_name, + Some(&prepared.layout.project_root), + Some(&prepared.layout.crate_dir), + ) + .map_err(|err| anyhow!("failed to generate Android project scaffolding: {err}"))?; + ensure_android_profileable_manifest(&prepared.layout.output_dir)?; + + let toolchain = resolve_android_toolchain()?; + let selected_serial = select_android_serial(&toolchain.adb)?; + let build = run_android_build( + &prepared.layout, + &toolchain.ndk_home.display().to_string(), + args.release, + false, + )?; + + install_android_apk(&toolchain.adb, &selected_serial, &build.app_path)?; + + let output_root = profile_output_root(args, manifest); + let scratch_root = output_root.join(".capture-tmp/android"); + let perf_path = raw_artifact_path_or_scratch(manifest, &scratch_root, "simpleperf", "sample.perf"); + let workdir = perf_path + .parent() + .ok_or_else(|| anyhow!("invalid simpleperf output path {}", perf_path.display()))? + .to_path_buf(); + fs::create_dir_all(&workdir)?; + + run_android_simpleperf_capture( + &toolchain, + &selected_serial, + &prepared.layout, + &perf_path, + args.capture_duration_secs, + )?; + + if matches!(args.format, ProfileFormat::Processed | ProfileFormat::Both) { + let folded_path = processed_artifact_path_or_scratch( + manifest, + &scratch_root, + "collapsed-stacks", + "stacks.folded", + ); + let flamegraph_path = processed_artifact_path_or_scratch( + manifest, + &scratch_root, + "flamegraph", + "flamegraph.html", + ); + + match write_android_processed_outputs( + &toolchain, + &perf_path, + &folded_path, + &flamegraph_path, + &manifest.function, + ) { + Ok(()) => manifest.capture_status = CaptureStatus::Captured, + Err(err) if matches!(args.format, ProfileFormat::Both) => { + manifest.capture_status = CaptureStatus::Partial; + manifest.warnings.push(format!( + "native Android capture succeeded, but folded stack or flamegraph generation failed: {err}" + )); + return Ok(()); + } + Err(err) => return Err(err), + } + } else { + manifest.capture_status = CaptureStatus::Captured; + } + + manifest.warnings.push(format!( + "captured Android simpleperf profile via adb target `{selected_serial}`" + )); + Ok(()) +} + +fn execute_local_ios_instruments( + args: &ProfileRunArgs, + target: &ResolvedProfileTarget, + manifest: &mut ProfileManifest, +) -> Result<()> { + let prepared = prepare_local_profile_run(args)?; + run_ios_build(&prepared.layout, args.release, false)?; + + let simulator = select_local_ios_simulator(target.device.as_ref())?; + let output_root = profile_output_root(args, manifest); + let scratch_root = output_root.join(".capture-tmp/ios"); + let derived_data = scratch_root.join("DerivedData"); + fs::create_dir_all(&scratch_root)?; + + boot_ios_simulator(&simulator)?; + ensure_ios_bench_delay_support(&prepared.layout.output_dir)?; + let app_path = build_ios_simulator_app(&prepared.layout, args.release, &simulator, &derived_data)?; + let sample_path = raw_artifact_path_or_scratch(manifest, &scratch_root, "sample", "sample.txt"); + capture_ios_sample_profile( + &simulator, + &prepared.layout.crate_name, + &app_path, + &sample_path, + args.capture_duration_secs, + )?; + + if matches!(args.format, ProfileFormat::Processed | ProfileFormat::Both) { + let folded_path = processed_artifact_path_or_scratch( + manifest, + &scratch_root, + "collapsed-stacks", + "stacks.folded", + ); + let flamegraph_path = processed_artifact_path_or_scratch( + manifest, + &scratch_root, + "flamegraph", + "flamegraph.html", + ); + + match write_ios_processed_outputs(&sample_path, &folded_path, &flamegraph_path, &manifest.function) + { + Ok(()) => manifest.capture_status = CaptureStatus::Captured, + Err(err) if matches!(args.format, ProfileFormat::Both) => { + manifest.capture_status = CaptureStatus::Partial; + manifest.warnings.push(format!( + "native iOS capture succeeded, but folded stack or flamegraph generation failed: {err}" + )); + return Ok(()); + } + Err(err) => return Err(err), + } + } else { + manifest.capture_status = CaptureStatus::Captured; + } + + manifest.warnings.push(format!( + "captured iOS simulator sample profile for the ios-instruments backend on {} ({})", + simulator.name, simulator.os_version + )); + Ok(()) +} + +fn prepare_local_profile_run(args: &ProfileRunArgs) -> Result { + let layout = resolve_project_layout(ProjectLayoutOptions { + start_dir: None, + project_root: None, + crate_path: args.crate_path.as_deref(), + config_path: args.config.as_deref(), + })?; + load_dotenv_for_layout(&layout); + validate_benchmark_function(&layout, &args.function)?; + + let spec = RunSpec { + target: args.target, + function: args.function.clone(), + iterations: args.iterations, + warmup: args.warmup, + devices: Vec::new(), + browserstack: None, + ios_xcuitest: None, + }; + persist_mobile_spec(&layout, &spec, args.release)?; + + Ok(PreparedLocalProfileRun { layout }) +} + +fn profile_output_root(args: &ProfileRunArgs, manifest: &ProfileManifest) -> PathBuf { + args.output_dir.join(&manifest.run_id) +} + +fn artifact_path(records: &[ArtifactRecord], label: &str) -> Option { + records + .iter() + .find(|artifact| artifact.label == label) + .map(|artifact| artifact.path.clone()) +} + +fn raw_artifact_path_or_scratch( + manifest: &ProfileManifest, + scratch_root: &Path, + label: &str, + filename: &str, +) -> PathBuf { + artifact_path(&manifest.raw_artifacts, label) + .unwrap_or_else(|| scratch_root.join("raw").join(filename)) +} + +fn processed_artifact_path_or_scratch( + manifest: &ProfileManifest, + scratch_root: &Path, + label: &str, + filename: &str, +) -> PathBuf { + artifact_path(&manifest.processed_artifacts, label) + .unwrap_or_else(|| scratch_root.join("processed").join(filename)) +} + +fn ensure_parent_dir(path: &Path) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("path {} has no parent directory", path.display()))?; + fs::create_dir_all(parent)?; + Ok(()) +} + +fn absolute_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + + Ok(std::env::current_dir() + .context("resolving current directory for profile artifact path")? + .join(path)) +} + +fn run_command_output(command: &mut Command, description: &str) -> Result { + let output = command + .output() + .with_context(|| format!("failed to run {description}"))?; + if output.status.success() { + return Ok(output); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "{description} failed with status {}.\nstdout:\n{}\nstderr:\n{}", + output.status, + stdout.trim(), + stderr.trim() + ) +} + +fn resolve_android_toolchain() -> Result { + let sdk_roots = android_sdk_roots(); + let adb = resolve_command_path( + "adb", + sdk_roots + .iter() + .map(|root| root.join("platform-tools/adb")) + .collect(), + )?; + + let ndk_home = if let Ok(explicit) = std::env::var("ANDROID_NDK_HOME") { + PathBuf::from(explicit) + } else { + sdk_roots + .iter() + .find_map(|root| newest_directory(root.join("ndk"))) + .ok_or_else(|| { + anyhow!( + "ANDROID_NDK_HOME is not set and no Android NDK was found under the local SDK. Set ANDROID_NDK_HOME before running local Android profiling." + ) + })? + }; + + let app_profiler = ndk_home.join("simpleperf/app_profiler.py"); + let stackcollapse = ndk_home.join("simpleperf/stackcollapse.py"); + for path in [&app_profiler, &stackcollapse] { + if !path.is_file() { + bail!( + "required simpleperf helper was not found at {}. Install an Android NDK with simpleperf support.", + path.display() + ); + } + } + + Ok(AndroidToolchain { + adb, + ndk_home, + app_profiler, + stackcollapse, + }) +} + +fn android_sdk_roots() -> Vec { + let mut roots = Vec::new(); + for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] { + if let Some(value) = std::env::var_os(key) { + roots.push(PathBuf::from(value)); + } + } + if let Some(home) = std::env::var_os("HOME") { + roots.push(PathBuf::from(home).join("Library/Android/sdk")); + } + roots.retain(|path| path.exists()); + roots.sort(); + roots.dedup(); + roots +} + +fn newest_directory(root: PathBuf) -> Option { + let mut entries = fs::read_dir(root).ok()?.filter_map(|entry| { + let entry = entry.ok()?; + entry.file_type().ok()?.is_dir().then_some(entry.path()) + }).collect::>(); + entries.sort(); + entries.pop() +} + +fn resolve_command_path(binary: &str, fallback_paths: Vec) -> Result { + if let Some(path) = std::env::var_os("PATH").and_then(|path_var| { + std::env::split_paths(&path_var) + .map(|dir| dir.join(binary)) + .find(|candidate| candidate.is_file()) + }) { + return Ok(path); + } + + if let Some(path) = fallback_paths.into_iter().find(|candidate| candidate.is_file()) { + return Ok(path); + } + + bail!("required executable `{binary}` was not found on PATH") +} + +fn select_android_serial(adb: &Path) -> Result { + if let Ok(serial) = std::env::var("ANDROID_SERIAL") + && !serial.trim().is_empty() + { + return Ok(serial); + } + + let output = run_command_output( + Command::new(adb).arg("devices").arg("-l"), + "listing connected Android devices", + )?; + let stdout = String::from_utf8_lossy(&output.stdout); + let devices = stdout + .lines() + .skip(1) + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('*') { + return None; + } + let mut parts = trimmed.split_whitespace(); + let serial = parts.next()?; + let state = parts.next()?; + (state == "device").then_some(serial.to_string()) + }) + .collect::>(); + + match devices.as_slice() { + [serial] => Ok(serial.clone()), + [] => bail!( + "no connected Android devices were found for local profiling. Connect a device or boot an emulator, then rerun `mobench profile run --target android ...`" + ), + _ => bail!( + "multiple Android devices are connected for local profiling. Set ANDROID_SERIAL to select one target. Connected devices: {}", + devices.join(", ") + ), + } +} + +fn install_android_apk(adb: &Path, serial: &str, apk_path: &Path) -> Result<()> { + run_command_output( + Command::new(adb) + .arg("-s") + .arg(serial) + .arg("install") + .arg("-r") + .arg(apk_path), + "installing Android benchmark APK", + )?; + Ok(()) +} + +fn run_android_simpleperf_capture( + toolchain: &AndroidToolchain, + serial: &str, + layout: &crate::ResolvedProjectLayout, + perf_path: &Path, + capture_duration_secs: u64, +) -> Result<()> { + ensure_parent_dir(perf_path)?; + let perf_path = absolute_path(perf_path)?; + let workdir = perf_path + .parent() + .ok_or_else(|| anyhow!("invalid perf output path {}", perf_path.display()))?; + let package_name = android_package_name(&layout.crate_name); + let native_lib_dir = layout.output_dir.join("android/app/src/main/jniLibs"); + let record_options = format!( + "-e task-clock:u -f 1000 -g --duration {}", + capture_duration_secs.max(1) + ); + + run_command_output( + Command::new("python3") + .arg(&toolchain.app_profiler) + .arg("-p") + .arg(&package_name) + .arg("-a") + .arg(".MainActivity") + .arg("-r") + .arg(&record_options) + .arg("-lib") + .arg(&native_lib_dir) + .arg("-o") + .arg(&perf_path) + .arg("--ndk_path") + .arg(&toolchain.ndk_home) + .env("ANDROID_SERIAL", serial) + .current_dir(workdir), + "capturing Android simpleperf profile", + )?; + Ok(()) +} + +fn write_android_processed_outputs( + toolchain: &AndroidToolchain, + perf_path: &Path, + folded_path: &Path, + flamegraph_path: &Path, + function: &str, +) -> Result<()> { + ensure_parent_dir(folded_path)?; + let perf_path = absolute_path(perf_path)?; + let workdir = perf_path + .parent() + .ok_or_else(|| anyhow!("invalid perf output path {}", perf_path.display()))?; + let binary_cache = workdir.join("binary_cache"); + if !binary_cache.exists() { + bail!( + "simpleperf binary cache was not found at {} after capture", + binary_cache.display() + ); + } + + let output = run_command_output( + Command::new("python3") + .arg(&toolchain.stackcollapse) + .arg("--symfs") + .arg(&binary_cache) + .arg("-i") + .arg(&perf_path) + .current_dir(workdir), + "collapsing Android simpleperf stacks", + )?; + fs::write(folded_path, &output.stdout)?; + render_flamegraph_html( + folded_path, + flamegraph_path, + &format!("Android Native Flamegraph: {function}"), + ) +} + +fn android_package_name(crate_name: &str) -> String { + format!( + "dev.world.{}", + mobench_sdk::codegen::sanitize_bundle_id_component(crate_name) + ) +} + +fn ensure_android_profileable_manifest(output_dir: &Path) -> Result<()> { + let manifest_path = output_dir.join("android/app/src/main/AndroidManifest.xml"); + if !manifest_path.exists() { + return Ok(()); + } + let manifest = fs::read_to_string(&manifest_path)?; + if manifest.contains("\n ) -> Result { + let output = run_command_output( + Command::new("xcrun") + .arg("simctl") + .arg("list") + .arg("devices") + .arg("available") + .arg("--json"), + "listing available iOS simulators", + )?; + let listing: SimctlList = serde_json::from_slice(&output.stdout) + .context("parsing simctl device listing JSON")?; + + let mut simulators = listing + .devices + .into_iter() + .filter_map(|(runtime, devices)| { + let os_version = runtime_version_from_simctl_runtime(&runtime)?; + Some( + devices + .into_iter() + .filter(|device| device.is_available && device.name.starts_with("iPhone")) + .map(move |device| LocalIosSimulator { + name: device.name, + udid: device.udid, + os_version: os_version.clone(), + state: device.state, + }), + ) + }) + .flatten() + .collect::>(); + + if simulators.is_empty() { + bail!("no available iOS simulators were found for local profiling"); + } + + simulators.sort_by(|left, right| { + simulator_sort_key(right).cmp(&simulator_sort_key(left)) + }); + + if let Some(requested) = requested { + if let Some(simulator) = simulators + .iter() + .find(|simulator| { + simulator.name == requested.name + && os_version_matches(&simulator.os_version, &requested.os_version) + }) + .cloned() + { + return Ok(simulator); + } + + let available = simulators + .iter() + .map(|simulator| format!("{} ({})", simulator.name, simulator.os_version)) + .collect::>() + .join(", "); + bail!( + "no local iOS simulator matched requested device {} (iOS {}). Available simulators: {}", + requested.name, + requested.os_version, + available + ); + } + + simulators + .into_iter() + .next() + .ok_or_else(|| anyhow!("no suitable iOS simulator was available")) +} + +fn simulator_sort_key(simulator: &LocalIosSimulator) -> (bool, Vec, &str) { + ( + simulator.state == "Booted", + version_parts(&simulator.os_version), + simulator.name.as_str(), + ) +} + +fn version_parts(version: &str) -> Vec { + version + .split('.') + .filter_map(|segment| segment.parse::().ok()) + .collect() +} + +fn runtime_version_from_simctl_runtime(runtime: &str) -> Option { + runtime + .split('.') + .next_back() + .and_then(|segment| segment.strip_prefix("iOS-")) + .map(|version| version.replace('-', ".")) +} + +fn os_version_matches(candidate: &str, requested: &str) -> bool { + candidate == requested + || candidate.starts_with(&format!("{requested}.")) + || requested.starts_with(&format!("{candidate}.")) +} + +fn boot_ios_simulator(simulator: &LocalIosSimulator) -> Result<()> { + if simulator.state != "Booted" { + run_command_output( + Command::new("xcrun") + .arg("simctl") + .arg("boot") + .arg(&simulator.udid), + "booting iOS simulator", + )?; + } + run_command_output( + Command::new("xcrun") + .arg("simctl") + .arg("bootstatus") + .arg(&simulator.udid) + .arg("-b"), + "waiting for iOS simulator boot", + )?; + Ok(()) +} + +fn build_ios_simulator_app( + layout: &crate::ResolvedProjectLayout, + release: bool, + _simulator: &LocalIosSimulator, + derived_data: &Path, +) -> Result { + let derived_data = if derived_data.is_absolute() { + derived_data.to_path_buf() + } else { + std::env::current_dir() + .context("resolving absolute iOS simulator build output path")? + .join(derived_data) + }; + let configuration = if release { "Release" } else { "Debug" }; + let project_path = layout.output_dir.join("ios/BenchRunner/BenchRunner.xcodeproj"); + let config_build_dir = derived_data.join(format!("{configuration}-iphonesimulator")); + run_command_output( + Command::new("xcodebuild") + .arg("-project") + .arg(&project_path) + .arg("-target") + .arg("BenchRunner") + .arg("-configuration") + .arg(configuration) + .arg("build") + .arg("SDKROOT=iphonesimulator") + .arg("SUPPORTED_PLATFORMS=iphonesimulator iphoneos") + .arg("CODE_SIGNING_ALLOWED=NO") + .arg("CODE_SIGNING_REQUIRED=NO") + .arg(format!("CONFIGURATION_BUILD_DIR={}", config_build_dir.display())) + .arg(format!("OBJROOT={}", derived_data.join("Intermediates").display())) + .arg(format!("SYMROOT={}", derived_data.join("Products").display())), + "building iOS simulator benchmark app", + )?; + + let app_path = config_build_dir.join("BenchRunner.app"); + if !app_path.exists() { + bail!( + "expected iOS simulator app at {}, but the build output was missing", + app_path.display() + ); + } + Ok(app_path) +} + +fn ensure_ios_bench_delay_support(output_dir: &Path) -> Result<()> { + let content_view_path = output_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift"); + if !content_view_path.exists() { + return Ok(()); + } + let content = fs::read_to_string(&content_view_path)?; + if content.contains("MOBENCH_BENCH_DELAY_MS") { + return Ok(()); + } + let updated = content.replacen( + " Task {\n let result = await BenchRunnerFFI.runCurrentBenchmark()\n", + " Task {\n if let delay = ProcessInfo.processInfo.environment[\"MOBENCH_BENCH_DELAY_MS\"],\n let delayMs = UInt64(delay) {\n try? await Task.sleep(nanoseconds: delayMs * 1_000_000)\n }\n let result = await BenchRunnerFFI.runCurrentBenchmark()\n", + 1, + ); + if updated == content { + bail!( + "failed to inject benchmark start delay support into {}", + content_view_path.display() + ); + } + fs::write(content_view_path, updated)?; + Ok(()) +} + +fn capture_ios_sample_profile( + simulator: &LocalIosSimulator, + crate_name: &str, + app_path: &Path, + sample_path: &Path, + capture_duration_secs: u64, +) -> Result<()> { + ensure_parent_dir(sample_path)?; + install_ios_simulator_app(simulator, app_path)?; + launch_ios_app_with_delay(simulator, crate_name)?; + let host_pid = wait_for_ios_host_process_pid()?; + run_command_output( + Command::new("sample") + .arg(host_pid.to_string()) + .arg(capture_duration_secs.max(1).to_string()) + .arg("-file") + .arg(sample_path), + "capturing iOS simulator sample profile", + )?; + Ok(()) +} + +fn install_ios_simulator_app(simulator: &LocalIosSimulator, app_path: &Path) -> Result<()> { + run_command_output( + Command::new("xcrun") + .arg("simctl") + .arg("install") + .arg(&simulator.udid) + .arg(app_path), + "installing iOS simulator benchmark app", + )?; + Ok(()) +} + +fn launch_ios_app_with_delay(simulator: &LocalIosSimulator, crate_name: &str) -> Result<()> { + let bundle_id = ios_bundle_identifier(crate_name); + let _ = run_command_output( + Command::new("xcrun") + .arg("simctl") + .arg("terminate") + .arg(&simulator.udid) + .arg(&bundle_id), + "terminating previous iOS simulator benchmark app", + ); + run_command_output( + Command::new("xcrun") + .arg("simctl") + .arg("launch") + .arg("--terminate-running-process") + .arg(&simulator.udid) + .arg(&bundle_id) + .env("SIMCTL_CHILD_MOBENCH_BENCH_DELAY_MS", "4000"), + "launching iOS simulator benchmark app", + )?; + Ok(()) +} + +fn wait_for_ios_host_process_pid() -> Result { + for _ in 0..20 { + let output = run_command_output( + Command::new("pgrep") + .arg("-f") + .arg("BenchRunner.app/BenchRunner"), + "locating iOS simulator benchmark host process", + ); + if let Ok(output) = output { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(pid) = stdout.lines().find_map(|line| line.trim().parse::().ok()) { + return Ok(pid); + } + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + + bail!("timed out waiting for the iOS simulator benchmark process to appear") +} + +fn ios_bundle_identifier(crate_name: &str) -> String { + format!( + "dev.world.{}.BenchRunner", + mobench_sdk::codegen::sanitize_bundle_id_component(crate_name) + ) +} + +fn write_ios_processed_outputs( + sample_path: &Path, + folded_path: &Path, + flamegraph_path: &Path, + function: &str, +) -> Result<()> { + ensure_parent_dir(folded_path)?; + let input = BufReader::new(File::open(sample_path)?); + let output = BufWriter::new(File::create(folded_path)?); + inferno_sample::Folder::default() + .collapse(input, output) + .context("collapsing iOS sample output to folded stacks")?; + + render_flamegraph_html( + folded_path, + flamegraph_path, + &format!("iOS Simulator Flamegraph: {function}"), + ) +} + +fn render_flamegraph_html(folded_path: &Path, html_path: &Path, title: &str) -> Result<()> { + ensure_parent_dir(html_path)?; + + let folded = fs::read(folded_path)?; + if folded.is_empty() { + bail!("folded stack file {} was empty", folded_path.display()); + } + + let mut options = flamegraph::Options::default(); + options.title = title.to_string(); + options.count_name = "samples".into(); + options.notes = "Generated by mobench profile run".into(); + + let mut svg = Vec::new(); + flamegraph::from_reader(&mut options, Cursor::new(folded), &mut svg) + .context("rendering flamegraph SVG")?; + let svg = String::from_utf8(svg).context("decoding rendered flamegraph SVG")?; + let html = wrap_svg_document(title, &svg); + fs::write(html_path, html.as_bytes())?; + Ok(()) +} + +fn wrap_svg_document(title: &str, svg: &str) -> String { + format!( + "{}{}", + escape_html(title), + svg + ) +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('\"', """) +} + fn select_viewer_hint( backend: ProfileBackend, format: ProfileFormat, @@ -554,7 +1448,10 @@ fn select_viewer_hint( match backend { ProfileBackend::AndroidNative => { if format != ProfileFormat::Native && !processed_artifacts.is_empty() { - Some("Open artifacts/processed/flamegraph.html in a browser".into()) + Some( + "Open artifacts/processed/flamegraph.html in a browser, or inspect artifacts/processed/stacks.folded for folded stacks" + .into(), + ) } else if !raw_artifacts.is_empty() { Some( "Inspect artifacts/raw/sample.perf with the Android profiling toolchain".into(), @@ -564,11 +1461,14 @@ fn select_viewer_hint( } } ProfileBackend::IosInstruments => { - if !raw_artifacts.is_empty() { - Some("Open artifacts/raw/time-profiler.trace in Instruments".into()) - } else if !processed_artifacts.is_empty() { + if format != ProfileFormat::Native && !processed_artifacts.is_empty() { + Some( + "Open artifacts/processed/flamegraph.html in a browser, or inspect artifacts/processed/stacks.folded" + .into(), + ) + } else if !raw_artifacts.is_empty() { Some( - "Inspect artifacts/processed/time-profiler.xml or rerun with --format both to keep the .trace bundle" + "Inspect artifacts/raw/sample.txt or rerun with --format both to keep the folded stacks and flamegraph" .into(), ) } else { @@ -613,6 +1513,8 @@ mod tests { target, function: "sample_fns::fibonacci".into(), crate_path: None, + iterations: 100, + warmup: 10, config: None, output_dir: PathBuf::from("target/mobench/profile"), device: None, @@ -622,6 +1524,8 @@ mod tests { provider, backend, format, + release: false, + capture_duration_secs: 10, } } @@ -634,6 +1538,36 @@ mod tests { assert_eq!(json["capture_status"], "partial"); } + #[test] + fn profile_manifest_serializes_native_capture_sections() { + let manifest = sample_manifest(); + + let json = serde_json::to_value(&manifest).expect("serialize manifest"); + assert!( + json.get("native_capture").is_some(), + "expected native capture metadata to be nested under native_capture, got: {json}" + ); + assert!( + json["native_capture"].get("symbolization").is_some(), + "expected native capture metadata to include symbolization state, got: {json}" + ); + } + + #[test] + fn profile_manifest_serializes_semantic_profile_sections() { + let manifest = sample_manifest(); + + let json = serde_json::to_value(&manifest).expect("serialize manifest"); + assert!( + json.get("semantic_profile").is_some(), + "expected semantic profiling metadata to be nested under semantic_profile, got: {json}" + ); + assert!( + json["semantic_profile"].get("phases").is_some(), + "expected semantic profiling metadata to expose phase data, got: {json}" + ); + } + #[test] fn render_profile_summary_mentions_backend_and_artifacts() { let manifest = sample_manifest(); @@ -644,6 +1578,25 @@ mod tests { assert!(markdown.contains("missing symbols")); } + #[test] + fn render_profile_summary_separates_native_and_semantic_outputs() { + let manifest = sample_manifest(); + let markdown = render_profile_markdown(&manifest); + + assert!( + markdown.contains("Native capture") || markdown.contains("native capture"), + "expected a native capture section, got:\n{markdown}" + ); + assert!( + markdown.contains("Semantic phases"), + "expected a semantic phases section, got:\n{markdown}" + ); + assert!( + markdown.contains("flamegraph.html") || markdown.contains("sample.perf"), + "expected native artifact references to remain visible, got:\n{markdown}" + ); + } + #[test] fn summarize_command_reads_manifest_and_renders_markdown() { let dir = tempfile::tempdir().expect("temp dir"); @@ -679,6 +1632,11 @@ mod tests { .iter() .any(|p| p.path.ends_with("sample.perf")) ); + assert!( + plan.processed_artifacts + .iter() + .any(|p| p.path.ends_with("stacks.folded")) + ); assert!( plan.processed_artifacts .iter() @@ -708,7 +1666,7 @@ mod tests { } #[test] - fn ios_backend_allocates_trace_bundle_and_export_paths() { + fn ios_backend_builds_capture_plan_with_sample_artifacts() { let plan = build_capture_plan( &sample_run_args( MobileTarget::Ios, @@ -723,12 +1681,17 @@ mod tests { assert!( plan.raw_artifacts .iter() - .any(|p| p.path.ends_with("time-profiler.trace")) + .any(|p| p.path.ends_with("sample.txt")) + ); + assert!( + plan.processed_artifacts + .iter() + .any(|p| p.path.ends_with("stacks.folded")) ); assert!( plan.processed_artifacts .iter() - .any(|p| p.path.ends_with("time-profiler.xml")) + .any(|p| p.path.ends_with("flamegraph.html")) ); } @@ -781,13 +1744,30 @@ mod tests { ); assert!( message.contains("Instruments") - || message.contains("time-profiler.trace") - || message.contains("time-profiler.xml") + || message.contains("sample.txt") || message.contains("flamegraph"), "expected the error to clarify the iOS artifact story, got: {message}" ); } + #[test] + fn profile_summary_renders_semantic_phases_separately_from_flamegraph_artifacts() { + let markdown = render_profile_markdown(&sample_manifest()); + + assert!( + markdown.contains("Semantic phases"), + "expected semantic phases to be rendered separately, got:\n{markdown}" + ); + assert!( + markdown.contains("prove"), + "expected semantic phase names to be visible, got:\n{markdown}" + ); + assert!( + markdown.contains("serialize"), + "expected semantic phase names to be visible, got:\n{markdown}" + ); + } + #[test] fn profile_rust_tracing_processed_only_is_rejected() { let error = build_capture_plan( @@ -824,8 +1804,8 @@ mod tests { ios_args.function = "sample_fns::checksum".into(); ios_args.output_dir = dir.path().to_path_buf(); - cmd_profile_run(&android_args, false).expect("write first planned profile session"); - cmd_profile_run(&ios_args, false).expect("write second planned profile session"); + cmd_profile_run(&android_args, true).expect("write first planned profile session"); + cmd_profile_run(&ios_args, true).expect("write second planned profile session"); let android_run_dir = dir.path().join("android-sample_fns--fibonacci"); let ios_run_dir = dir.path().join("ios-sample_fns--checksum"); diff --git a/crates/mobench/tests/profile_cli.rs b/crates/mobench/tests/profile_cli.rs index 87ff833..adb9a7e 100644 --- a/crates/mobench/tests/profile_cli.rs +++ b/crates/mobench/tests/profile_cli.rs @@ -83,8 +83,7 @@ fn browserstack_native_profile_error_is_actionable() { ); assert!( stderr.contains("Instruments") - || stderr.contains("time-profiler.trace") - || stderr.contains("time-profiler.xml") + || stderr.contains("sample.txt") || stderr.contains("flamegraph"), "expected iOS artifact clarification, got:\n{stderr}" ); @@ -97,8 +96,8 @@ fn profile_run_help_mentions_planned_only_or_execution_scope() { let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("plan") - || stdout.contains("Plan") + stdout.contains("local + android-native") + || stdout.contains("local + ios-instruments") || stdout.contains("depending on backend/provider support"), "expected help to explain whether capture is planned or executed, got:\n{stdout}" ); @@ -121,4 +120,32 @@ fn profile_run_cli_surface_exposes_or_explicitly_omits_device_selection() { || stdout.contains("device selection is unavailable"), "expected help to expose device selection or explicitly document its absence, got:\n{stdout}" ); + assert!( + stdout.contains("--iterations"), + "expected help to expose benchmark iteration count for real local profiling, got:\n{stdout}" + ); + assert!( + stdout.contains("--warmup"), + "expected help to expose benchmark warmup count for real local profiling, got:\n{stdout}" + ); + assert!( + stdout.contains("--release"), + "expected help to expose build profile control for real local profiling, got:\n{stdout}" + ); +} + +#[test] +fn profile_run_help_mentions_native_symbolization_and_semantic_phases() { + let output = run_mobench(["profile", "run", "--help"]); + assert!(output.status.success(), "expected help to succeed"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("symbolization") || stdout.contains("native capture"), + "expected help to hint at native symbolization, got:\n{stdout}" + ); + assert!( + stdout.contains("Semantic phases") || stdout.contains("prove"), + "expected help to hint at semantic profiling output, got:\n{stdout}" + ); } From af9f85942136313bff547017535e0a7c38404e8c Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 17:31:37 -0700 Subject: [PATCH 122/196] test: freeze profiling upgrade targets --- crates/mobench-sdk/src/builders/android.rs | 59 +- crates/mobench/src/profile.rs | 1052 ++------------------ crates/mobench/tests/profile_cli.rs | 35 +- 3 files changed, 86 insertions(+), 1060 deletions(-) diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index e843f04..9afc4e3 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -103,19 +103,6 @@ pub struct AndroidBuilder { } impl AndroidBuilder { - fn android_test_profile_name(&self) -> &'static str { - // The generated template pins instrumentation to the release app via - // `testBuildType "release"`, so the test APK always lands under the - // release androidTest output regardless of the main app profile. - "release" - } - - fn android_test_gradle_task(&self) -> &'static str { - // Android Gradle Plugin exposes instrumentation assembly on the app - // module, not as a root-project shorthand task in this generated app. - "app:assembleReleaseAndroidTest" - } - /// Creates a new Android builder /// /// # Arguments @@ -260,8 +247,7 @@ impl AndroidBuilder { )), test_suite_path: Some(android_dir.join(format!( "app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk", - self.android_test_profile_name(), - self.android_test_profile_name() + profile_name, profile_name ))), }); } @@ -1130,7 +1116,7 @@ impl AndroidBuilder { } /// Builds the Android test APK using Gradle - fn build_test_apk(&self, _config: &BuildConfig) -> Result { + fn build_test_apk(&self, config: &BuildConfig) -> Result { let android_dir = self.output_dir.join("android"); if !android_dir.exists() { @@ -1142,7 +1128,10 @@ impl AndroidBuilder { ))); } - let gradle_task = self.android_test_gradle_task(); + let gradle_task = match config.profile { + BuildProfile::Debug => "assembleDebugAndroidTest", + BuildProfile::Release => "assembleReleaseAndroidTest", + }; let mut cmd = Command::new("./gradlew"); cmd.arg(gradle_task).current_dir(&android_dir); @@ -1188,7 +1177,10 @@ impl AndroidBuilder { ))); } - let profile_name = self.android_test_profile_name(); + let profile_name = match config.profile { + BuildProfile::Debug => "debug", + BuildProfile::Release => "release", + }; let test_apk_dir = android_dir .join("app/build/outputs/apk/androidTest") @@ -1286,25 +1278,6 @@ mod tests { assert_eq!(builder.output_dir, PathBuf::from("/custom/output")); } - #[test] - fn test_android_test_task_matches_generated_app_module() { - let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); - assert_eq!(builder.android_test_gradle_task(), "app:assembleReleaseAndroidTest"); - assert_eq!(builder.android_test_profile_name(), "release"); - } - - #[test] - fn android_native_offsets_are_symbolized_into_rust_frames() { - let input = - "dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1"; - let output = symbolize_android_native_stack_line(input); - - assert!( - output.contains("sample_fns::fibonacci"), - "expected unresolved native offsets to be rewritten into Rust symbols, got: {output}" - ); - } - #[test] fn test_parse_output_metadata_unsigned() { let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); @@ -1337,6 +1310,18 @@ mod tests { assert_eq!(result, None); } + #[test] + fn android_native_offsets_are_symbolized_into_rust_frames() { + let input = + "dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1"; + let output = symbolize_android_native_stack_line(input); + + assert!( + output.contains("sample_fns::fibonacci"), + "expected unresolved native offsets to be rewritten into Rust symbols, got: {output}" + ); + } + #[test] fn test_find_crate_dir_current_directory_is_crate() { // Test case 1: Current directory IS the crate with matching package name diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 40e8da9..fa99dac 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -1,20 +1,10 @@ -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Result, bail}; use clap::{Args, ValueEnum}; -use inferno::collapse::Collapse; -use inferno::{collapse::sample as inferno_sample, flamegraph}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; use std::fmt::Write; -use std::fs::{self, File}; -use std::io::{BufReader, BufWriter, Cursor}; use std::path::{Path, PathBuf}; -use std::process::Command; -use crate::{ - DevicePlatform, MobileTarget, ProjectLayoutOptions, ResolvedMatrixDevice, RunSpec, - load_dotenv_for_layout, persist_mobile_spec, resolve_devices_for_profile, - resolve_project_layout, run_android_build, run_ios_build, validate_benchmark_function, -}; +use crate::{DevicePlatform, MobileTarget, ResolvedMatrixDevice, resolve_devices_for_profile}; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -49,23 +39,20 @@ pub enum ProfileSummaryFormat { #[derive(Debug, Clone, Args)] #[command( - about = "Execute a native profiling session locally, or write a profile contract when execution is unsupported", + about = "Plan or execute a native profiling session depending on backend/provider support", after_help = concat!( "Capability matrix:\n", - " local + android-native: builds the Android bench app, captures simpleperf, writes folded stacks, and renders flamegraph.html\n", - " local + ios-instruments: builds the iOS Simulator bench app, samples the simulator-host process, writes folded stacks, and renders flamegraph.html\n", - " local + rust-tracing: planned manifest today; structured trace output is local-only and still not implemented\n", + " local + android-native: planned manifest today; native simpleperf capture is not implemented yet\n", + " local + ios-instruments: planned manifest today; Instruments trace export capture is not implemented yet\n", + " local + rust-tracing: planned manifest today; structured trace output is local-only\n", " browserstack + android-native: unsupported for native capture in this release\n", " browserstack + ios-instruments: unsupported for native capture in this release\n", " browserstack + rust-tracing: unsupported; use local provider for trace-events output\n", "\n", - "Local capture requirements:\n", - " Android native capture requires one connected adb device or booted emulator plus Android SDK/NDK simpleperf tools.\n", - " iOS native capture runs against a local iOS Simulator selected by --device/--os-version when provided.\n", - "\n", "Device selection:\n", - " Use --device/--os-version for one explicit device request, or --profile with optional\n", - " --device-matrix/--config to reuse the same deterministic resolution model as `mobench devices resolve`.\n" + " Use --device/--os-version for one explicit BrowserStack device, or --profile with\n", + " optional --device-matrix/--config to reuse the same deterministic resolution model as\n", + " `mobench devices resolve`.\n" ) )] pub struct ProfileRunArgs { @@ -78,10 +65,6 @@ pub struct ProfileRunArgs { help = "Path to the benchmark crate directory containing Cargo.toml" )] pub crate_path: Option, - #[arg(long, default_value_t = 100)] - pub iterations: u32, - #[arg(long, default_value_t = 10)] - pub warmup: u32, #[arg(long, help = "Optional path to config file")] pub config: Option, #[arg(long, default_value = "target/mobench/profile")] @@ -113,14 +96,6 @@ pub struct ProfileRunArgs { pub backend: ProfileBackend, #[arg(long, value_enum, default_value_t = ProfileFormat::Both)] pub format: ProfileFormat, - #[arg(long, help = "Build mobile artifacts in release mode")] - pub release: bool, - #[arg( - long, - default_value_t = 10, - help = "Capture duration in seconds for native profiling sessions" - )] - pub capture_duration_secs: u64, } #[derive(Debug, Clone, Args)] @@ -353,32 +328,20 @@ fn build_capture_plan(args: &ProfileRunArgs, output_root: &Path) -> Result ( vec![ArtifactRecord { - label: "sample".into(), - path: raw_root.join("sample.txt"), + label: "time-profiler".into(), + path: raw_root.join("time-profiler.trace"), + }], + vec![ArtifactRecord { + label: "xctrace-export".into(), + path: processed_root.join("time-profiler.xml"), }], - vec![ - ArtifactRecord { - label: "collapsed-stacks".into(), - path: processed_root.join("stacks.folded"), - }, - ArtifactRecord { - label: "flamegraph".into(), - path: processed_root.join("flamegraph.html"), - }, - ], ), ProfileBackend::RustTracing => ( vec![ArtifactRecord { @@ -531,34 +494,26 @@ fn execute_capture( target: &ResolvedProfileTarget, manifest: &mut ProfileManifest, ) -> Result<()> { - if let Some(device) = &target.device { - manifest.warnings.push(format!( - "resolved target device: {} ({}, source: {})", - device.identifier, device.os, device.source - )); - } - - match (args.provider, target.backend) { - (ProfileProvider::Local, ProfileBackend::AndroidNative) => { - execute_local_android_native(args, target, manifest)? - } - (ProfileProvider::Local, ProfileBackend::IosInstruments) => { - execute_local_ios_instruments(args, target, manifest)? - } - (ProfileProvider::Local, ProfileBackend::RustTracing) => manifest.warnings.push( - "local rust-tracing capture is not implemented yet; this session records the planned trace-events artifact contract only" - .into(), + let plan_only_warning = match (args.provider, target.backend) { + (ProfileProvider::Local, ProfileBackend::AndroidNative) => Some( + "local android-native capture is not implemented yet; this session records the planned simpleperf artifact contract only", + ), + (ProfileProvider::Local, ProfileBackend::IosInstruments) => Some( + "local ios-instruments capture is not implemented yet; this session records the planned Instruments trace/XML artifact contract only", + ), + (ProfileProvider::Local, ProfileBackend::RustTracing) => Some( + "local rust-tracing capture is not implemented yet; this session records the planned trace-events artifact contract only", ), (ProfileProvider::Browserstack, ProfileBackend::AndroidNative) => { bail!(browserstack_native_capture_unsupported_message( "android-native", - "local Android profiling produces simpleperf captures, folded stacks, and flamegraph.html", + "local Android profiling produces simpleperf artifacts and flamegraphs when implemented", )); } (ProfileProvider::Browserstack, ProfileBackend::IosInstruments) => { bail!(browserstack_native_capture_unsupported_message( "ios-instruments", - "local iOS profiling on simulator produces sample-based folded stacks and flamegraph.html", + "local iOS profiling produces Instruments traces (`time-profiler.trace`) and XML exports (`time-profiler.xml`), not flamegraphs", )); } (ProfileProvider::Browserstack, ProfileBackend::RustTracing) => { @@ -567,6 +522,16 @@ fn execute_capture( ); } (_, ProfileBackend::Auto) => unreachable!("auto backend should resolve before execution"), + }; + + if let Some(device) = &target.device { + manifest.warnings.push(format!( + "resolved target device: {} ({}, source: {})", + device.identifier, device.os, device.source + )); + } + if let Some(warning) = plan_only_warning { + manifest.warnings.push(warning.into()); } Ok(()) } @@ -580,865 +545,6 @@ fn browserstack_native_capture_unsupported_message( ) } -#[derive(Debug)] -struct PreparedLocalProfileRun { - layout: crate::ResolvedProjectLayout, -} - -#[derive(Debug)] -struct AndroidToolchain { - adb: PathBuf, - ndk_home: PathBuf, - app_profiler: PathBuf, - stackcollapse: PathBuf, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct LocalIosSimulator { - name: String, - udid: String, - os_version: String, - state: String, -} - -#[derive(Debug, Deserialize)] -struct SimctlList { - devices: BTreeMap>, -} - -#[derive(Debug, Deserialize)] -struct SimctlDevice { - name: String, - udid: String, - state: String, - #[serde(rename = "isAvailable", default)] - is_available: bool, -} - -fn execute_local_android_native( - args: &ProfileRunArgs, - target: &ResolvedProfileTarget, - manifest: &mut ProfileManifest, -) -> Result<()> { - if target.device.is_some() { - manifest.warnings.push( - "local android-native capture uses the connected adb target; BrowserStack-style device resolution is ignored locally. Set ANDROID_SERIAL if more than one device is attached." - .into(), - ); - } - - let prepared = prepare_local_profile_run(args)?; - mobench_sdk::codegen::ensure_android_project_with_options( - &prepared.layout.output_dir, - &prepared.layout.crate_name, - Some(&prepared.layout.project_root), - Some(&prepared.layout.crate_dir), - ) - .map_err(|err| anyhow!("failed to generate Android project scaffolding: {err}"))?; - ensure_android_profileable_manifest(&prepared.layout.output_dir)?; - - let toolchain = resolve_android_toolchain()?; - let selected_serial = select_android_serial(&toolchain.adb)?; - let build = run_android_build( - &prepared.layout, - &toolchain.ndk_home.display().to_string(), - args.release, - false, - )?; - - install_android_apk(&toolchain.adb, &selected_serial, &build.app_path)?; - - let output_root = profile_output_root(args, manifest); - let scratch_root = output_root.join(".capture-tmp/android"); - let perf_path = raw_artifact_path_or_scratch(manifest, &scratch_root, "simpleperf", "sample.perf"); - let workdir = perf_path - .parent() - .ok_or_else(|| anyhow!("invalid simpleperf output path {}", perf_path.display()))? - .to_path_buf(); - fs::create_dir_all(&workdir)?; - - run_android_simpleperf_capture( - &toolchain, - &selected_serial, - &prepared.layout, - &perf_path, - args.capture_duration_secs, - )?; - - if matches!(args.format, ProfileFormat::Processed | ProfileFormat::Both) { - let folded_path = processed_artifact_path_or_scratch( - manifest, - &scratch_root, - "collapsed-stacks", - "stacks.folded", - ); - let flamegraph_path = processed_artifact_path_or_scratch( - manifest, - &scratch_root, - "flamegraph", - "flamegraph.html", - ); - - match write_android_processed_outputs( - &toolchain, - &perf_path, - &folded_path, - &flamegraph_path, - &manifest.function, - ) { - Ok(()) => manifest.capture_status = CaptureStatus::Captured, - Err(err) if matches!(args.format, ProfileFormat::Both) => { - manifest.capture_status = CaptureStatus::Partial; - manifest.warnings.push(format!( - "native Android capture succeeded, but folded stack or flamegraph generation failed: {err}" - )); - return Ok(()); - } - Err(err) => return Err(err), - } - } else { - manifest.capture_status = CaptureStatus::Captured; - } - - manifest.warnings.push(format!( - "captured Android simpleperf profile via adb target `{selected_serial}`" - )); - Ok(()) -} - -fn execute_local_ios_instruments( - args: &ProfileRunArgs, - target: &ResolvedProfileTarget, - manifest: &mut ProfileManifest, -) -> Result<()> { - let prepared = prepare_local_profile_run(args)?; - run_ios_build(&prepared.layout, args.release, false)?; - - let simulator = select_local_ios_simulator(target.device.as_ref())?; - let output_root = profile_output_root(args, manifest); - let scratch_root = output_root.join(".capture-tmp/ios"); - let derived_data = scratch_root.join("DerivedData"); - fs::create_dir_all(&scratch_root)?; - - boot_ios_simulator(&simulator)?; - ensure_ios_bench_delay_support(&prepared.layout.output_dir)?; - let app_path = build_ios_simulator_app(&prepared.layout, args.release, &simulator, &derived_data)?; - let sample_path = raw_artifact_path_or_scratch(manifest, &scratch_root, "sample", "sample.txt"); - capture_ios_sample_profile( - &simulator, - &prepared.layout.crate_name, - &app_path, - &sample_path, - args.capture_duration_secs, - )?; - - if matches!(args.format, ProfileFormat::Processed | ProfileFormat::Both) { - let folded_path = processed_artifact_path_or_scratch( - manifest, - &scratch_root, - "collapsed-stacks", - "stacks.folded", - ); - let flamegraph_path = processed_artifact_path_or_scratch( - manifest, - &scratch_root, - "flamegraph", - "flamegraph.html", - ); - - match write_ios_processed_outputs(&sample_path, &folded_path, &flamegraph_path, &manifest.function) - { - Ok(()) => manifest.capture_status = CaptureStatus::Captured, - Err(err) if matches!(args.format, ProfileFormat::Both) => { - manifest.capture_status = CaptureStatus::Partial; - manifest.warnings.push(format!( - "native iOS capture succeeded, but folded stack or flamegraph generation failed: {err}" - )); - return Ok(()); - } - Err(err) => return Err(err), - } - } else { - manifest.capture_status = CaptureStatus::Captured; - } - - manifest.warnings.push(format!( - "captured iOS simulator sample profile for the ios-instruments backend on {} ({})", - simulator.name, simulator.os_version - )); - Ok(()) -} - -fn prepare_local_profile_run(args: &ProfileRunArgs) -> Result { - let layout = resolve_project_layout(ProjectLayoutOptions { - start_dir: None, - project_root: None, - crate_path: args.crate_path.as_deref(), - config_path: args.config.as_deref(), - })?; - load_dotenv_for_layout(&layout); - validate_benchmark_function(&layout, &args.function)?; - - let spec = RunSpec { - target: args.target, - function: args.function.clone(), - iterations: args.iterations, - warmup: args.warmup, - devices: Vec::new(), - browserstack: None, - ios_xcuitest: None, - }; - persist_mobile_spec(&layout, &spec, args.release)?; - - Ok(PreparedLocalProfileRun { layout }) -} - -fn profile_output_root(args: &ProfileRunArgs, manifest: &ProfileManifest) -> PathBuf { - args.output_dir.join(&manifest.run_id) -} - -fn artifact_path(records: &[ArtifactRecord], label: &str) -> Option { - records - .iter() - .find(|artifact| artifact.label == label) - .map(|artifact| artifact.path.clone()) -} - -fn raw_artifact_path_or_scratch( - manifest: &ProfileManifest, - scratch_root: &Path, - label: &str, - filename: &str, -) -> PathBuf { - artifact_path(&manifest.raw_artifacts, label) - .unwrap_or_else(|| scratch_root.join("raw").join(filename)) -} - -fn processed_artifact_path_or_scratch( - manifest: &ProfileManifest, - scratch_root: &Path, - label: &str, - filename: &str, -) -> PathBuf { - artifact_path(&manifest.processed_artifacts, label) - .unwrap_or_else(|| scratch_root.join("processed").join(filename)) -} - -fn ensure_parent_dir(path: &Path) -> Result<()> { - let parent = path - .parent() - .ok_or_else(|| anyhow!("path {} has no parent directory", path.display()))?; - fs::create_dir_all(parent)?; - Ok(()) -} - -fn absolute_path(path: &Path) -> Result { - if path.is_absolute() { - return Ok(path.to_path_buf()); - } - - Ok(std::env::current_dir() - .context("resolving current directory for profile artifact path")? - .join(path)) -} - -fn run_command_output(command: &mut Command, description: &str) -> Result { - let output = command - .output() - .with_context(|| format!("failed to run {description}"))?; - if output.status.success() { - return Ok(output); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - bail!( - "{description} failed with status {}.\nstdout:\n{}\nstderr:\n{}", - output.status, - stdout.trim(), - stderr.trim() - ) -} - -fn resolve_android_toolchain() -> Result { - let sdk_roots = android_sdk_roots(); - let adb = resolve_command_path( - "adb", - sdk_roots - .iter() - .map(|root| root.join("platform-tools/adb")) - .collect(), - )?; - - let ndk_home = if let Ok(explicit) = std::env::var("ANDROID_NDK_HOME") { - PathBuf::from(explicit) - } else { - sdk_roots - .iter() - .find_map(|root| newest_directory(root.join("ndk"))) - .ok_or_else(|| { - anyhow!( - "ANDROID_NDK_HOME is not set and no Android NDK was found under the local SDK. Set ANDROID_NDK_HOME before running local Android profiling." - ) - })? - }; - - let app_profiler = ndk_home.join("simpleperf/app_profiler.py"); - let stackcollapse = ndk_home.join("simpleperf/stackcollapse.py"); - for path in [&app_profiler, &stackcollapse] { - if !path.is_file() { - bail!( - "required simpleperf helper was not found at {}. Install an Android NDK with simpleperf support.", - path.display() - ); - } - } - - Ok(AndroidToolchain { - adb, - ndk_home, - app_profiler, - stackcollapse, - }) -} - -fn android_sdk_roots() -> Vec { - let mut roots = Vec::new(); - for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] { - if let Some(value) = std::env::var_os(key) { - roots.push(PathBuf::from(value)); - } - } - if let Some(home) = std::env::var_os("HOME") { - roots.push(PathBuf::from(home).join("Library/Android/sdk")); - } - roots.retain(|path| path.exists()); - roots.sort(); - roots.dedup(); - roots -} - -fn newest_directory(root: PathBuf) -> Option { - let mut entries = fs::read_dir(root).ok()?.filter_map(|entry| { - let entry = entry.ok()?; - entry.file_type().ok()?.is_dir().then_some(entry.path()) - }).collect::>(); - entries.sort(); - entries.pop() -} - -fn resolve_command_path(binary: &str, fallback_paths: Vec) -> Result { - if let Some(path) = std::env::var_os("PATH").and_then(|path_var| { - std::env::split_paths(&path_var) - .map(|dir| dir.join(binary)) - .find(|candidate| candidate.is_file()) - }) { - return Ok(path); - } - - if let Some(path) = fallback_paths.into_iter().find(|candidate| candidate.is_file()) { - return Ok(path); - } - - bail!("required executable `{binary}` was not found on PATH") -} - -fn select_android_serial(adb: &Path) -> Result { - if let Ok(serial) = std::env::var("ANDROID_SERIAL") - && !serial.trim().is_empty() - { - return Ok(serial); - } - - let output = run_command_output( - Command::new(adb).arg("devices").arg("-l"), - "listing connected Android devices", - )?; - let stdout = String::from_utf8_lossy(&output.stdout); - let devices = stdout - .lines() - .skip(1) - .filter_map(|line| { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('*') { - return None; - } - let mut parts = trimmed.split_whitespace(); - let serial = parts.next()?; - let state = parts.next()?; - (state == "device").then_some(serial.to_string()) - }) - .collect::>(); - - match devices.as_slice() { - [serial] => Ok(serial.clone()), - [] => bail!( - "no connected Android devices were found for local profiling. Connect a device or boot an emulator, then rerun `mobench profile run --target android ...`" - ), - _ => bail!( - "multiple Android devices are connected for local profiling. Set ANDROID_SERIAL to select one target. Connected devices: {}", - devices.join(", ") - ), - } -} - -fn install_android_apk(adb: &Path, serial: &str, apk_path: &Path) -> Result<()> { - run_command_output( - Command::new(adb) - .arg("-s") - .arg(serial) - .arg("install") - .arg("-r") - .arg(apk_path), - "installing Android benchmark APK", - )?; - Ok(()) -} - -fn run_android_simpleperf_capture( - toolchain: &AndroidToolchain, - serial: &str, - layout: &crate::ResolvedProjectLayout, - perf_path: &Path, - capture_duration_secs: u64, -) -> Result<()> { - ensure_parent_dir(perf_path)?; - let perf_path = absolute_path(perf_path)?; - let workdir = perf_path - .parent() - .ok_or_else(|| anyhow!("invalid perf output path {}", perf_path.display()))?; - let package_name = android_package_name(&layout.crate_name); - let native_lib_dir = layout.output_dir.join("android/app/src/main/jniLibs"); - let record_options = format!( - "-e task-clock:u -f 1000 -g --duration {}", - capture_duration_secs.max(1) - ); - - run_command_output( - Command::new("python3") - .arg(&toolchain.app_profiler) - .arg("-p") - .arg(&package_name) - .arg("-a") - .arg(".MainActivity") - .arg("-r") - .arg(&record_options) - .arg("-lib") - .arg(&native_lib_dir) - .arg("-o") - .arg(&perf_path) - .arg("--ndk_path") - .arg(&toolchain.ndk_home) - .env("ANDROID_SERIAL", serial) - .current_dir(workdir), - "capturing Android simpleperf profile", - )?; - Ok(()) -} - -fn write_android_processed_outputs( - toolchain: &AndroidToolchain, - perf_path: &Path, - folded_path: &Path, - flamegraph_path: &Path, - function: &str, -) -> Result<()> { - ensure_parent_dir(folded_path)?; - let perf_path = absolute_path(perf_path)?; - let workdir = perf_path - .parent() - .ok_or_else(|| anyhow!("invalid perf output path {}", perf_path.display()))?; - let binary_cache = workdir.join("binary_cache"); - if !binary_cache.exists() { - bail!( - "simpleperf binary cache was not found at {} after capture", - binary_cache.display() - ); - } - - let output = run_command_output( - Command::new("python3") - .arg(&toolchain.stackcollapse) - .arg("--symfs") - .arg(&binary_cache) - .arg("-i") - .arg(&perf_path) - .current_dir(workdir), - "collapsing Android simpleperf stacks", - )?; - fs::write(folded_path, &output.stdout)?; - render_flamegraph_html( - folded_path, - flamegraph_path, - &format!("Android Native Flamegraph: {function}"), - ) -} - -fn android_package_name(crate_name: &str) -> String { - format!( - "dev.world.{}", - mobench_sdk::codegen::sanitize_bundle_id_component(crate_name) - ) -} - -fn ensure_android_profileable_manifest(output_dir: &Path) -> Result<()> { - let manifest_path = output_dir.join("android/app/src/main/AndroidManifest.xml"); - if !manifest_path.exists() { - return Ok(()); - } - let manifest = fs::read_to_string(&manifest_path)?; - if manifest.contains("\n ) -> Result { - let output = run_command_output( - Command::new("xcrun") - .arg("simctl") - .arg("list") - .arg("devices") - .arg("available") - .arg("--json"), - "listing available iOS simulators", - )?; - let listing: SimctlList = serde_json::from_slice(&output.stdout) - .context("parsing simctl device listing JSON")?; - - let mut simulators = listing - .devices - .into_iter() - .filter_map(|(runtime, devices)| { - let os_version = runtime_version_from_simctl_runtime(&runtime)?; - Some( - devices - .into_iter() - .filter(|device| device.is_available && device.name.starts_with("iPhone")) - .map(move |device| LocalIosSimulator { - name: device.name, - udid: device.udid, - os_version: os_version.clone(), - state: device.state, - }), - ) - }) - .flatten() - .collect::>(); - - if simulators.is_empty() { - bail!("no available iOS simulators were found for local profiling"); - } - - simulators.sort_by(|left, right| { - simulator_sort_key(right).cmp(&simulator_sort_key(left)) - }); - - if let Some(requested) = requested { - if let Some(simulator) = simulators - .iter() - .find(|simulator| { - simulator.name == requested.name - && os_version_matches(&simulator.os_version, &requested.os_version) - }) - .cloned() - { - return Ok(simulator); - } - - let available = simulators - .iter() - .map(|simulator| format!("{} ({})", simulator.name, simulator.os_version)) - .collect::>() - .join(", "); - bail!( - "no local iOS simulator matched requested device {} (iOS {}). Available simulators: {}", - requested.name, - requested.os_version, - available - ); - } - - simulators - .into_iter() - .next() - .ok_or_else(|| anyhow!("no suitable iOS simulator was available")) -} - -fn simulator_sort_key(simulator: &LocalIosSimulator) -> (bool, Vec, &str) { - ( - simulator.state == "Booted", - version_parts(&simulator.os_version), - simulator.name.as_str(), - ) -} - -fn version_parts(version: &str) -> Vec { - version - .split('.') - .filter_map(|segment| segment.parse::().ok()) - .collect() -} - -fn runtime_version_from_simctl_runtime(runtime: &str) -> Option { - runtime - .split('.') - .next_back() - .and_then(|segment| segment.strip_prefix("iOS-")) - .map(|version| version.replace('-', ".")) -} - -fn os_version_matches(candidate: &str, requested: &str) -> bool { - candidate == requested - || candidate.starts_with(&format!("{requested}.")) - || requested.starts_with(&format!("{candidate}.")) -} - -fn boot_ios_simulator(simulator: &LocalIosSimulator) -> Result<()> { - if simulator.state != "Booted" { - run_command_output( - Command::new("xcrun") - .arg("simctl") - .arg("boot") - .arg(&simulator.udid), - "booting iOS simulator", - )?; - } - run_command_output( - Command::new("xcrun") - .arg("simctl") - .arg("bootstatus") - .arg(&simulator.udid) - .arg("-b"), - "waiting for iOS simulator boot", - )?; - Ok(()) -} - -fn build_ios_simulator_app( - layout: &crate::ResolvedProjectLayout, - release: bool, - _simulator: &LocalIosSimulator, - derived_data: &Path, -) -> Result { - let derived_data = if derived_data.is_absolute() { - derived_data.to_path_buf() - } else { - std::env::current_dir() - .context("resolving absolute iOS simulator build output path")? - .join(derived_data) - }; - let configuration = if release { "Release" } else { "Debug" }; - let project_path = layout.output_dir.join("ios/BenchRunner/BenchRunner.xcodeproj"); - let config_build_dir = derived_data.join(format!("{configuration}-iphonesimulator")); - run_command_output( - Command::new("xcodebuild") - .arg("-project") - .arg(&project_path) - .arg("-target") - .arg("BenchRunner") - .arg("-configuration") - .arg(configuration) - .arg("build") - .arg("SDKROOT=iphonesimulator") - .arg("SUPPORTED_PLATFORMS=iphonesimulator iphoneos") - .arg("CODE_SIGNING_ALLOWED=NO") - .arg("CODE_SIGNING_REQUIRED=NO") - .arg(format!("CONFIGURATION_BUILD_DIR={}", config_build_dir.display())) - .arg(format!("OBJROOT={}", derived_data.join("Intermediates").display())) - .arg(format!("SYMROOT={}", derived_data.join("Products").display())), - "building iOS simulator benchmark app", - )?; - - let app_path = config_build_dir.join("BenchRunner.app"); - if !app_path.exists() { - bail!( - "expected iOS simulator app at {}, but the build output was missing", - app_path.display() - ); - } - Ok(app_path) -} - -fn ensure_ios_bench_delay_support(output_dir: &Path) -> Result<()> { - let content_view_path = output_dir.join("ios/BenchRunner/BenchRunner/ContentView.swift"); - if !content_view_path.exists() { - return Ok(()); - } - let content = fs::read_to_string(&content_view_path)?; - if content.contains("MOBENCH_BENCH_DELAY_MS") { - return Ok(()); - } - let updated = content.replacen( - " Task {\n let result = await BenchRunnerFFI.runCurrentBenchmark()\n", - " Task {\n if let delay = ProcessInfo.processInfo.environment[\"MOBENCH_BENCH_DELAY_MS\"],\n let delayMs = UInt64(delay) {\n try? await Task.sleep(nanoseconds: delayMs * 1_000_000)\n }\n let result = await BenchRunnerFFI.runCurrentBenchmark()\n", - 1, - ); - if updated == content { - bail!( - "failed to inject benchmark start delay support into {}", - content_view_path.display() - ); - } - fs::write(content_view_path, updated)?; - Ok(()) -} - -fn capture_ios_sample_profile( - simulator: &LocalIosSimulator, - crate_name: &str, - app_path: &Path, - sample_path: &Path, - capture_duration_secs: u64, -) -> Result<()> { - ensure_parent_dir(sample_path)?; - install_ios_simulator_app(simulator, app_path)?; - launch_ios_app_with_delay(simulator, crate_name)?; - let host_pid = wait_for_ios_host_process_pid()?; - run_command_output( - Command::new("sample") - .arg(host_pid.to_string()) - .arg(capture_duration_secs.max(1).to_string()) - .arg("-file") - .arg(sample_path), - "capturing iOS simulator sample profile", - )?; - Ok(()) -} - -fn install_ios_simulator_app(simulator: &LocalIosSimulator, app_path: &Path) -> Result<()> { - run_command_output( - Command::new("xcrun") - .arg("simctl") - .arg("install") - .arg(&simulator.udid) - .arg(app_path), - "installing iOS simulator benchmark app", - )?; - Ok(()) -} - -fn launch_ios_app_with_delay(simulator: &LocalIosSimulator, crate_name: &str) -> Result<()> { - let bundle_id = ios_bundle_identifier(crate_name); - let _ = run_command_output( - Command::new("xcrun") - .arg("simctl") - .arg("terminate") - .arg(&simulator.udid) - .arg(&bundle_id), - "terminating previous iOS simulator benchmark app", - ); - run_command_output( - Command::new("xcrun") - .arg("simctl") - .arg("launch") - .arg("--terminate-running-process") - .arg(&simulator.udid) - .arg(&bundle_id) - .env("SIMCTL_CHILD_MOBENCH_BENCH_DELAY_MS", "4000"), - "launching iOS simulator benchmark app", - )?; - Ok(()) -} - -fn wait_for_ios_host_process_pid() -> Result { - for _ in 0..20 { - let output = run_command_output( - Command::new("pgrep") - .arg("-f") - .arg("BenchRunner.app/BenchRunner"), - "locating iOS simulator benchmark host process", - ); - if let Ok(output) = output { - let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(pid) = stdout.lines().find_map(|line| line.trim().parse::().ok()) { - return Ok(pid); - } - } - std::thread::sleep(std::time::Duration::from_millis(250)); - } - - bail!("timed out waiting for the iOS simulator benchmark process to appear") -} - -fn ios_bundle_identifier(crate_name: &str) -> String { - format!( - "dev.world.{}.BenchRunner", - mobench_sdk::codegen::sanitize_bundle_id_component(crate_name) - ) -} - -fn write_ios_processed_outputs( - sample_path: &Path, - folded_path: &Path, - flamegraph_path: &Path, - function: &str, -) -> Result<()> { - ensure_parent_dir(folded_path)?; - let input = BufReader::new(File::open(sample_path)?); - let output = BufWriter::new(File::create(folded_path)?); - inferno_sample::Folder::default() - .collapse(input, output) - .context("collapsing iOS sample output to folded stacks")?; - - render_flamegraph_html( - folded_path, - flamegraph_path, - &format!("iOS Simulator Flamegraph: {function}"), - ) -} - -fn render_flamegraph_html(folded_path: &Path, html_path: &Path, title: &str) -> Result<()> { - ensure_parent_dir(html_path)?; - - let folded = fs::read(folded_path)?; - if folded.is_empty() { - bail!("folded stack file {} was empty", folded_path.display()); - } - - let mut options = flamegraph::Options::default(); - options.title = title.to_string(); - options.count_name = "samples".into(); - options.notes = "Generated by mobench profile run".into(); - - let mut svg = Vec::new(); - flamegraph::from_reader(&mut options, Cursor::new(folded), &mut svg) - .context("rendering flamegraph SVG")?; - let svg = String::from_utf8(svg).context("decoding rendered flamegraph SVG")?; - let html = wrap_svg_document(title, &svg); - fs::write(html_path, html.as_bytes())?; - Ok(()) -} - -fn wrap_svg_document(title: &str, svg: &str) -> String { - format!( - "{}{}", - escape_html(title), - svg - ) -} - -fn escape_html(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('\"', """) -} - fn select_viewer_hint( backend: ProfileBackend, format: ProfileFormat, @@ -1448,10 +554,7 @@ fn select_viewer_hint( match backend { ProfileBackend::AndroidNative => { if format != ProfileFormat::Native && !processed_artifacts.is_empty() { - Some( - "Open artifacts/processed/flamegraph.html in a browser, or inspect artifacts/processed/stacks.folded for folded stacks" - .into(), - ) + Some("Open artifacts/processed/flamegraph.html in a browser".into()) } else if !raw_artifacts.is_empty() { Some( "Inspect artifacts/raw/sample.perf with the Android profiling toolchain".into(), @@ -1461,14 +564,11 @@ fn select_viewer_hint( } } ProfileBackend::IosInstruments => { - if format != ProfileFormat::Native && !processed_artifacts.is_empty() { - Some( - "Open artifacts/processed/flamegraph.html in a browser, or inspect artifacts/processed/stacks.folded" - .into(), - ) - } else if !raw_artifacts.is_empty() { + if !raw_artifacts.is_empty() { + Some("Open artifacts/raw/time-profiler.trace in Instruments".into()) + } else if !processed_artifacts.is_empty() { Some( - "Inspect artifacts/raw/sample.txt or rerun with --format both to keep the folded stacks and flamegraph" + "Inspect artifacts/processed/time-profiler.xml or rerun with --format both to keep the .trace bundle" .into(), ) } else { @@ -1513,8 +613,6 @@ mod tests { target, function: "sample_fns::fibonacci".into(), crate_path: None, - iterations: 100, - warmup: 10, config: None, output_dir: PathBuf::from("target/mobench/profile"), device: None, @@ -1524,8 +622,6 @@ mod tests { provider, backend, format, - release: false, - capture_duration_secs: 10, } } @@ -1579,21 +675,20 @@ mod tests { } #[test] - fn render_profile_summary_separates_native_and_semantic_outputs() { - let manifest = sample_manifest(); - let markdown = render_profile_markdown(&manifest); + fn profile_summary_renders_semantic_phases_separately_from_flamegraph_artifacts() { + let markdown = render_profile_markdown(&sample_manifest()); assert!( - markdown.contains("Native capture") || markdown.contains("native capture"), - "expected a native capture section, got:\n{markdown}" + markdown.contains("Semantic phases"), + "expected semantic phases to be rendered separately, got:\n{markdown}" ); assert!( - markdown.contains("Semantic phases"), - "expected a semantic phases section, got:\n{markdown}" + markdown.contains("prove"), + "expected semantic phase names to be visible, got:\n{markdown}" ); assert!( - markdown.contains("flamegraph.html") || markdown.contains("sample.perf"), - "expected native artifact references to remain visible, got:\n{markdown}" + markdown.contains("serialize"), + "expected semantic phase names to be visible, got:\n{markdown}" ); } @@ -1632,11 +727,6 @@ mod tests { .iter() .any(|p| p.path.ends_with("sample.perf")) ); - assert!( - plan.processed_artifacts - .iter() - .any(|p| p.path.ends_with("stacks.folded")) - ); assert!( plan.processed_artifacts .iter() @@ -1666,7 +756,7 @@ mod tests { } #[test] - fn ios_backend_builds_capture_plan_with_sample_artifacts() { + fn ios_backend_allocates_trace_bundle_and_export_paths() { let plan = build_capture_plan( &sample_run_args( MobileTarget::Ios, @@ -1681,17 +771,12 @@ mod tests { assert!( plan.raw_artifacts .iter() - .any(|p| p.path.ends_with("sample.txt")) + .any(|p| p.path.ends_with("time-profiler.trace")) ); assert!( plan.processed_artifacts .iter() - .any(|p| p.path.ends_with("stacks.folded")) - ); - assert!( - plan.processed_artifacts - .iter() - .any(|p| p.path.ends_with("flamegraph.html")) + .any(|p| p.path.ends_with("time-profiler.xml")) ); } @@ -1744,30 +829,13 @@ mod tests { ); assert!( message.contains("Instruments") - || message.contains("sample.txt") + || message.contains("time-profiler.trace") + || message.contains("time-profiler.xml") || message.contains("flamegraph"), "expected the error to clarify the iOS artifact story, got: {message}" ); } - #[test] - fn profile_summary_renders_semantic_phases_separately_from_flamegraph_artifacts() { - let markdown = render_profile_markdown(&sample_manifest()); - - assert!( - markdown.contains("Semantic phases"), - "expected semantic phases to be rendered separately, got:\n{markdown}" - ); - assert!( - markdown.contains("prove"), - "expected semantic phase names to be visible, got:\n{markdown}" - ); - assert!( - markdown.contains("serialize"), - "expected semantic phase names to be visible, got:\n{markdown}" - ); - } - #[test] fn profile_rust_tracing_processed_only_is_rejected() { let error = build_capture_plan( @@ -1804,8 +872,8 @@ mod tests { ios_args.function = "sample_fns::checksum".into(); ios_args.output_dir = dir.path().to_path_buf(); - cmd_profile_run(&android_args, true).expect("write first planned profile session"); - cmd_profile_run(&ios_args, true).expect("write second planned profile session"); + cmd_profile_run(&android_args, false).expect("write first planned profile session"); + cmd_profile_run(&ios_args, false).expect("write second planned profile session"); let android_run_dir = dir.path().join("android-sample_fns--fibonacci"); let ios_run_dir = dir.path().join("ios-sample_fns--checksum"); diff --git a/crates/mobench/tests/profile_cli.rs b/crates/mobench/tests/profile_cli.rs index adb9a7e..87ff833 100644 --- a/crates/mobench/tests/profile_cli.rs +++ b/crates/mobench/tests/profile_cli.rs @@ -83,7 +83,8 @@ fn browserstack_native_profile_error_is_actionable() { ); assert!( stderr.contains("Instruments") - || stderr.contains("sample.txt") + || stderr.contains("time-profiler.trace") + || stderr.contains("time-profiler.xml") || stderr.contains("flamegraph"), "expected iOS artifact clarification, got:\n{stderr}" ); @@ -96,8 +97,8 @@ fn profile_run_help_mentions_planned_only_or_execution_scope() { let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("local + android-native") - || stdout.contains("local + ios-instruments") + stdout.contains("plan") + || stdout.contains("Plan") || stdout.contains("depending on backend/provider support"), "expected help to explain whether capture is planned or executed, got:\n{stdout}" ); @@ -120,32 +121,4 @@ fn profile_run_cli_surface_exposes_or_explicitly_omits_device_selection() { || stdout.contains("device selection is unavailable"), "expected help to expose device selection or explicitly document its absence, got:\n{stdout}" ); - assert!( - stdout.contains("--iterations"), - "expected help to expose benchmark iteration count for real local profiling, got:\n{stdout}" - ); - assert!( - stdout.contains("--warmup"), - "expected help to expose benchmark warmup count for real local profiling, got:\n{stdout}" - ); - assert!( - stdout.contains("--release"), - "expected help to expose build profile control for real local profiling, got:\n{stdout}" - ); -} - -#[test] -fn profile_run_help_mentions_native_symbolization_and_semantic_phases() { - let output = run_mobench(["profile", "run", "--help"]); - assert!(output.status.success(), "expected help to succeed"); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("symbolization") || stdout.contains("native capture"), - "expected help to hint at native symbolization, got:\n{stdout}" - ); - assert!( - stdout.contains("Semantic phases") || stdout.contains("prove"), - "expected help to hint at semantic profiling output, got:\n{stdout}" - ); } From 5b04f83f6ef1b9283dc33fb7eec29920d36878cb Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 17:38:14 -0700 Subject: [PATCH 123/196] fix: align ios low-spec profile with deployment target --- crates/mobench/src/lib.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/mobench/src/lib.rs b/crates/mobench/src/lib.rs index c2ca6c8..c87eedb 100644 --- a/crates/mobench/src/lib.rs +++ b/crates/mobench/src/lib.rs @@ -6713,7 +6713,7 @@ fn builtin_device_for_profile( profile: &str, ) -> Option { let (name, os, os_version) = match (platform, profile) { - (DevicePlatform::Ios, "low-spec") => ("iPhone 11", "ios", "13"), + (DevicePlatform::Ios, "low-spec") => ("iPhone 13", "ios", "15"), (DevicePlatform::Ios, "mid-spec") => ("iPhone 14", "ios", "16"), (DevicePlatform::Ios, "high-spec") => ("iPhone 16 Pro", "ios", "18"), (DevicePlatform::Android, "low-spec") => ("Google Pixel 6", "android", "12.0"), @@ -8714,6 +8714,16 @@ project = "proj" assert_eq!(ids, vec!["Pixel 6-12.0", "Pixel 7-13.0"]); } + #[test] + fn builtin_ios_low_spec_profile_matches_ci_deployment_target() { + let resolved = builtin_device_for_profile(DevicePlatform::Ios, "low-spec") + .expect("built-in low-spec iOS profile"); + + assert_eq!(resolved.name, "iPhone 13"); + assert_eq!(resolved.os_version, "15"); + assert_eq!(resolved.identifier, "iPhone 13-15"); + } + #[test] fn render_summary_markdown_from_merged_output() { let summary = json!({ From 54cf7f3f58ec5482e122f2059670ef7ea3a9b3ca Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 17:41:21 -0700 Subject: [PATCH 124/196] test: freeze profiling upgrade targets --- crates/mobench/src/profile.rs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index fa99dac..711137a 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -634,6 +634,16 @@ mod tests { assert_eq!(json["capture_status"], "partial"); } + #[test] + fn render_profile_summary_mentions_backend_and_artifacts() { + let manifest = sample_manifest(); + let markdown = render_profile_markdown(&manifest); + + assert!(markdown.contains("android-native")); + assert!(markdown.contains("artifacts/raw/sample.perf")); + assert!(markdown.contains("missing symbols")); + } + #[test] fn profile_manifest_serializes_native_capture_sections() { let manifest = sample_manifest(); @@ -665,13 +675,17 @@ mod tests { } #[test] - fn render_profile_summary_mentions_backend_and_artifacts() { - let manifest = sample_manifest(); - let markdown = render_profile_markdown(&manifest); + fn render_profile_summary_separates_native_and_semantic_outputs() { + let markdown = render_profile_markdown(&sample_manifest()); - assert!(markdown.contains("android-native")); - assert!(markdown.contains("artifacts/raw/sample.perf")); - assert!(markdown.contains("missing symbols")); + assert!( + markdown.contains("Raw Artifacts") || markdown.contains("Processed Artifacts"), + "expected native capture output to remain visible, got:\n{markdown}" + ); + assert!( + markdown.contains("Semantic phases"), + "expected semantic phases to be rendered separately, got:\n{markdown}" + ); } #[test] From 06d81917767b73ba9efa8fab86191305748f3ae1 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 17:46:07 -0700 Subject: [PATCH 125/196] test: tighten profiling freeze assertions --- crates/mobench-sdk/src/builders/android.rs | 9 ++++----- crates/mobench/src/profile.rs | 3 ++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index 9afc4e3..1740890 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -1246,15 +1246,14 @@ impl AndroidBuilder { } } -#[cfg(test)] -fn symbolize_android_native_stack_line(line: &str) -> String { - line.to_string() -} - #[cfg(test)] mod tests { use super::*; + fn symbolize_android_native_stack_line(line: &str) -> String { + line.to_string() + } + #[test] fn test_android_builder_creation() { let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 711137a..af5ab27 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -679,7 +679,8 @@ mod tests { let markdown = render_profile_markdown(&sample_manifest()); assert!( - markdown.contains("Raw Artifacts") || markdown.contains("Processed Artifacts"), + markdown.contains("artifacts/raw/sample.perf") + || markdown.contains("artifacts/processed/flamegraph.html"), "expected native capture output to remain visible, got:\n{markdown}" ); assert!( From 58f964261569ed87657d5e2d1de55e252a78cf9c Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 17:53:02 -0700 Subject: [PATCH 126/196] refactor: split profile manifest into native and semantic sections --- README.md | 42 +++- crates/mobench/src/profile.rs | 444 ++++++++++++++++++++++++++++------ 2 files changed, 398 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 7881faa..23d67f5 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ cargo mobench ci run --target android --function sample_fns::fibonacci --local-o cargo mobench report summarize --summary target/mobench/ci/summary.json --plots auto cargo mobench report github --pr 123 --summary target/mobench/ci/summary.json -# Experimental profiling session contract (local-first in this release) +# Experimental profiling session capture cargo mobench profile run --target android --function sample_fns::fibonacci \ --provider local --backend android-native cargo mobench profile summarize --profile target/mobench/profile/profile.json @@ -97,28 +97,43 @@ CI contract outputs are written to `target/mobench/ci/`: Local summary renderers (`ci run --plots ...` and `report summarize --plots ...`) append a `Device Comparison Plots` section with one Sina-style SVG per benchmark function. Summary resource fields use `cpu_total_ms` and `peak_memory_kb`; Android raw resource stats are preserved and iOS peak memory is enriched from BrowserStack app profiling when available. -Experimental profiling commands are local-first in this release. Each planned -session is written under `target/mobench/profile//`, and the CLI also -refreshes top-level `target/mobench/profile/profile.json` and `summary.md` as -convenience copies of the latest run. +Experimental profiling commands are local-first in this release. Supported +local backends execute a real capture and write artifacts under +`target/mobench/profile//`, and the CLI also refreshes top-level +`target/mobench/profile/profile.json` and `summary.md` as convenience copies of +the latest run. + +The manifest is split into three explicit sections: + +- `native_capture`: native stack artifacts, symbolization state, and viewer hints +- `semantic_profile`: optional benchmark phase data such as `prove` and `serialize` +- `capture_metadata`: device resolution, capture settings, and warnings + +The summary renderer keeps native and semantic outputs separate so the flamegraph +view stays focused on native stacks while phase timings remain readable as +benchmark metadata. Profiling capability matrix: | Provider | Backend | Current behavior | Notes | |----------|---------|------------------|-------| -| `local` | `android-native` | Planned manifest only | Native `simpleperf` capture is not implemented yet | -| `local` | `ios-instruments` | Planned manifest only | iOS output is an Instruments trace (`time-profiler.trace`) plus XML export (`time-profiler.xml`), not a flamegraph | -| `local` | `rust-tracing` | Planned manifest only | Structured trace output is local-only | +| `local` | `android-native` | Real capture | Builds the Android bench app, captures `simpleperf`, writes `sample.perf`, `stacks.folded`, and `flamegraph.html` | +| `local` | `ios-instruments` | Real capture | Builds the iOS Simulator bench app, samples the simulator-host process, writes `sample.txt`, `stacks.folded`, and `flamegraph.html` | +| `local` | `rust-tracing` | Planned manifest only | Structured trace output is local-only and still not implemented | | `browserstack` | `android-native` | Unsupported | Use `--provider local` for planning/local capture, or a normal BrowserStack benchmark for timing/memory metrics | | `browserstack` | `ios-instruments` | Unsupported | BrowserStack does not provide retrievable native Instruments trace artifacts in this release | | `browserstack` | `rust-tracing` | Unsupported | Use `--provider local` for trace-events output | `profile run --dry-run` always stops after target resolution plus planning and -writes the planned manifest only. Non-dry-run profile runs currently do not -execute local native capture tools automatically, and BrowserStack-backed native -profiling fails deliberately with an explanatory error instead of silently +writes the planned manifest only. Non-dry-run profile runs attempt local native +capture for supported backend/provider pairs, and BrowserStack-backed native +profiling still fails deliberately with an explanatory error instead of silently pretending to capture data. +Real local profile runs also accept the same benchmark knobs as `mobench run`, +including `--iterations`, `--warmup`, `--release`, and +`--capture-duration-secs`. + When you need device-specific planning inputs for profiling, `profile run` reuses the same resolution model as `devices resolve`: @@ -249,7 +264,10 @@ fn db_query(db: &Database) { - Clarified that profiling remains local-first in this release; BrowserStack native profiling is explicitly unsupported with actionable error text and a visible capability matrix. - Split `profile run` into target resolution, capture planning, and capture execution seams so planned manifests no longer imply that native capture actually ran. - Added device-selection inputs to `profile run` (`--device`, `--os-version`, `--profile`, `--device-matrix`) by reusing the existing deterministic device-resolution flow. -- Corrected the iOS artifact story: the planned output is an Instruments trace/XML export contract, not a flamegraph. +- Added real local profiling outputs on both platforms: + - Android: `simpleperf` capture plus folded stacks and `flamegraph.html` + - iOS Simulator: host-process sample capture plus folded stacks and `flamegraph.html` +- Corrected the iOS artifact story so the implemented local backend describes sample-based flamegraph generation rather than an Instruments trace/XML export contract. - Added regression coverage for profile help text, BrowserStack unsupported execution, dry-run planning semantics, and direct device target resolution. - Added experimental `cargo mobench profile run|summarize` commands for a normalized local profiling session contract across Android and iOS. - Profile sessions now write run-scoped artifacts under `target/mobench/profile//` and refresh top-level latest-session `profile.json` and `summary.md` convenience files. diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index af5ab27..09466f3 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -1,10 +1,10 @@ -use anyhow::{Result, bail}; +use anyhow::{bail, Result}; use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; use std::fmt::Write; use std::path::{Path, PathBuf}; -use crate::{DevicePlatform, MobileTarget, ResolvedMatrixDevice, resolve_devices_for_profile}; +use crate::{resolve_devices_for_profile, DevicePlatform, MobileTarget, ResolvedMatrixDevice}; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -117,12 +117,96 @@ pub enum CaptureStatus { Failed, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SemanticCaptureStatus { + Planned, + Captured, + Partial, + Failed, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ArtifactRecord { pub label: String, pub path: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolizationRecord { + pub status: CaptureStatus, + pub tool: Option, + pub resolved_frames: u64, + pub unresolved_frames: u64, + pub notes: Vec, +} + +impl Default for SymbolizationRecord { + fn default() -> Self { + Self { + status: CaptureStatus::Planned, + tool: None, + resolved_frames: 0, + unresolved_frames: 0, + notes: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeCaptureRecord { + pub status: CaptureStatus, + pub raw_artifacts: Vec, + pub processed_artifacts: Vec, + pub symbolization: SymbolizationRecord, + pub viewer_hint: Option, +} + +impl Default for NativeCaptureRecord { + fn default() -> Self { + Self { + status: CaptureStatus::Planned, + raw_artifacts: Vec::new(), + processed_artifacts: Vec::new(), + symbolization: SymbolizationRecord::default(), + viewer_hint: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SemanticPhaseRecord { + pub name: String, + pub duration_ns: Option, + pub percent_total: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SemanticProfileRecord { + pub status: SemanticCaptureStatus, + pub phases: Vec, + pub spans_path: Option, +} + +impl Default for SemanticProfileRecord { + fn default() -> Self { + Self { + status: SemanticCaptureStatus::Planned, + phases: Vec::new(), + spans_path: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct CaptureMetadataRecord { + pub device: Option, + pub sample_duration_secs: Option, + pub warmup_mode: Option, + pub capture_method: Option, + pub warnings: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProfileManifest { pub run_id: String, @@ -132,11 +216,9 @@ pub struct ProfileManifest { pub provider: ProfileProvider, pub backend: ProfileBackend, pub format: ProfileFormat, - pub capture_status: CaptureStatus, - pub raw_artifacts: Vec, - pub processed_artifacts: Vec, - pub warnings: Vec, - pub viewer_hint: Option, + pub native_capture: NativeCaptureRecord, + pub semantic_profile: SemanticProfileRecord, + pub capture_metadata: CaptureMetadataRecord, } fn default_profile_provider() -> ProfileProvider { @@ -176,46 +258,132 @@ pub fn render_profile_markdown(manifest: &ProfileManifest) -> String { "- Backend: `{}`", manifest.backend.to_possible_value().unwrap().get_name() ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## Native capture"); + let _ = writeln!(markdown); let _ = writeln!( markdown, "- Status: `{}`", - capture_status_label(manifest.capture_status) + capture_status_label(manifest.native_capture.status) ); - let _ = writeln!(markdown); - let _ = writeln!(markdown, "## Raw Artifacts"); - let _ = writeln!(markdown); - for artifact in &manifest.raw_artifacts { + let _ = writeln!(markdown, "- Raw artifacts:"); + for artifact in &manifest.native_capture.raw_artifacts { let _ = writeln!( markdown, - "- `{}`: `{}`", + " - `{}`: `{}`", artifact.label, artifact.path.display() ); } - let _ = writeln!(markdown); - let _ = writeln!(markdown, "## Processed Artifacts"); - let _ = writeln!(markdown); - for artifact in &manifest.processed_artifacts { + let _ = writeln!(markdown, "- Processed artifacts:"); + for artifact in &manifest.native_capture.processed_artifacts { let _ = writeln!( markdown, - "- `{}`: `{}`", + " - `{}`: `{}`", artifact.label, artifact.path.display() ); } - if !manifest.warnings.is_empty() { + let _ = writeln!(markdown, "- Symbolization:"); + let _ = writeln!( + markdown, + " - Status: `{}`", + capture_status_label(manifest.native_capture.symbolization.status) + ); + if let Some(tool) = &manifest.native_capture.symbolization.tool { + let _ = writeln!(markdown, " - Tool: `{tool}`"); + } + let _ = writeln!( + markdown, + " - Resolved frames: `{}`", + manifest.native_capture.symbolization.resolved_frames + ); + let _ = writeln!( + markdown, + " - Unresolved frames: `{}`", + manifest.native_capture.symbolization.unresolved_frames + ); + for note in &manifest.native_capture.symbolization.notes { + let _ = writeln!(markdown, " - {}", note); + } + if let Some(viewer_hint) = &manifest.native_capture.viewer_hint { let _ = writeln!(markdown); - let _ = writeln!(markdown, "## Warnings"); + let _ = writeln!(markdown, "## Viewer"); let _ = writeln!(markdown); - for warning in &manifest.warnings { - let _ = writeln!(markdown, "- {}", warning); + let _ = writeln!(markdown, "{}", viewer_hint); + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## Semantic phases"); + let _ = writeln!(markdown); + let _ = writeln!( + markdown, + "- Status: `{}`", + semantic_capture_status_label(manifest.semantic_profile.status) + ); + match &manifest.semantic_profile.spans_path { + Some(path) => { + let _ = writeln!(markdown, "- Spans path: `{}`", path.display()); + } + None => { + let _ = writeln!(markdown, "- Spans path: `not recorded`"); + } + } + if manifest.semantic_profile.phases.is_empty() { + let _ = writeln!(markdown, "- No semantic phases recorded"); + } else { + let _ = writeln!(markdown, "- Phases:"); + for phase in &manifest.semantic_profile.phases { + let _ = writeln!(markdown, " - `{}`", phase.name); + if let Some(duration_ns) = phase.duration_ns { + let _ = writeln!(markdown, " - Duration: `{duration_ns}` ns"); + } + if let Some(percent_total) = phase.percent_total { + let _ = writeln!(markdown, " - Share of total: `{percent_total}`"); + } + } + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## Capture metadata"); + let _ = writeln!(markdown); + match &manifest.capture_metadata.device { + Some(device) => { + let _ = writeln!(markdown, "- Device: `{device}`"); + } + None => { + let _ = writeln!(markdown, "- Device: `not recorded`"); + } + } + match manifest.capture_metadata.sample_duration_secs { + Some(sample_duration_secs) => { + let _ = writeln!(markdown, "- Sample duration: `{sample_duration_secs}` s"); + } + None => { + let _ = writeln!(markdown, "- Sample duration: `not recorded`"); } } - if let Some(viewer_hint) = &manifest.viewer_hint { + match &manifest.capture_metadata.warmup_mode { + Some(warmup_mode) => { + let _ = writeln!(markdown, "- Warmup mode: `{warmup_mode}`"); + } + None => { + let _ = writeln!(markdown, "- Warmup mode: `not recorded`"); + } + } + match &manifest.capture_metadata.capture_method { + Some(capture_method) => { + let _ = writeln!(markdown, "- Capture method: `{capture_method}`"); + } + None => { + let _ = writeln!(markdown, "- Capture method: `not recorded`"); + } + } + if !manifest.capture_metadata.warnings.is_empty() { let _ = writeln!(markdown); - let _ = writeln!(markdown, "## Viewer"); + let _ = writeln!(markdown, "### Warnings"); let _ = writeln!(markdown); - let _ = writeln!(markdown, "{}", viewer_hint); + for warning in &manifest.capture_metadata.warnings { + let _ = writeln!(markdown, "- {}", warning); + } } markdown @@ -234,9 +402,9 @@ pub fn cmd_profile_run(args: &ProfileRunArgs, dry_run: bool) -> Result<()> { let target = resolve_profile_target(args)?; let run_id = build_run_id(args.target, &args.function); let run_output_dir = args.output_dir.join(&run_id); - let mut manifest = build_capture_plan(args, &run_output_dir)?; + let mut manifest = build_capture_plan(args, &target, &run_output_dir)?; if dry_run { - manifest.warnings.push( + manifest.capture_metadata.warnings.push( "dry-run enabled; capture planning stopped before execution and recorded the planned artifact contract only" .into(), ); @@ -246,7 +414,10 @@ pub fn cmd_profile_run(args: &ProfileRunArgs, dry_run: bool) -> Result<()> { std::fs::create_dir_all(&args.output_dir)?; std::fs::create_dir_all(&run_output_dir)?; - create_selected_artifact_roots(&manifest.raw_artifacts, &manifest.processed_artifacts)?; + create_selected_artifact_roots( + &manifest.native_capture.raw_artifacts, + &manifest.native_capture.processed_artifacts, + )?; let rendered_summary = render_profile_markdown(&manifest); let run_profile_path = run_output_dir.join("profile.json"); @@ -294,6 +465,15 @@ fn capture_status_label(status: CaptureStatus) -> &'static str { } } +fn semantic_capture_status_label(status: SemanticCaptureStatus) -> &'static str { + match status { + SemanticCaptureStatus::Planned => "planned", + SemanticCaptureStatus::Captured => "captured", + SemanticCaptureStatus::Partial => "partial", + SemanticCaptureStatus::Failed => "failed", + } +} + fn load_profile_manifest(path: &Path) -> Result { let body = std::fs::read_to_string(path)?; Ok(serde_json::from_str(&body)?) @@ -315,7 +495,11 @@ fn resolve_profile_target(args: &ProfileRunArgs) -> Result Result { +fn build_capture_plan( + args: &ProfileRunArgs, + target: &ResolvedProfileTarget, + output_root: &Path, +) -> Result { let backend = resolve_backend(args.target, args.backend); validate_format_capabilities(backend, args.format)?; @@ -366,11 +550,29 @@ fn build_capture_plan(args: &ProfileRunArgs, output_root: &Path) -> Result "simpleperf".into(), + ProfileBackend::IosInstruments => "instruments".into(), + ProfileBackend::RustTracing => "trace-events".into(), + ProfileBackend::Auto => unreachable!("auto backend should resolve before planning"), + }), + warnings: Vec::new(), + }, }) } @@ -525,13 +727,13 @@ fn execute_capture( }; if let Some(device) = &target.device { - manifest.warnings.push(format!( + manifest.capture_metadata.warnings.push(format!( "resolved target device: {} ({}, source: {})", device.identifier, device.os, device.source )); } if let Some(warning) = plan_only_warning { - manifest.warnings.push(warning.into()); + manifest.capture_metadata.warnings.push(warning.into()); } Ok(()) } @@ -630,8 +832,8 @@ mod tests { let manifest = sample_manifest(); let json = serde_json::to_value(&manifest).expect("serialize manifest"); - assert_eq!(json["warnings"][0], "missing symbols"); - assert_eq!(json["capture_status"], "partial"); + assert_eq!(json["capture_metadata"]["warnings"][0], "missing symbols"); + assert_eq!(json["native_capture"]["status"], "partial"); } #[test] @@ -640,7 +842,9 @@ mod tests { let markdown = render_profile_markdown(&manifest); assert!(markdown.contains("android-native")); + assert!(markdown.contains("## Native capture")); assert!(markdown.contains("artifacts/raw/sample.perf")); + assert!(markdown.contains("## Semantic phases")); assert!(markdown.contains("missing symbols")); } @@ -657,6 +861,10 @@ mod tests { json["native_capture"].get("symbolization").is_some(), "expected native capture metadata to include symbolization state, got: {json}" ); + assert!( + json["native_capture"].get("viewer_hint").is_some(), + "expected native capture metadata to include viewer hints, got: {json}" + ); } #[test] @@ -733,20 +941,27 @@ mod tests { ProfileBackend::AndroidNative, ProfileFormat::Both, ), + &resolve_profile_target(&sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Both, + )) + .expect("resolve target"), &PathBuf::from("target/mobench/profile"), ) .expect("android capture plan"); - assert!( - plan.raw_artifacts - .iter() - .any(|p| p.path.ends_with("sample.perf")) - ); - assert!( - plan.processed_artifacts - .iter() - .any(|p| p.path.ends_with("flamegraph.html")) - ); + assert!(plan + .native_capture + .raw_artifacts + .iter() + .any(|p| p.path.ends_with("sample.perf"))); + assert!(plan + .native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("flamegraph.html"))); } #[test] @@ -758,14 +973,21 @@ mod tests { ProfileBackend::AndroidNative, ProfileFormat::Native, ), + &resolve_profile_target(&sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::AndroidNative, + ProfileFormat::Native, + )) + .expect("resolve target"), &PathBuf::from("target/mobench/profile"), ) .expect("native-only capture plan"); - assert_eq!(plan.raw_artifacts.len(), 1); - assert!(plan.processed_artifacts.is_empty()); + assert_eq!(plan.native_capture.raw_artifacts.len(), 1); + assert!(plan.native_capture.processed_artifacts.is_empty()); assert_eq!( - plan.viewer_hint.as_deref(), + plan.native_capture.viewer_hint.as_deref(), Some("Inspect artifacts/raw/sample.perf with the Android profiling toolchain") ); } @@ -779,20 +1001,27 @@ mod tests { ProfileBackend::IosInstruments, ProfileFormat::Both, ), + &resolve_profile_target(&sample_run_args( + MobileTarget::Ios, + ProfileProvider::Local, + ProfileBackend::IosInstruments, + ProfileFormat::Both, + )) + .expect("resolve target"), &PathBuf::from("target/mobench/profile"), ) .expect("ios capture plan"); - assert!( - plan.raw_artifacts - .iter() - .any(|p| p.path.ends_with("time-profiler.trace")) - ); - assert!( - plan.processed_artifacts - .iter() - .any(|p| p.path.ends_with("time-profiler.xml")) - ); + assert!(plan + .native_capture + .raw_artifacts + .iter() + .any(|p| p.path.ends_with("time-profiler.trace"))); + assert!(plan + .native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("time-profiler.xml"))); } #[test] @@ -805,7 +1034,8 @@ mod tests { ); let target = resolve_profile_target(&args).expect("resolve target"); let mut manifest = - build_capture_plan(&args, &PathBuf::from("target/mobench/profile")).expect("plan"); + build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile")) + .expect("plan"); let error = execute_capture(&args, &target, &mut manifest).unwrap_err(); assert!(error.to_string().contains("BrowserStack")); @@ -825,7 +1055,8 @@ mod tests { ); let target = resolve_profile_target(&args).expect("resolve target"); let mut manifest = - build_capture_plan(&args, &PathBuf::from("target/mobench/profile")).expect("plan"); + build_capture_plan(&args, &target, &PathBuf::from("target/mobench/profile")) + .expect("plan"); let error = execute_capture(&args, &target, &mut manifest).unwrap_err(); let message = error.to_string(); @@ -853,6 +1084,13 @@ mod tests { #[test] fn profile_rust_tracing_processed_only_is_rejected() { + let target = resolve_profile_target(&sample_run_args( + MobileTarget::Android, + ProfileProvider::Local, + ProfileBackend::RustTracing, + ProfileFormat::Both, + )) + .expect("resolve target"); let error = build_capture_plan( &sample_run_args( MobileTarget::Android, @@ -860,6 +1098,7 @@ mod tests { ProfileBackend::RustTracing, ProfileFormat::Processed, ), + &target, &PathBuf::from("target/mobench/profile"), ) .unwrap_err(); @@ -915,6 +1154,13 @@ mod tests { ProfileBackend::RustTracing, ProfileFormat::Both, ), + &resolve_profile_target(&sample_run_args( + MobileTarget::Android, + ProfileProvider::Browserstack, + ProfileBackend::RustTracing, + ProfileFormat::Both, + )) + .expect("resolve target"), &PathBuf::from("target/mobench/profile"), ) .expect("build manifest"); @@ -964,14 +1210,15 @@ mod tests { .join("profile.json"), ) .expect("load planned manifest"); - assert_eq!(manifest.capture_status, CaptureStatus::Planned); + assert_eq!(manifest.native_capture.status, CaptureStatus::Planned); assert!( manifest + .capture_metadata .warnings .iter() .any(|warning| warning.contains("dry-run enabled")), "expected dry-run warning in manifest: {:?}", - manifest.warnings + manifest.capture_metadata.warnings ); } @@ -1001,6 +1248,17 @@ mod tests { } fn sample_manifest() -> ProfileManifest { + let target = ResolvedProfileTarget { + backend: ProfileBackend::AndroidNative, + device: Some(ResolvedProfileDevice { + name: "Pixel 7".into(), + os: "android".into(), + os_version: "13".into(), + identifier: "Pixel 7-13.0".into(), + profile: Some("high-spec".into()), + source: "matrix".into(), + }), + }; ProfileManifest { run_id: "run-123".into(), target: MobileTarget::Android, @@ -1008,17 +1266,51 @@ mod tests { provider: ProfileProvider::Local, backend: ProfileBackend::AndroidNative, format: ProfileFormat::Both, - capture_status: CaptureStatus::Partial, - raw_artifacts: vec![ArtifactRecord { - label: "simpleperf".into(), - path: PathBuf::from("artifacts/raw/sample.perf"), - }], - processed_artifacts: vec![ArtifactRecord { - label: "flamegraph".into(), - path: PathBuf::from("artifacts/processed/flamegraph.html"), - }], - warnings: vec!["missing symbols".into()], - viewer_hint: Some("Open flamegraph.html in a browser".into()), + native_capture: NativeCaptureRecord { + status: CaptureStatus::Partial, + raw_artifacts: vec![ArtifactRecord { + label: "simpleperf".into(), + path: PathBuf::from("artifacts/raw/sample.perf"), + }], + processed_artifacts: vec![ArtifactRecord { + label: "flamegraph".into(), + path: PathBuf::from("artifacts/processed/flamegraph.html"), + }], + symbolization: SymbolizationRecord { + status: CaptureStatus::Partial, + tool: Some("llvm-addr2line".into()), + resolved_frames: 3, + unresolved_frames: 1, + notes: vec!["missing symbols".into()], + }, + viewer_hint: Some("Open flamegraph.html in a browser".into()), + }, + semantic_profile: SemanticProfileRecord { + status: SemanticCaptureStatus::Captured, + phases: vec![ + SemanticPhaseRecord { + name: "prove".into(), + duration_ns: Some(120_000), + percent_total: None, + }, + SemanticPhaseRecord { + name: "serialize".into(), + duration_ns: Some(8_000), + percent_total: None, + }, + ], + spans_path: Some(PathBuf::from("artifacts/semantic/spans.json")), + }, + capture_metadata: CaptureMetadataRecord { + device: target + .device + .as_ref() + .map(|device| device.identifier.clone()), + sample_duration_secs: Some(15), + warmup_mode: Some("warm".into()), + capture_method: Some("simpleperf".into()), + warnings: vec!["missing symbols".into()], + }, } } } From e39962078a1d7a5260b3b9614590f564d7049b0f Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 18:00:06 -0700 Subject: [PATCH 127/196] docs: realign profiling README with local-first behavior --- README.md | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 23d67f5..c3d562b 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ CI contract outputs are written to `target/mobench/ci/`: Local summary renderers (`ci run --plots ...` and `report summarize --plots ...`) append a `Device Comparison Plots` section with one Sina-style SVG per benchmark function. Summary resource fields use `cpu_total_ms` and `peak_memory_kb`; Android raw resource stats are preserved and iOS peak memory is enriched from BrowserStack app profiling when available. -Experimental profiling commands are local-first in this release. Supported -local backends execute a real capture and write artifacts under +Experimental profiling commands are local-first in this release. Each session +writes its current manifest and summary under `target/mobench/profile//`, and the CLI also refreshes top-level `target/mobench/profile/profile.json` and `summary.md` as convenience copies of the latest run. @@ -117,23 +117,19 @@ Profiling capability matrix: | Provider | Backend | Current behavior | Notes | |----------|---------|------------------|-------| -| `local` | `android-native` | Real capture | Builds the Android bench app, captures `simpleperf`, writes `sample.perf`, `stacks.folded`, and `flamegraph.html` | -| `local` | `ios-instruments` | Real capture | Builds the iOS Simulator bench app, samples the simulator-host process, writes `sample.txt`, `stacks.folded`, and `flamegraph.html` | +| `local` | `android-native` | Planned manifest only | Native `simpleperf` capture is not implemented yet | +| `local` | `ios-instruments` | Planned manifest only | iOS output is an Instruments trace (`time-profiler.trace`) plus XML export (`time-profiler.xml`), not a flamegraph | | `local` | `rust-tracing` | Planned manifest only | Structured trace output is local-only and still not implemented | | `browserstack` | `android-native` | Unsupported | Use `--provider local` for planning/local capture, or a normal BrowserStack benchmark for timing/memory metrics | | `browserstack` | `ios-instruments` | Unsupported | BrowserStack does not provide retrievable native Instruments trace artifacts in this release | | `browserstack` | `rust-tracing` | Unsupported | Use `--provider local` for trace-events output | `profile run --dry-run` always stops after target resolution plus planning and -writes the planned manifest only. Non-dry-run profile runs attempt local native -capture for supported backend/provider pairs, and BrowserStack-backed native +writes the planned manifest only. Non-dry-run profile runs currently do not +execute local native capture tools automatically, and BrowserStack-backed native profiling still fails deliberately with an explanatory error instead of silently pretending to capture data. -Real local profile runs also accept the same benchmark knobs as `mobench run`, -including `--iterations`, `--warmup`, `--release`, and -`--capture-duration-secs`. - When you need device-specific planning inputs for profiling, `profile run` reuses the same resolution model as `devices resolve`: @@ -264,10 +260,7 @@ fn db_query(db: &Database) { - Clarified that profiling remains local-first in this release; BrowserStack native profiling is explicitly unsupported with actionable error text and a visible capability matrix. - Split `profile run` into target resolution, capture planning, and capture execution seams so planned manifests no longer imply that native capture actually ran. - Added device-selection inputs to `profile run` (`--device`, `--os-version`, `--profile`, `--device-matrix`) by reusing the existing deterministic device-resolution flow. -- Added real local profiling outputs on both platforms: - - Android: `simpleperf` capture plus folded stacks and `flamegraph.html` - - iOS Simulator: host-process sample capture plus folded stacks and `flamegraph.html` -- Corrected the iOS artifact story so the implemented local backend describes sample-based flamegraph generation rather than an Instruments trace/XML export contract. +- Corrected the iOS artifact story: the planned output remains an Instruments trace/XML export contract, not a flamegraph. - Added regression coverage for profile help text, BrowserStack unsupported execution, dry-run planning semantics, and direct device target resolution. - Added experimental `cargo mobench profile run|summarize` commands for a normalized local profiling session contract across Android and iOS. - Profile sessions now write run-scoped artifacts under `target/mobench/profile//` and refresh top-level latest-session `profile.json` and `summary.md` convenience files. From 2e41a555c24321c468668ede755b726ca221ebcf Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 18:01:36 -0700 Subject: [PATCH 128/196] docs: align profiling README with current branch --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index c3d562b..9c31c2c 100644 --- a/README.md +++ b/README.md @@ -124,12 +124,6 @@ Profiling capability matrix: | `browserstack` | `ios-instruments` | Unsupported | BrowserStack does not provide retrievable native Instruments trace artifacts in this release | | `browserstack` | `rust-tracing` | Unsupported | Use `--provider local` for trace-events output | -`profile run --dry-run` always stops after target resolution plus planning and -writes the planned manifest only. Non-dry-run profile runs currently do not -execute local native capture tools automatically, and BrowserStack-backed native -profiling still fails deliberately with an explanatory error instead of silently -pretending to capture data. - When you need device-specific planning inputs for profiling, `profile run` reuses the same resolution model as `devices resolve`: From ea3b25350572eb6bbac16178407e7b5cb3664823 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 18:06:53 -0700 Subject: [PATCH 129/196] fix: preserve legacy profile manifest compatibility --- crates/mobench/src/profile.rs | 106 +++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index 09466f3..bd5605e 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -207,7 +207,7 @@ pub struct CaptureMetadataRecord { pub warnings: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct ProfileManifest { pub run_id: String, pub target: MobileTarget, @@ -221,6 +221,76 @@ pub struct ProfileManifest { pub capture_metadata: CaptureMetadataRecord, } +#[derive(Debug, Clone, Deserialize)] +struct ProfileManifestSerde { + run_id: String, + target: MobileTarget, + function: String, + #[serde(default = "default_profile_provider")] + provider: ProfileProvider, + backend: ProfileBackend, + format: ProfileFormat, + #[serde(default)] + native_capture: NativeCaptureRecord, + #[serde(default)] + semantic_profile: SemanticProfileRecord, + #[serde(default)] + capture_metadata: CaptureMetadataRecord, + #[serde(default)] + capture_status: Option, + #[serde(default)] + raw_artifacts: Vec, + #[serde(default)] + processed_artifacts: Vec, + #[serde(default)] + warnings: Vec, + #[serde(default)] + viewer_hint: Option, +} + +impl<'de> Deserialize<'de> for ProfileManifest { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let helper = ProfileManifestSerde::deserialize(deserializer)?; + Ok(Self::from(helper)) + } +} + +impl From for ProfileManifest { + fn from(mut helper: ProfileManifestSerde) -> Self { + let has_legacy_native_fields = helper.capture_status.is_some() + || !helper.raw_artifacts.is_empty() + || !helper.processed_artifacts.is_empty() + || helper.viewer_hint.is_some(); + if has_legacy_native_fields && helper.native_capture == NativeCaptureRecord::default() { + helper.native_capture = NativeCaptureRecord { + status: helper.capture_status.unwrap_or(CaptureStatus::Planned), + raw_artifacts: helper.raw_artifacts, + processed_artifacts: helper.processed_artifacts, + symbolization: SymbolizationRecord::default(), + viewer_hint: helper.viewer_hint, + }; + } + if !helper.warnings.is_empty() && helper.capture_metadata.warnings.is_empty() { + helper.capture_metadata.warnings = helper.warnings; + } + + Self { + run_id: helper.run_id, + target: helper.target, + function: helper.function, + provider: helper.provider, + backend: helper.backend, + format: helper.format, + native_capture: helper.native_capture, + semantic_profile: helper.semantic_profile, + capture_metadata: helper.capture_metadata, + } + } +} + fn default_profile_provider() -> ProfileProvider { ProfileProvider::Local } @@ -882,6 +952,40 @@ mod tests { ); } + #[test] + fn legacy_profile_manifest_deserializes_into_nested_sections() { + let legacy = serde_json::json!({ + "run_id": "run-123", + "target": "android", + "function": "sample_fns::fibonacci", + "provider": "local", + "backend": "android-native", + "format": "both", + "capture_status": "partial", + "raw_artifacts": [ + {"label": "simpleperf", "path": "artifacts/raw/sample.perf"} + ], + "processed_artifacts": [ + {"label": "flamegraph", "path": "artifacts/processed/flamegraph.html"} + ], + "warnings": ["legacy manifest"], + "viewer_hint": "Open flamegraph.html in a browser" + }); + + let manifest: ProfileManifest = + serde_json::from_value(legacy).expect("deserialize legacy manifest"); + + assert_eq!(manifest.native_capture.status, CaptureStatus::Partial); + assert_eq!(manifest.native_capture.raw_artifacts.len(), 1); + assert_eq!(manifest.native_capture.processed_artifacts.len(), 1); + assert_eq!( + manifest.native_capture.viewer_hint.as_deref(), + Some("Open flamegraph.html in a browser") + ); + assert_eq!(manifest.capture_metadata.warnings, vec!["legacy manifest"]); + assert_eq!(manifest.semantic_profile.status, SemanticCaptureStatus::Planned); + } + #[test] fn render_profile_summary_separates_native_and_semantic_outputs() { let markdown = render_profile_markdown(&sample_manifest()); From 76d122e79c0e803e078659e1cc65caeced7f3193 Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 18:17:07 -0700 Subject: [PATCH 130/196] feat: symbolize Android native profiling frames --- crates/mobench-sdk/src/builders/android.rs | 230 +++++++++++++++++++-- crates/mobench-sdk/src/types.rs | 14 ++ crates/mobench/src/profile.rs | 156 +++++++++++++- 3 files changed, 383 insertions(+), 17 deletions(-) diff --git a/crates/mobench-sdk/src/builders/android.rs b/crates/mobench-sdk/src/builders/android.rs index 1740890..d06b30e 100644 --- a/crates/mobench-sdk/src/builders/android.rs +++ b/crates/mobench-sdk/src/builders/android.rs @@ -56,7 +56,9 @@ //! ``` use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root}; -use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target}; +use crate::types::{ + BenchError, BuildConfig, BuildProfile, BuildResult, NativeLibraryArtifact, Target, +}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -249,6 +251,7 @@ impl AndroidBuilder { "app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk", profile_name, profile_name ))), + native_libraries: Vec::new(), }); } @@ -274,7 +277,7 @@ impl AndroidBuilder { // Step 3: Copy .so files to jniLibs println!("Copying native libraries to jniLibs..."); - self.copy_native_libraries(config)?; + let native_libraries = self.copy_native_libraries(config)?; // Step 4: Build APK with Gradle println!("Building Android APK with Gradle..."); @@ -289,6 +292,7 @@ impl AndroidBuilder { platform: Target::Android, app_path: apk_path, test_suite_path: Some(test_suite_path), + native_libraries, }; self.validate_build_artifacts(&result, config)?; @@ -677,7 +681,10 @@ impl AndroidBuilder { } /// Copies .so files to Android jniLibs directories - fn copy_native_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> { + fn copy_native_libraries( + &self, + config: &BuildConfig, + ) -> Result, BenchError> { let crate_dir = self.find_crate_dir()?; let profile_dir = match config.profile { BuildProfile::Debug => "debug", @@ -703,12 +710,14 @@ impl AndroidBuilder { ("armv7-linux-androideabi", "armeabi-v7a"), ("x86_64-linux-android", "x86_64"), ]; + let mut native_libraries = Vec::new(); for (rust_target, android_abi) in abi_mappings { + let library_name = format!("lib{}.so", self.crate_name.replace("-", "_")); let src = target_dir .join(rust_target) .join(profile_dir) - .join(format!("lib{}.so", self.crate_name.replace("-", "_"))); + .join(&library_name); let dest_dir = jni_libs_dir.join(android_abi); std::fs::create_dir_all(&dest_dir).map_err(|e| { @@ -720,7 +729,7 @@ impl AndroidBuilder { )) })?; - let dest = dest_dir.join(format!("lib{}.so", self.crate_name.replace("-", "_"))); + let dest = dest_dir.join(&library_name); if src.exists() { std::fs::copy(&src, &dest).map_err(|e| { @@ -736,6 +745,13 @@ impl AndroidBuilder { if self.verbose { println!(" Copied {} -> {}", src.display(), dest.display()); } + + native_libraries.push(NativeLibraryArtifact { + abi: android_abi.to_string(), + library_name: library_name.clone(), + unstripped_path: src, + packaged_path: dest, + }); } else { // Always warn about missing native libraries - this will cause runtime crashes eprintln!( @@ -748,7 +764,7 @@ impl AndroidBuilder { } } - Ok(()) + Ok(native_libraries) } /// Ensures local.properties exists with sdk.dir set @@ -1246,14 +1262,115 @@ impl AndroidBuilder { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AndroidStackSymbolization { + pub line: String, + pub resolved_frames: u64, + pub unresolved_frames: u64, +} + +pub fn symbolize_android_native_stack_line_with_resolver( + line: &str, + mut resolve: F, +) -> AndroidStackSymbolization +where + F: FnMut(&str, u64) -> Option, +{ + let (stack, sample_count) = split_folded_stack_line(line); + let mut resolved_frames = 0; + let mut unresolved_frames = 0; + let rewritten = stack + .split(';') + .map(|frame| { + if let Some((library_name, offset)) = parse_android_native_offset_frame(frame) { + if let Some(symbol) = resolve(library_name, offset) { + resolved_frames += 1; + return symbol; + } + unresolved_frames += 1; + } + frame.to_string() + }) + .collect::>() + .join(";"); + + let line = match sample_count { + Some(count) => format!("{rewritten} {count}"), + None => rewritten, + }; + + AndroidStackSymbolization { + line, + resolved_frames, + unresolved_frames, + } +} + +pub fn resolve_android_native_symbol_with_addr2line( + library_path: &Path, + offset: u64, +) -> Option { + resolve_android_native_symbol_with_tool(Path::new("llvm-addr2line"), library_path, offset) +} + +pub fn resolve_android_native_symbol_with_tool( + tool_path: &Path, + library_path: &Path, + offset: u64, +) -> Option { + let output = Command::new(tool_path) + .args(["-Cfpe"]) + .arg(library_path) + .arg(format!("0x{offset:x}")) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + parse_android_addr2line_stdout(&String::from_utf8_lossy(&output.stdout)) +} + +fn parse_android_addr2line_stdout(stdout: &str) -> Option { + stdout.lines().find_map(|line| { + let symbol = line.trim(); + if symbol.is_empty() || symbol == "??" || symbol.starts_with("?? ") { + None + } else { + Some(symbol.split(" at ").next().unwrap_or(symbol).trim().to_owned()) + } + }) +} + +fn split_folded_stack_line(line: &str) -> (&str, Option<&str>) { + match line.rsplit_once(' ') { + Some((stack, count)) if !stack.is_empty() && count.chars().all(|ch| ch.is_ascii_digit()) => { + (stack, Some(count)) + } + _ => (line, None), + } +} + +fn parse_android_native_offset_frame(frame: &str) -> Option<(&str, u64)> { + let marker = ".so[+"; + let marker_index = frame.find(marker)?; + let library_end = marker_index + 3; + let library_name = frame[..library_end].rsplit('/').next()?; + let offset_start = marker_index + marker.len(); + let offset_end = frame[offset_start..].find(']')? + offset_start; + let offset_raw = &frame[offset_start..offset_end]; + let offset = if let Some(hex) = offset_raw.strip_prefix("0x") { + u64::from_str_radix(hex, 16).ok()? + } else { + offset_raw.parse().ok()? + }; + Some((library_name, offset)) +} + #[cfg(test)] mod tests { use super::*; - fn symbolize_android_native_stack_line(line: &str) -> String { - line.to_string() - } - #[test] fn test_android_builder_creation() { let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile"); @@ -1313,12 +1430,99 @@ mod tests { fn android_native_offsets_are_symbolized_into_rust_frames() { let input = "dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1"; - let output = symbolize_android_native_stack_line(input); + let output = symbolize_android_native_stack_line_with_resolver( + input, + |library_name, offset| { + if library_name == "libsample_fns.so" && offset == 94_138 { + Some("sample_fns::fibonacci".into()) + } else { + None + } + }, + ); assert!( - output.contains("sample_fns::fibonacci"), - "expected unresolved native offsets to be rewritten into Rust symbols, got: {output}" + output.line.contains("sample_fns::fibonacci"), + "expected unresolved native offsets to be rewritten into Rust symbols, got: {}", + output.line + ); + assert_eq!(output.resolved_frames, 1); + assert_eq!(output.unresolved_frames, 0); + } + + #[test] + fn resolve_android_native_symbol_with_tool_invokes_addr2line() { + let temp_dir = std::env::temp_dir().join(format!( + "mobench-addr2line-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time") + .as_nanos() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let tool_path = temp_dir.join("llvm-addr2line.sh"); + let args_path = temp_dir.join("args.txt"); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf '%s\\n' 'sample_fns::fibonacci at /tmp/src/lib.rs:131'\n", + args_path.display() ); + std::fs::write(&tool_path, script).expect("write shim"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&tool_path) + .expect("metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&tool_path, perms).expect("chmod"); + } + + let symbol = resolve_android_native_symbol_with_tool( + &tool_path, + Path::new("/cargo/target/aarch64-linux-android/release/libsample_fns.so"), + 94_138, + ); + + assert_eq!(symbol.as_deref(), Some("sample_fns::fibonacci")); + + let args = std::fs::read_to_string(&args_path).expect("read args"); + let expected_offset = format!("0x{:x}", 94_138); + assert!( + args.lines().any(|line| line == "-Cfpe"), + "expected llvm-addr2line to be called with -Cfpe, got:\n{args}" + ); + assert!( + args.lines().any(|line| { + line == "/cargo/target/aarch64-linux-android/release/libsample_fns.so" + }), + "expected llvm-addr2line to use the unstripped library path, got:\n{args}" + ); + assert!( + args.lines().any(|line| line == expected_offset), + "expected llvm-addr2line to receive the resolved offset, got:\n{args}" + ); + } + + #[test] + fn android_native_offsets_preserve_unresolved_frames() { + let input = "dev.world.samplefns;libsample_fns.so[+94138];libother.so[+17] 1"; + let output = symbolize_android_native_stack_line_with_resolver( + input, + |library_name, offset| { + if library_name == "libsample_fns.so" && offset == 94_138 { + Some("sample_fns::fibonacci".into()) + } else { + None + } + }, + ); + + assert!(output.line.contains("sample_fns::fibonacci")); + assert!(output.line.contains("libother.so[+17]")); + assert_eq!(output.resolved_frames, 1); + assert_eq!(output.unresolved_frames, 1); } #[test] diff --git a/crates/mobench-sdk/src/types.rs b/crates/mobench-sdk/src/types.rs index 0617416..bdcdd42 100644 --- a/crates/mobench-sdk/src/types.rs +++ b/crates/mobench-sdk/src/types.rs @@ -273,6 +273,18 @@ impl BuildProfile { /// println!("Test suite at: {:?}", test_suite); /// } /// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeLibraryArtifact { + /// ABI name used in Android packaging, for example `arm64-v8a`. + pub abi: String, + /// Shared library filename, for example `libsample_fns.so`. + pub library_name: String, + /// Path to the unstripped library produced by Cargo. + pub unstripped_path: PathBuf, + /// Path to the packaged copy under `jniLibs/`. + pub packaged_path: PathBuf, +} + #[derive(Debug, Clone)] pub struct BuildResult { /// Platform that was built. @@ -287,4 +299,6 @@ pub struct BuildResult { /// - Android: Path to the androidTest APK (for Espresso) /// - iOS: Path to the XCUITest runner zip pub test_suite_path: Option, + /// Native libraries associated with this build, when applicable. + pub native_libraries: Vec, } diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index bd5605e..d0d8934 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -1,10 +1,12 @@ use anyhow::{bail, Result}; use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fmt::Write; use std::path::{Path, PathBuf}; use crate::{resolve_devices_for_profile, DevicePlatform, MobileTarget, ResolvedMatrixDevice}; +use mobench_sdk::types::NativeLibraryArtifact; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -544,6 +546,82 @@ fn semantic_capture_status_label(status: SemanticCaptureStatus) -> &'static str } } +#[allow(dead_code)] +pub(crate) fn symbolize_android_folded_stacks_with_resolver( + folded_stacks: &str, + mut resolve: F, +) -> (String, SymbolizationRecord, String) +where + F: FnMut(&str, u64) -> Option, +{ + let mut lines = Vec::new(); + let mut resolved_frames = 0; + let mut unresolved_frames = 0; + + for line in folded_stacks.lines().filter(|line| !line.trim().is_empty()) { + let symbolized = + mobench_sdk::builders::android::symbolize_android_native_stack_line_with_resolver( + line, + |library_name, offset| resolve(library_name, offset), + ); + resolved_frames += symbolized.resolved_frames; + unresolved_frames += symbolized.unresolved_frames; + lines.push(symbolized.line); + } + + let symbolized_stacks = lines.join("\n"); + let status = match (resolved_frames, unresolved_frames) { + (0, 0) => CaptureStatus::Planned, + (_, 0) => CaptureStatus::Captured, + (0, _) => CaptureStatus::Failed, + _ => CaptureStatus::Partial, + }; + let mut notes = Vec::new(); + if unresolved_frames > 0 { + notes.push("some native frames could not be symbolized".into()); + } + + let record = SymbolizationRecord { + status, + tool: Some("llvm-addr2line".into()), + resolved_frames, + unresolved_frames, + notes, + }; + let report = if symbolized_stacks.is_empty() { + "No native frames were symbolized.".into() + } else { + symbolized_stacks.clone() + }; + + (symbolized_stacks, record, report) +} + +#[allow(dead_code)] +pub(crate) fn symbolize_android_folded_stacks_with_native_libraries( + folded_stacks: &str, + native_libraries: &[NativeLibraryArtifact], + mut resolve: F, +) -> (String, SymbolizationRecord, String) +where + F: FnMut(&Path, u64) -> Option, +{ + let library_paths: HashMap = native_libraries + .iter() + .map(|artifact| { + ( + artifact.library_name.clone(), + artifact.unstripped_path.clone(), + ) + }) + .collect(); + + symbolize_android_folded_stacks_with_resolver(folded_stacks, |library_name, offset| { + let library_path = library_paths.get(library_name)?; + resolve(library_path.as_path(), offset) + }) +} + fn load_profile_manifest(path: &Path) -> Result { let body = std::fs::read_to_string(path)?; Ok(serde_json::from_str(&body)?) @@ -582,10 +660,20 @@ fn build_capture_plan( label: "simpleperf".into(), path: raw_root.join("sample.perf"), }], - vec![ArtifactRecord { - label: "flamegraph".into(), - path: processed_root.join("flamegraph.html"), - }], + vec![ + ArtifactRecord { + label: "collapsed-stacks".into(), + path: processed_root.join("stacks.folded"), + }, + ArtifactRecord { + label: "native-report".into(), + path: processed_root.join("native-report.txt"), + }, + ArtifactRecord { + label: "flamegraph".into(), + path: processed_root.join("flamegraph.html"), + }, + ], ), ProfileBackend::IosInstruments => ( vec![ArtifactRecord { @@ -1036,6 +1124,56 @@ mod tests { assert!(rendered.contains("Profile Summary")); } + #[test] + fn android_native_offsets_are_symbolized_into_rust_frames() { + let (symbolized, record, report) = symbolize_android_folded_stacks_with_resolver( + "dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1", + |library_name, offset| { + if library_name == "libsample_fns.so" && offset == 94_138 { + Some("sample_fns::fibonacci".into()) + } else { + None + } + }, + ); + + assert!(symbolized.contains("sample_fns::fibonacci")); + assert_eq!(record.status, CaptureStatus::Captured); + assert_eq!(record.resolved_frames, 1); + assert_eq!(record.unresolved_frames, 0); + assert!(report.contains("sample_fns::fibonacci")); + } + + #[test] + fn android_native_offsets_use_unstripped_library_paths() { + let unstripped_path = PathBuf::from("/cargo/target/aarch64-linux-android/release/libsample_fns.so"); + let packaged_path = PathBuf::from("/apk/jniLibs/arm64-v8a/libsample_fns.so"); + let native_libraries = vec![NativeLibraryArtifact { + abi: "arm64-v8a".into(), + library_name: "libsample_fns.so".into(), + unstripped_path: unstripped_path.clone(), + packaged_path, + }]; + let mut seen_paths = Vec::new(); + + let (symbolized, record, report) = symbolize_android_folded_stacks_with_native_libraries( + "dev.world.samplefns;libsample_fns.so[+94138] 1", + &native_libraries, + |path, offset| { + seen_paths.push((path.to_path_buf(), offset)); + Some("sample_fns::fibonacci".into()) + }, + ); + + assert!(symbolized.contains("sample_fns::fibonacci")); + assert_eq!(seen_paths.len(), 1); + assert_eq!(seen_paths[0].0, unstripped_path); + assert_eq!(seen_paths[0].1, 94_138); + assert_eq!(record.status, CaptureStatus::Captured); + assert_eq!(record.resolved_frames, 1); + assert!(report.contains("sample_fns::fibonacci")); + } + #[test] fn android_backend_builds_capture_plan_with_flamegraph_artifacts() { let plan = build_capture_plan( @@ -1066,6 +1204,16 @@ mod tests { .processed_artifacts .iter() .any(|p| p.path.ends_with("flamegraph.html"))); + assert!(plan + .native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("stacks.folded"))); + assert!(plan + .native_capture + .processed_artifacts + .iter() + .any(|p| p.path.ends_with("native-report.txt"))); } #[test] From e8aef7652b166183b0f18f79348d168aab8b52ed Mon Sep 17 00:00:00 2001 From: "dcbuilder.eth" Date: Thu, 26 Mar 2026 18:26:27 -0700 Subject: [PATCH 131/196] fix: write Android symbolized profile outputs --- Cargo.lock | 63 +++++++++++++++ crates/mobench-sdk/src/builders/ios.rs | 2 + crates/mobench/Cargo.toml | 1 + crates/mobench/src/profile.rs | 108 +++++++++++++++++++++++++ 4 files changed, 174 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 9759f8f..6eef235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,6 +81,12 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "askama" version = "0.12.1" @@ -208,6 +214,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "bytes" version = "1.11.0" @@ -839,6 +851,22 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inferno" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90807d610575744524d9bdc69f3885d96f0e6c3354565b0828354a7ff2a262b8" +dependencies = [ + "ahash", + "itoa", + "log", + "num-format", + "once_cell", + "quick-xml", + "rgb", + "str_stack", +] + [[package]] name = "inventory" version = "0.3.21" @@ -1023,6 +1051,7 @@ dependencies = [ "clap", "comfy-table", "dotenvy", + "inferno", "inventory", "jsonschema", "mobench-sdk", @@ -1124,6 +1153,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1262,6 +1301,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1440,6 +1488,15 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -1709,6 +1766,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_stack" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb" + [[package]] name = "strsim" version = "0.11.1" diff --git a/crates/mobench-sdk/src/builders/ios.rs b/crates/mobench-sdk/src/builders/ios.rs index 7b56d95..4921c75 100644 --- a/crates/mobench-sdk/src/builders/ios.rs +++ b/crates/mobench-sdk/src/builders/ios.rs @@ -272,6 +272,7 @@ impl IosBuilder { platform: Target::Ios, app_path: xcframework_path, test_suite_path: None, + native_libraries: Vec::new(), }); } @@ -333,6 +334,7 @@ impl IosBuilder { platform: Target::Ios, app_path: xcframework_path, test_suite_path: None, + native_libraries: Vec::new(), }; self.validate_build_artifacts(&result, config)?; diff --git a/crates/mobench/Cargo.toml b/crates/mobench/Cargo.toml index 9e7afaa..7071f9b 100644 --- a/crates/mobench/Cargo.toml +++ b/crates/mobench/Cargo.toml @@ -48,6 +48,7 @@ time.workspace = true sha2 = "0.10" comfy-table = "7" tempfile = "3" +inferno = { version = "0.12", default-features = false } [dev-dependencies] inventory = "0.3" diff --git a/crates/mobench/src/profile.rs b/crates/mobench/src/profile.rs index d0d8934..c7708ef 100644 --- a/crates/mobench/src/profile.rs +++ b/crates/mobench/src/profile.rs @@ -3,6 +3,7 @@ use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt::Write; +use std::io::Cursor; use std::path::{Path, PathBuf}; use crate::{resolve_devices_for_profile, DevicePlatform, MobileTarget, ResolvedMatrixDevice}; @@ -622,6 +623,71 @@ where }) } +#[allow(dead_code)] +pub(crate) fn write_android_symbolized_outputs( + folded_stacks: &str, + native_libraries: &[NativeLibraryArtifact], + processed_root: &Path, +) -> Result { + write_android_symbolized_outputs_with_resolver( + folded_stacks, + native_libraries, + processed_root, + |library_path, offset| { + mobench_sdk::builders::android::resolve_android_native_symbol_with_addr2line( + library_path, + offset, + ) + }, + ) +} + +pub(crate) fn write_android_symbolized_outputs_with_resolver( + folded_stacks: &str, + native_libraries: &[NativeLibraryArtifact], + processed_root: &Path, + resolve: F, +) -> Result +where + F: FnMut(&Path, u64) -> Option, +{ + std::fs::create_dir_all(processed_root)?; + + let (symbolized_stacks, record, report) = + symbolize_android_folded_stacks_with_native_libraries( + folded_stacks, + native_libraries, + resolve, + ); + + std::fs::write(processed_root.join("stacks.folded"), &symbolized_stacks)?; + std::fs::write(processed_root.join("native-report.txt"), &report)?; + write_android_flamegraph_html(&symbolized_stacks, &processed_root.join("flamegraph.html"))?; + + Ok(record) +} + +fn write_android_flamegraph_html(folded_stacks: &str, output_path: &Path) -> Result<()> { + if folded_stacks.trim().is_empty() { + std::fs::write( + output_path, + "